Skip to content

Marketplace Promotions & Coupons

Purpose

Discount codes ("coupons") a customer can apply at checkout, either scoped to a single seller (seller-funded) or platform-wide (platform-funded). Discount math, eligibility, funding attribution, and the final payable amount are always computed server-side from persisted snapshots - the client only ever supplies a coupon code. Builds on Payments & Seller Settlements and Marketplace Returns & Refunds; read by Marketplace Reporting & Dashboards.

Domain model

Coupon (ECommerce.Domain/Entities/Coupon.cs) - no public mutable setters; every field changes only through a named, domain-guarded method (Update, Activate, Deactivate, ConsumeUsage), mirroring Shipment/ReturnRequest's pattern.

Code                  unique, 3-32 chars, letters/digits/hyphen/underscore only
Name / Description
SellerUserId          null = platform-owned; a value = owned by that seller's owner user id
DiscountType          Percentage | FixedAmount
DiscountValue
MinimumOrderAmount    eligible subtotal must be >= this
MaximumDiscountAmount optional cap on the computed discount (Percentage type mainly)
UsageLimit            optional; null = unlimited
UsageCount            server-owned, incremented only via atomic consumption (see below)
StartsAt / EndsAt     UTC validity window
Status                Draft -> Active <-> Inactive

CouponRedemption - one durable, auditable row per successful use: CouponId, CustomerId, OrderId, and the EligibleSubtotal/DiscountAmount snapshot at redemption time. A unique index on (CouponId, CustomerId) enforces "one redemption per customer per coupon" at the database level, and a unique index on OrderId makes payment-completion retries idempotent.

Coupon ownership

A coupon is either:

  • Platform-owned (SellerUserId == null) - created only by Admin, discounts any seller's lines in the cart, and is a platform expense: the seller's own settlement basis is unaffected.
  • Seller-owned (SellerUserId set) - created by that seller (or Admin acting on their behalf), discounts only that seller's own lines, and is a seller expense: it reduces that seller's own settlement basis. CouponFundingPolicy.Calculate throws if a seller-owned coupon's allocation ever lands on a different seller's line - this can only happen from a bug, never from valid input, since CouponEligibility already excludes other sellers' lines from the eligible subtotal.

Percentage / fixed calculation

Coupon.Calculate(eligibleSubtotal, utcNow) (CouponCalculation):

Percentage:  raw = eligibleSubtotal * DiscountValue / 100
FixedAmount: raw = DiscountValue
if MaximumDiscountAmount set: raw = min(raw, MaximumDiscountAmount)
discount = min(eligibleSubtotal, round(raw, 2, AwayFromZero))
discountedSubtotal = eligibleSubtotal - discount

Throws (never silently clamps) when: Status != Active, outside [StartsAt, EndsAt], UsageLimit already reached, eligibleSubtotal < MinimumOrderAmount, or eligibleSubtotal <= 0.

Eligible subtotal (minimum order / scope resolution)

CouponEligibility.ResolveEligibleSubtotalAsync (PromotionFeatures.cs) walks the customer's own persisted cart (never a client-supplied line list) and sums only the lines the coupon can legally discount: every line for a platform coupon, or only that seller's own lines for a seller coupon. This is the same helper used by the checkout-preview endpoint and by the real Razorpay-order creation path, so a customer never sees a preview number that checkout itself would compute differently.

Line-level discount allocation

CouponDiscountAllocator.Allocate (CouponDiscountAllocator.cs) spreads a single server-calculated total discount across checkout lines proportionally to each line's gross amount, deterministically:

share_i = round(totalDiscount * grossAmount_i / eligibleSubtotal)

The last eligible line absorbs totalDiscount - Σ(already-allocated shares) instead of its own proportional share, and any leftover 1-cent remainder from rounding is pushed backwards deterministically (capped at each line's own remaining capacity) until it reconciles to exactly zero. This guarantees Σ(line discounts) == totalDiscount exactly, on every input, including inputs that don't divide evenly - the same telescoping principle Marketplace Returns & Refunds uses for cumulative tax/commission allocation. Every line's own share becomes its immutable snapshot (InventoryReservationLine, later SellerOrderLine), so later order/refund calculations never re-derive it from today's price.

Seller-funded vs platform-funded accounting

CouponFundingPolicy.Calculate classifies the total discount as either fully seller-funded (for a seller coupon) or fully platform-funded (for a platform coupon) - a coupon is never split funding between the two. This funding split is snapshotted per line and per SellerOrder:

SellerOrder.CouponDiscountAmount               = Σ(line discount)          # total discount, either funding source
SellerOrder.SellerFundedCouponDiscountAmount    = discount, only if this SellerOrder's own coupon
SellerOrder.PlatformFundedCouponDiscountAmount  = discount, only if platform-funded
SellerOrder.CustomerNetSubTotal  = SubTotal - CouponDiscountAmount           # what the customer actually paid
SellerOrder.SettlementMerchandiseAmount = SubTotal - SellerFundedCouponDiscountAmount

Platform-funded discount never reduces SettlementMerchandiseAmount - the seller's settlement basis stays the full gross value while the customer pays less; the platform absorbs the difference as its own expense. Seller-funded discount reduces both the customer's payment and the seller's own settlement basis by the same amount - the seller economically funds their own promotion.

Worked example (platform coupon, ₹100 gross, ₹20 off):

Customer pays          = 80   (SubTotal - CouponDiscountAmount)
Seller settlement basis = 100  (SubTotal - SellerFundedCouponDiscountAmount, which is 0 here)
Platform subsidy        = 20   (the gap the platform absorbs while the item is kept)

Same example, seller-funded coupon instead:

Customer pays          = 80
Seller settlement basis = 80   (SubTotal - SellerFundedCouponDiscountAmount, which is 20 here)

Minimum order, max discount, usage limits

  • Minimum order is checked against the coupon's own eligible subtotal (platform coupon: whole cart; seller coupon: only that seller's lines) - never the whole order's gross value for a seller-scoped coupon, so a small single-seller purchase inside a larger multi-seller cart is evaluated fairly.
  • Max discount caps the computed discount before allocation, so a percentage coupon can never exceed a fixed rupee ceiling.
  • Usage limit is enforced twice: an optimistic check during checkout preview/reservation (Coupon.Calculate), and an atomic, authoritative check at payment completion (see below) - the preview check exists purely for UX (fail fast before the customer reaches Razorpay), never as the actual capacity gate.

Concurrency / redemption idempotency

Two guards, each a real database-level constraint - never an in-memory check two concurrent requests could both pass:

  • Final usage slot: ICouponRepository.TryConsumeUsageAsync is a single conditional SQL UPDATE Coupons SET UsageCount = UsageCount + 1 WHERE ... AND (UsageLimit IS NULL OR UsageCount < UsageLimit). Exactly one of two concurrent payment completions racing for the last slot wins; the loser's entire order/inventory/reservation transaction rolls back (COUPON_USAGE_LIMIT_REACHED).
  • Duplicate redemption: the unique index on CouponRedemptions(CouponId, CustomerId) is the real safety net (COUPON_ALREADY_REDEEMED) - an earlier in-memory HasCustomerRedeemedAsync check (run both at checkout-order-creation time and again at payment completion) exists only to fail fast, not as the sole guard.
  • The unique index on CouponRedemptions.OrderId makes a retried payment-verification call for the same order idempotent rather than double-redeeming.

Checkout flow

1. Customer adds items to cart (multi-seller supported).
2. POST /api/coupons/preview {code}   - UI estimate only, re-validates from the live cart.
3. POST /api/payment/create-order?couponCode=...
     - resolves eligible subtotal, calculates discount, allocates per line,
       classifies seller-funded vs platform-funded, snapshots the reservation
       (InventoryReservation.CouponId/CouponCode/CouponSellerUserId/CouponEligibleSubtotal/
       CouponDiscountAmount/DiscountedSubtotal, and each InventoryReservationLine's own
       CouponDiscountAmount), then creates the Razorpay order for the DISCOUNTED total.
4. Razorpay checkout popup opens for that server-computed amount.
5. POST /api/payment/verify - VerifyAndCreateOrderCommandHandler re-derives everything from the
     reservation snapshot (never re-asks the coupon), builds SellerOrder/SellerOrderLine funding
     snapshots, reconciles Order.TotalAmount against the Razorpay-authorized amount, THEN
     atomically consumes coupon usage and records the CouponRedemption in the same transaction as
     order creation.

The client never supplies discount amount, eligible subtotal, seller/platform funding split, or the final Razorpay amount anywhere in this flow - see CreateRazorpayOrderCommand's structural test (CreateRazorpayOrderCommand_HasNoClientSuppliedFinancialFields).

Payment integration

Order.TotalAmount (built from the reservation's already-discounted line snapshots) must equal the amount Razorpay was authorized to charge, or VerifyAndCreateOrderCommandHandler throws before any DB write commits. PaymentTransaction.Amount is created equal to Order.TotalAmount - the two are reconciled by construction, never independently computed twice.

Return/refund behavior

See Marketplace Returns & Refunds for the general inspection/refund pipeline; the promotion-specific rules layered on top of it:

  • Customer refund uses SellerOrderLine.CustomerNetAmount (gross minus the full coupon discount) - a customer can never receive back more than they actually paid, so a platform subsidy is never returned to the customer.
  • Seller debit uses SellerOrderLine.SettlementMerchandiseAmount (gross minus only the seller-funded portion) - on a full return of a platform-funded item, the seller is debited the full gross value, recovering the platform's subsidy from the seller's future settlement even though the customer was only refunded the discounted amount. RefundTransaction keeps these two figures as separate fields (MerchandiseRefundAmount vs SellerMerchandiseDebitAmount) - TotalRefundAmount (what actually moves back to the customer) never includes the seller-debit delta.
  • Partial/multiple returns use the same cumulative/telescoping technique as the general refund pipeline, applied independently to both the customer-facing and seller-facing figures, so each reconciles exactly to its own line snapshot on the final returned unit regardless of rounding.
  • No coupon on the original purchase: SellerFundedCouponDiscountAmount == CouponDiscountAmount == 0, so CustomerNetAmount == SettlementMerchandiseAmount == GrossAmount - refund and seller debit are identical, exactly matching pre-promotions behavior.

Settlement behavior

See Payments & Seller Settlements for the general settlement lifecycle. GenerateSettlementCommandHandler uses SellerOrder.SettlementMerchandiseAmount (never CustomerNetSubTotal) as the merchandise basis - this is what preserves a platform subsidy in the seller's payable amount while the item is kept, and what a pre-settlement refund reduces via the refund's SellerMerchandiseDebitAmount (not its customer-facing MerchandiseRefundAmount). Post-settlement refunds create a SettlementAdjustment the same way as any other refund - the immutable settlement snapshot itself is never touched.

Reporting semantics

See Marketplace Reporting & Dashboards for the general reporting architecture. Promotions add, to both the seller dashboard/sales-report and the admin dashboard/sales-report (same underlying IMarketplaceReportingRepository code path, scoped or unfiltered exactly like every other metric there):

CouponDiscountAmount              total discount across SellerOrders created in range
SellerFundedCouponDiscountAmount  the portion funded by the seller(s) in scope
PlatformFundedCouponDiscountAmount the portion funded by the platform
CouponRedemptionCount             count of SellerOrders in range with a non-zero coupon discount
TopCoupons                        leaderboard by total discount driven, in scope

GMV is never redefined by promotions - it remains Order.SubTotal, the original pre-coupon merchandise value, exactly as before this feature (AdminDashboard_PlatformFundedCoupon_... ReportsDiscountSplitAndDoesNotRedefineGmv in ReportingFlowTests.cs asserts this explicitly). Recommended reading of the figures:

GMV                        = original merchandise value before any coupon
Customer net sales         = GMV - CouponDiscountAmount (what customers actually paid)
Seller settlement merchandise = GMV - SellerFundedCouponDiscountAmount (platform-funded subsidy preserved)
Platform-funded promotion  = a platform expense, tracked separately, never netted into commission

Every promotion figure is a SQL-side aggregation over already-persisted SellerOrder coupon-funding columns (SumAsync/GroupBy on the MySQL/Pomelo provider; the same provider-aware SQLite client-side fallback every other reporting method in this codebase uses for its own test-provider compatibility) - never an API-memory loop over Coupon/CouponRedemption rows.

Error codes

Code HTTP Meaning
COUPON_NOT_FOUND 404 No coupon with that code/id
COUPON_FORBIDDEN 403 Not this seller's coupon, and caller isn't Admin
COUPON_ALREADY_EXISTS 409 A coupon with this code already exists
COUPON_INVALID_TRANSITION 409 e.g. editing financial rules on an already-Active coupon
COUPON_NOT_ACTIVE 409 Coupon is Draft or Inactive
COUPON_EXPIRED 409 Outside [StartsAt, EndsAt]
COUPON_USAGE_LIMIT_REACHED 409 UsageLimit already consumed (checkout preview or the atomic final-slot race)
COUPON_MINIMUM_ORDER_NOT_MET 400 Eligible subtotal below MinimumOrderAmount
COUPON_ALREADY_REDEEMED 409 This customer already redeemed this coupon (unique-index race included)
COUPON_INVALID 400 Any other coupon validation failure (bad code format, invalid financial rule, empty cart)

Never a raw EF/MySQL error - see ExceptionHandlingMiddleware.cs.

Frontend

  • Customer: cart/checkout coupon code input with Apply/Remove, a discount-summary line, and the final payable total - all recomputed from the server's own preview/order-creation response, never computed client-side alone. Invalid/expired/minimum-order/usage-limit/already-redeemed states surface the server's own message via the shared COUPON_* error codes.
  • Seller: /seller/coupons - list (with status filter), create, edit, activate/deactivate, and usage count (UsageCount / UsageLimit). Every coupon created here is seller-owned by construction; the UI states plainly that it is seller-funded.
  • Admin: /admin/coupons - list every coupon (platform- and seller-owned, filterable by owner), create platform coupons, edit/activate/deactivate any coupon (the backend authorizes Admin on the same seller-scoped endpoints), and an ownership badge on every row.
  • The client never supplies discount amount, eligible subtotal, seller/platform funding split, or the final Razorpay amount anywhere in any of these flows - every financial figure is server-computed and merely displayed.

Deferred work (explicitly out of scope for this slice)

  • Real Razorpay refund integration - a return against a coupon-discounted order still only ever records a RefundTransaction/SettlementAdjustment in this codebase (see Marketplace Returns & Refunds); no payment-provider refund API call exists anywhere.
  • Automatic seller payout - SellerFundedCouponDiscountAmount's effect on SettlementMerchandiseAmount only ever changes the computed NetPayableAmount; no automated deduction or payout occurs.
  • No production deployment of this feature - this slice has been validated against a local MySQL 8.2 instance and the SQLite test provider only (see the branch's PR description for the local migration/smoke-test results); no staging/production database has been migrated.
  • Coupon stacking - a checkout may apply at most one coupon at a time; combining a platform and a seller coupon in the same order is not supported.
  • Category/product-scoped coupons - eligibility is always seller-scoped (or platform-wide), never narrowed to specific products/categories within a seller's catalog.
  • Per-customer usage limits below the redemption cap of 1 - UsageLimit is a marketplace-wide cap; the per-customer cap is fixed at exactly one redemption and is not itself configurable.