Skip to Content
MarketplaceCoupons & Promo Codes

Coupons & Promo Codes

Coupons let us run marketing promotions that discount the buyer’s total at checkout. The system is bespoke — we don’t use Stripe’s native coupon feature. Instead, we manage coupon logic internally and create ephemeral Stripe coupons on-the-fly at checkout for clean display.

Package@repo/coupon (domain), @repo/checkout (feature integration)
PatternReservation pattern with atomic slot claim via MongoDB transactions
Key Filescoupon.service.ts, coupon.errors.ts, checkout.service.ts, checkout.handler.ts

Why Not Stripe-Native Coupons?

We evaluated Stripe’s built-in coupon system and decided against it for several reasons:

IssueWhy it matters
Wrong party affectedStripe coupons reduce the amount captured, which messes up Connect fund splits — the seller would absorb the discount instead of the platform
Two Stripe accountsNA and EU use separate Stripe accounts, making coupon sync tedious and error-prone
Refund mathStripe-native discounts make partial refund calculations dangerous
Limited anti-gamingStripe provides basic limits, but we need per-user limits, region restrictions, self-purchase checks, and new-buyer-only coupons

Instead, the platform absorbs the discount cost as a marketing expense. Sellers receive their full payout regardless of whether a coupon was used.

How It Works — The Big Picture

Coupon Types

Coupons support two discount types:

Applies a percentage off the transaction subtotal (items + shipping, pre-fee).

  • value is a number from 1–100 representing the percentage
  • Use maximumDiscountAmount to cap the discount (e.g., “20% off, up to $50”)
  • Currency-agnostic — works across all currencies

Example: A 25% coupon on a 80subtotal=80 subtotal = 20 discount

Coupon Configuration

Each coupon has a rich set of configuration options:

FieldDescription
codeUnique, uppercase string (e.g., LAUNCH25). Immutable after creation
typepercentage or fixed_amount
valuePercentage (1-100) or amount in cents
currencyRequired for fixed_amount coupons
regionNA, EU, or null (all regions)
applicableCurrenciesRestrict to specific currencies, or empty for all
maxRedemptionsGlobal usage limit, or null for unlimited
maxRedemptionsPerUserPer-user limit (default: 1)
minimumOrderAmountMinimum subtotal in cents, or null for no minimum
maximumDiscountAmountCap the discount in cents (important for percentage coupons)
startsAt / expiresAtValidity window. expiresAt can be null for no expiry
isActiveAdmin kill switch
excludeSelfPurchasePrevent sellers from using a coupon on their own items
newBuyersOnlyOnly users with zero completed purchases

Validation Rules

When a buyer applies a coupon, we run through these checks in order:

The validate endpoint (coupon.validate) exposes each validation error as a separate typed oRPC error — the frontend can match on the error code directly (e.g., COUPON_EXPIRED, COUPON_MINIMUM_NOT_MET) for granular user messages.

At checkout time (checkout.create), coupon validation errors are collapsed into a single COUPON_INVALID error with { code, reason } data. This keeps the checkout contract focused on checkout-level concerns while still providing the underlying reason for debugging.

Reservation Pattern

A key challenge is preventing over-consumption of limited coupons. If a coupon has maxRedemptions: 100 and 100 people try to check out simultaneously, we need exactly 100 to succeed.

We use a reservation pattern similar to inventory reservations:

Validate (read-only preview)

The buyer-facing coupon.validate endpoint performs a non-atomic read check. This is fine for preview — it’s OK to show “valid” to a user who might get rejected at checkout if someone else grabs the last slot.

Reserve (atomic slot claim)

At checkout, reserveCoupon atomically increments redemptionCount with a guard:

// Only succeeds if capacity remains CouponModel.findOneAndUpdate( { _id: couponId, $expr: { $lt: ["$redemptionCount", "$maxRedemptions"] } }, { $inc: { redemptionCount: 1 } }, { session } // Inside MongoDB transaction )

This runs inside the checkout’s withTransaction, so if anything downstream fails (inventory, Stripe), the increment is automatically rolled back.

Release (on expiry)

If the checkout session expires without payment, releaseCouponForTransaction decrements the counter:

// Guard: only decrement if count > 0 (prevents going negative) CouponModel.updateOne( { _id: couponId, redemptionCount: { $gt: 0 } }, { $inc: { redemptionCount: -1 } }, { session } )

This is idempotent — safe to call from both expireExistingCheckout and the Stripe expiry webhook.

Record (on payment success)

When the webhook confirms payment, recordRedemption creates a CouponRedemption document using $setOnInsert (upsert keyed by transactionId), making webhook retries idempotent.

The redemptionCount is incremented at reservation time (checkout creation), not at redemption time (payment success). This prevents a race where two people both validate and pay before the count is updated.

Checkout Integration

When a buyer provides a couponCode in the checkout request, the flow is:

Validate the coupon

Call couponService.validateCoupon() with cart context (subtotal, region, currency, seller IDs).

Reserve the slot

Call couponService.reserveCoupon() inside the MongoDB transaction.

Create an ephemeral Stripe Coupon

We create a one-time Stripe coupon object with the calculated amount_off. This is purely for display — Stripe shows a clean “discount” line on the payment page.

stripe.coupons.create({ amount_off: discountAmount, currency, duration: "once", max_redemptions: 1, name: `Promo: ${code}`, })

Create the Checkout Session

Pass the Stripe coupon ID in the discounts parameter. Stripe reduces the total charged accordingly.

Store on Transaction

The transaction stores couponId, couponCode, and couponDiscountAmount for audit and downstream use.

Minimum Order Amount

Orders below 100 cents (1.00 in any currency) are rejected at checkout with an ORDER_TOTAL_TOO_LOW error. This prevents tiny/accidental orders that would cost more to process than they’re worth.

const MINIMUM_ORDER_AMOUNT = 100 // cents

However, this check is bypassed when a promo code is present. The rationale: a coupon might intentionally bring the total to zero (or near-zero), and that’s a valid marketing scenario. If someone has a 100% coupon, we don’t want to reject their free order.

// Only enforce minimum when no promo code if (!couponCode && subtotalWithShipping + buyerFee.total < MINIMUM_ORDER_AMOUNT) { return err(CheckoutErr.orderTotalTooLow(MINIMUM_ORDER_AMOUNT, cart.currency)) }

The minimum order check runs before coupon validation in the checkout flow. This means a buyer without a coupon is rejected early, while a buyer with a coupon skips this check entirely and proceeds to coupon validation.

Stripe Minimum Charge Auto-Absorb

Stripe enforces minimum charge amounts that vary by currency. If a coupon brings the payable total below the minimum but above zero, Stripe would reject the payment with an amount_too_small error.

CurrencyStripe minimum
USD50 cents ($0.50)
EUR50 cents (€0.50)
CAD50 cents (CA$0.50)
CHF50 centimes (CHF 0.50)
GBP30 pence (ÂŁ0.30)
SEK300 ore (3.00 SEK)
DKK250 ore (2.50 DKK)
NOK300 ore (3.00 NOK)
PLN200 groszy (2.00 PLN)
HUF175 forint (175 HUF)

To prevent this, the checkout service auto-absorbs the remaining amount into the discount when it falls into the danger zone (between 1 cent and the Stripe minimum). The discount is increased to cover the full order total, making the order effectively free.

const remainder = stripeTotal - cappedDiscount const stripeMin = STRIPE_MINIMUM_CHARGE[cart.currency] ?? 50 // If remainder is between 1 cent and Stripe's minimum, absorb it if (remainder > 0 && remainder < stripeMin) { cappedDiscount = stripeTotal // order becomes free }

Example: A buyer has a 10.00cartandappliesa10.00 cart and applies a 9.80 coupon. The remainder would be 0.20—belowStripe′sUSDminimumof0.20 — below Stripe's USD minimum of 0.50. Instead of failing, the discount is bumped to $10.00 and the order goes through as free.

The auto-absorbed amount is persisted as couponDiscountAmount on the transaction. The database always reflects what Stripe actually charged (or didn’t charge), not the coupon’s original calculated discount.

The three possible outcomes when a coupon is applied:

ScenarioRemainderWhat happens
Remainder >= Stripe minimume.g., $5.00Normal charge — Stripe processes the remainder
Remainder is 0$0.00Free order — no Stripe charge, session created without payment
Remainder between 1 cent and Stripe minimume.g., $0.20Auto-absorbed — discount increased, order becomes free

How Discounts Flow Through Orders

Coupons apply at the transaction level (all sellers combined). When orders are created from a transaction, the discount is prorated across orders proportionally by subtotal:

Order discount = couponDiscount Ă— (orderSubtotal / transactionSubtotal)

The last order absorbs any rounding remainder to ensure the sum exactly matches.

Example: 10coupononatransactionwithtwoorders(10 coupon on a transaction with two orders (60 and $40 subtotals):

  • Order 1: 10Ă—60/100=10 Ă— 60/100 = 6
  • Order 2: 10Ă—40/100=10 Ă— 40/100 = 4

Seller Payout Math

The coupon discount reduces the charged (what Stripe holds for each order), but the transfer formula compensates for this. Sellers are completely unaffected:

transferAmount = charged - refunded - sellerFee - buyerFee - tax - transferred + couponDiscount

Without a coupon: charged includes the full amount, couponDiscount is 0. With a coupon: charged is reduced by the discount, but + couponDiscount adds it back.

Net result: sellerTransfer = orderSubtotal + shipping - sellerFee — identical with or without a coupon.

This transfer amount is pre-computed as funds.distribution.sellerPayout when the order reaches a fund-releasable status (completed, resolved_partial_refund, resolved_no_refund). The distribution is immutable once computed — the fund release job reads it directly without recomputing.

This is a critical design property. If you modify the transfer formula or the proration logic, always verify that seller payouts remain unchanged when a coupon is applied.

Error Reference

Errors are surfaced through two endpoints with different error granularity.

Validate Endpoint (coupon.validate)

The validate endpoint exposes each coupon error as a separate typed oRPC error. The frontend can match on the error code directly for granular user messaging.

Error CodeDataWhen
CART_EMPTY{}Cart is empty (need cart context to validate)
COUPON_NOT_FOUND{ code }Code doesn’t exist
COUPON_NOT_YET_ACTIVE{ code }Before startsAt
COUPON_EXPIRED{ code }After expiresAt
COUPON_INACTIVE{ code }Admin deactivated
COUPON_MAX_REDEMPTIONS_REACHED{ code }Global limit hit
COUPON_USER_LIMIT_REACHED{ code }Per-user limit hit
COUPON_MINIMUM_NOT_MET{ code, minimumAmount }Subtotal below coupon’s minimum
COUPON_REGION_MISMATCH{ code }Wrong marketplace region
COUPON_CURRENCY_MISMATCH{ code }Wrong currency
COUPON_SELF_PURCHASE{ code }Buyer is also seller
COUPON_NEW_BUYERS_ONLY{ code }User has prior purchases

Checkout Endpoint (checkout.create)

The checkout endpoint collapses all coupon validation errors into a single COUPON_INVALID error. It also adds checkout-level errors unrelated to coupons.

Error CodeDataWhen
CART_EMPTY{}Cart has no items
ITEM_UNAVAILABLE{ inventoryLineId, reason }Item sold, unlisted, or insufficient quantity
PRICE_CHANGED{ inventoryLineId, cartPrice, currentPrice }Price increased since added to cart
SELLER_CANNOT_SELL{ sellerId, inventoryLineId }Seller’s Stripe Connect account is not active
TRANSACTION_CREATION_FAILED{ reason }Database error creating transaction
STRIPE_SESSION_FAILED{ reason }Stripe API error
COUPON_INVALID{ code, reason }Coupon failed validation (reason contains the specific coupon error code)
ORDER_TOTAL_TOO_LOW{ minimumAmount, currency }Order below 100 cents with no promo code

At the checkout level, COUPON_INVALID.data.reason contains the underlying coupon error code (e.g., "COUPON_EXPIRED", "COUPON_REGION_MISMATCH"). The frontend should use the validate endpoint for showing inline validation messages and reserve the checkout errors for the actual payment flow.

Admin API

Admins manage coupons through the marketplace-admin API:

EndpointDescription
coupons.createCreate a new coupon with all configuration
coupons.updateUpdate coupon fields (code is immutable)
coupons.getGet a single coupon by ID
coupons.listList coupons with filters (search, active, type, region)
coupons.deactivateSet isActive = false (soft delete)
coupons.listRedemptionsList redemptions for a specific coupon

The code field is immutable after creation. This avoids duplicate-key edge cases and preserves audit integrity — redemption records reference the original code.

Data Model

Code Locations

ComponentLocation
Coupon Modelpackages/core/db/src/mongo/marketplace/coupon.model.ts
CouponRedemption Modelpackages/core/db/src/mongo/marketplace/coupon-redemption.model.ts
Coupon Domain Servicepackages/backend/domains/coupon/src/coupon.service.ts
Coupon Errorspackages/backend/domains/coupon/src/coupon.errors.ts
Stripe Coupon Helperspackages/integrations/stripe/src/payments/coupons.ts
Checkout Integrationpackages/backend/features/checkout/src/checkout.service.ts
Checkout Errorspackages/backend/features/checkout/src/checkout.errors.ts
Checkout Contractpackages/core/api-dtos/src/lib/checkout/create.ts
Validate Coupon Contractpackages/core/api-dtos/src/lib/coupon/validate-coupon.ts
Webhook Handlerpackages/backend/features/stripe-webhooks/src/lib/handlers/checkout.handler.ts
Prorate Discountpackages/backend/features/orders/src/prorate-coupon-discount.ts
Transfer Calculationpackages/backend/features/orders/src/compute-transfer-amount.ts
Buyer API Contractpackages/core/api-dtos/src/lib/coupon/
Admin Contractspackages/backend/marketplace-admin/src/contracts/coupons/
Admin Handlerspackages/backend/api/src/lib/orpc/handlers/marketplace-admin/coupons/
Last updated on