coupon system - mukular/food-delivery-feastfast--architecture GitHub Wiki
This document explains the coupon architecture, rules engine, edge-case handling, and design decisions behind the coupon system used in this food delivery platform.
The system is designed to be:
- Race-condition resistant
- Scalable
- Easy to reason about
- Aligned with real-world platforms (Swiggy / Zomato style)
The coupon system supports:
- Restaurant-specific coupons
- Percentage / Fixed / Free-item discounts
- Per-user usage limits
- Global usage limits
- Category & item-level restrictions
- Coupon preview during checkout
- Final validation during order creation
| Type | Description |
|---|---|
| percentage | % discount with optional max cap |
| fixed | Flat amount discount |
| free_item | Adds a free item to cart |
{
code: String,
shop: ObjectId,
discountType: "percentage" | "fixed" | "free_item",
discountValue: Number,
freeItem: {
id: ObjectId,
name: String
},
minOrderAmount: Number,
maxDiscountAmount: Number,
startDate: Date,
endDate: Date,
usageLimitPerUser: Number,
usageLimitTotal: Number,
usedCount: Number,
applicableCategories: [String],
excludedItems: [{ id, name }],
userUsed: [{
userId,
usedCount,
lastUsed
}]
}Step-by-step lifecycle:
Coupon Created
↓
Coupon Applied (Checkout Preview)
↓
Coupon Validated (Create Order)
↓
Coupon Consumed
- Show discount to user
- Validate basic eligibility
- Does NOT mutate database
- Active status
- Date validity
- Min order amount
- Category match
- Excluded items check
- User usage limit
- Total usage limit
{
"discount": 40,
"appliedCoupon": {
"code": "SAVE40",
"discountType": "percentage"
}
}Coupon is re-validated during order creation to prevent:
- Tampering
- Race conditions
- Stale client data
Frontend cannot be trusted.
Consume coupon during order creation, not during payment webhook.
| Problem | Explanation |
|---|---|
| Race conditions | Webhooks can arrive late |
| Overuse | Multiple users can exceed limit |
| Complexity | Reservation systems add bugs |
| Refund cost | Payment gateways charge fees |
This mirrors real production systems.
When coupon is consumed:
- Wallet payment → immediately
- COD → immediately
- Online payment → immediately (before payment)
coupon.usedCount += 1;
if (userUsed exists) {
userUsed.usedCount += 1;
} else {
push new userUsed entry
}Atomicity is ensured by:
- MongoDB session (transaction)
- Single write path
All coupon mutations and order creation occur within the same database transaction, ensuring consistency under concurrent requests.
Two users apply the last available coupon simultaneously.
Final check during order creation:
if (coupon.usedCount >= coupon.usageLimitTotal) {
reject coupon
}Only one transaction succeeds.
- No price discount applied
- A new item is injected into
shopOrderItems
shopOrderItems.push({
item: freeItem.id,
name: freeItem.name,
price: 0,
quantity: 1
});- Appears in order summary
- Included in seller view
- Included in delivery packing list
Coupon applies only if at least one item matches category.
Coupon is rejected if any excluded item is present.
if (excludedItemFound) {
return "Coupon not applicable for X item";
}Coupons use UTC timestamps:
startDate <= now < endDate
Important: endDate is treated as end-of-day using normalization when needed.
Considered but rejected because:
| Issue | Reason |
|---|---|
| Complexity | Hard to reason about |
| Refund costs | Merchant bears loss |
| UX confusion | User pays but coupon invalid |
| Scale issues | Redis locks explode |
This project chooses determinism over perfection.
| Scenario | Result |
|---|---|
| Payment fails | Coupon remains consumed |
| Order cancelled | Coupon not restored |
| Webhook delayed | No effect |
| Multiple tabs | Safe |
This is an intentional tradeoff.
- Coupon validation happens server-side only
- No trust in frontend discount
- Idempotent coupon updates
- Coupon tied to shop
Coupons are scoped to a single shop and are never applied across multiple restaurants within a single order.
- Coupon fetched only for visible shops
- Batched queries (
$in) - Lean queries where possible
- Race-safe
- Predictable
- Scales cleanly
- Real-world aligned
- Easy to debug