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) |
| Pattern | Reservation pattern with atomic slot claim via MongoDB transactions |
| Key Files | coupon.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:
| Issue | Why it matters |
|---|---|
| Wrong party affected | Stripe coupons reduce the amount captured, which messes up Connect fund splits — the seller would absorb the discount instead of the platform |
| Two Stripe accounts | NA and EU use separate Stripe accounts, making coupon sync tedious and error-prone |
| Refund math | Stripe-native discounts make partial refund calculations dangerous |
| Limited anti-gaming | Stripe 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:
Percentage
Applies a percentage off the transaction subtotal (items + shipping, pre-fee).
valueis a number from 1–100 representing the percentage- Use
maximumDiscountAmountto cap the discount (e.g., “20% off, up to $50”) - Currency-agnostic — works across all currencies
Example: A 25% coupon on a 20 discount
Coupon Configuration
Each coupon has a rich set of configuration options:
| Field | Description |
|---|---|
code | Unique, uppercase string (e.g., LAUNCH25). Immutable after creation |
type | percentage or fixed_amount |
value | Percentage (1-100) or amount in cents |
currency | Required for fixed_amount coupons |
region | NA, EU, or null (all regions) |
applicableCurrencies | Restrict to specific currencies, or empty for all |
maxRedemptions | Global usage limit, or null for unlimited |
maxRedemptionsPerUser | Per-user limit (default: 1) |
minimumOrderAmount | Minimum subtotal in cents, or null for no minimum |
maximumDiscountAmount | Cap the discount in cents (important for percentage coupons) |
startsAt / expiresAt | Validity window. expiresAt can be null for no expiry |
isActive | Admin kill switch |
excludeSelfPurchase | Prevent sellers from using a coupon on their own items |
newBuyersOnly | Only 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 // centsHowever, 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.
| Currency | Stripe minimum |
|---|---|
| USD | 50 cents ($0.50) |
| EUR | 50 cents (€0.50) |
| CAD | 50 cents (CA$0.50) |
| CHF | 50 centimes (CHF 0.50) |
| GBP | 30 pence (ÂŁ0.30) |
| SEK | 300 ore (3.00 SEK) |
| DKK | 250 ore (2.50 DKK) |
| NOK | 300 ore (3.00 NOK) |
| PLN | 200 groszy (2.00 PLN) |
| HUF | 175 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 9.80 coupon. The remainder would be 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:
| Scenario | Remainder | What happens |
|---|---|---|
| Remainder >= Stripe minimum | e.g., $5.00 | Normal charge — Stripe processes the remainder |
| Remainder is 0 | $0.00 | Free order — no Stripe charge, session created without payment |
| Remainder between 1 cent and Stripe minimum | e.g., $0.20 | Auto-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: 60 and $40 subtotals):
- Order 1: 6
- Order 2: 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 + couponDiscountWithout 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 Code | Data | When |
|---|---|---|
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 Code | Data | When |
|---|---|---|
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:
| Endpoint | Description |
|---|---|
coupons.create | Create a new coupon with all configuration |
coupons.update | Update coupon fields (code is immutable) |
coupons.get | Get a single coupon by ID |
coupons.list | List coupons with filters (search, active, type, region) |
coupons.deactivate | Set isActive = false (soft delete) |
coupons.listRedemptions | List 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.