Menu

Coupon Console

Good to know

The coupon console is new in v4.0.0, at /dashboard/admin/coupons. It applies to plans with provider: 'stripe' only.

The premise: Stripe is the only store

No local table, no migration, no coupon data stored locally. Every interaction on the page reads and writes Stripe directly.

The reasoning is simple: how many times a code was redeemed, whether it expired, whether it was disabled — Stripe is always the source of truth. Keeping a local copy would only create sync problems for zero benefit, since coupons are read and written far too rarely to need a cache.

The cost is that listing is limited by what the Stripe API can express — see "The listing scan cap" below.

The data model: one coupon is two objects

Stripe splits a coupon into two layers, and the boilerplate follows that semantics:

Coupon (the discount itself)
├── Discount: percent_off or amount_off (mutually exclusive)
├── Scope: applies_to.products (empty = every product)
└── Billing duration: duration = once | repeating | forever
                      repeating also needs duration_in_months
 
Promotion Code (what the customer types)
├── code: the string entered at checkout, e.g. LAUNCH20
├── max_redemptions: total redemption cap
├── expires_at: expiry
└── restrictions: first_time_transaction (new customers only) / minimum_amount

Both objects are created in one shot. If the second step (the promotion code) fails — a duplicate code being the usual cause — the freshly created coupon is deleted, so a rejected form never leaves an orphan behind.

The constraint that matters most: a code is immutable

Once a Stripe promotion code exists, nothing about it can be changed except the enable/disable switch. Past invoices reference it, so Stripe never truly deletes it either.

This constraint shapes the whole UI:

What you want to doWhat you can actually do
Change the discount❌ Disable the old code, duplicate it, edit, create a new one
Change which plans it covers❌ Same
Change the expiry❌ Same
Change the redemption cap❌ Same
Stop handing it out✅ Turn off the enable switch
Delete it❌ Stripe does not support it; disabling is deletion

So a row has exactly two actions: the enable switch and duplicate. "Duplicate" prefills the create form with every parameter of the current code; edit and submit produces a new one.

coupon-list

Statuses

Four statuses as an operator sees them:

StatusMeaningCan it be re-enabled
activeLive and usable
disabledManually turned off✅ Can be turned back on
expiredPast its expires_at❌ Terminal
exhaustedRedemption cap reached❌ Terminal

expired and exhausted are separate values rather than one merged "inactive" precisely because Stripe refuses to re-enable them — the row's toggle reads its disabled state straight off the status.

Creating a coupon

coupon-create

Form fields:

FieldDescription
CodeLeave empty and Stripe generates one. A custom one must match ^[A-Z0-9_-]{3,30}$ and is upper-cased automatically
NameThe coupon display name, shown in the Stripe dashboard and on invoices; max 40 characters
Discount typePercentage (0 < x ≤ 100) or a fixed amount (integer cents). Mutually exclusive
Applies toMulti-select, up to 20 plans. Empty = every product
Billing durationonce (first invoice only) / repeating (the first N months, 1–36) / forever (every invoice)
Redemption capEmpty = unlimited
ExpiryQuick presets (never / 7 / 30 / 90 days) or a custom value. Must be in the future
New customers onlyRestricted to customers who never paid before
Minimum order amountInteger cents; empty = no minimum

Two interaction details are worth calling out:

Live availability check. As you type a code, checkPromotionCodeAvailability asks Stripe whether it is taken, so you learn about a duplicate before submitting rather than through a failed creation.

The summary sentence. The bottom of the dialog states in plain language what the customer actually gets — "$10 off each of the first 3 months, Pro Monthly only, new customers only", for instance. Discount, scope and duration are easy to miscombine, and that sentence is the safety net.

How the scope reaches Stripe

The form selects plan slugs, which are translated into Stripe product ids on submit:

planId (config/pricing.ts)
    ↓ getPlanById + isStripePlan validation
Stripe Price ID
    ↓ reverse lookup
Stripe Product ID
    ↓ written into
coupon.applies_to.products

If a selected plan is not sold through Stripe (its provider is not stripe), the request is rejected and names the offending plan.

Mind test vs live mode

Product ids differ between test and live mode. A coupon created in test mode only applies to test-mode products, so before going live you must recreate the same code in live mode.

Auto-apply

With a promotion code configured in config/pricing.ts, checkout applies it automatically and the customer types nothing:

config/pricing.ts
// Site-wide campaign
export const pricingCampaign: PricingCampaign = {
  promotionCode: 'LAUNCH20',
}
 
// Or per plan (higher precedence)
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }

Auto-applied codes are badged in the listing, and that badge matters: such a code is live for every visitor, so disabling one silently drops the site-wide discount. Customers see no error at all, only a changed price. Check that config/pricing.ts no longer references a code before disabling it.

Conversely, if the configured code does not exist or is inactive in the current mode, checkout does not fail — it falls back to the provider's manual input. A stale campaign config never blocks a sale.

See the promotion code section of Pricing Configuration.

The listing scan cap

Stripe's promotion code list endpoint offers cursor pagination only, with no text search. Giving operators "search by code + filter by status + filter by plan" therefore means pulling the whole set once and narrowing it in memory.

Hence a hard cap:

const MAX_SCANNED_CODES = 500

At most 500 codes per scan (100 per page, auto-paged). The result carries two extra fields:

FieldPurpose
scannedHow many codes Stripe held before filtering — the denominator in "0 of 42"
truncatedWhether the scan hit the 500 cap, meaning older codes were dropped

When truncated is true the page shows a scan-cap warning. Stripe returns newest first, which is exactly the order an operator expects right after creating a code, so that order is preserved rather than re-sorted.

If your code count stays above 500, it is time for a different strategy — a shared prefix managed in the Stripe dashboard, or periodic cleanup of historical codes.

The server-side API

actions/coupons/admin.ts, four actions, all admin-guarded:

ActionPurpose
getAdminPromotionCodes({ pageIndex, pageSize, search?, status?, appliesTo? })Listing: one scan plus in-memory filtering and pagination
checkPromotionCodeAvailability(code)Pre-submit duplicate check
createAdminPromotionCode(input)Create the coupon + promotion code, rolling back the coupon if the promo step fails
setPromotionCodeActive({ id, active })The only mutation Stripe allows

When Stripe is not configured, every action returns an error and the page renders an unavailable state.

FAQ

Q: What about coupons for Creem and PayPal?

This console covers Stripe only. Creem uses its own discountCode (a promotion code configured in config/pricing.ts is passed to Creem as discountCode at checkout); PayPal does not support auto-applied promotion codes.

Stripe promotion codes can only be restricted to Stripe products, which is the root reason the console is Stripe-only.

Q: Why can't a coupon be edited?

That is a Stripe constraint, not a boilerplate choice. Promotion codes are referenced by historical invoices, and allowing edits would make past billing records untrustworthy.

Q: Can a disabled code still be used?

Not for new redemptions. But a subscription that already used it keeps the discount under the original terms if the duration is repeating or forever — disabling only stops new redemptions.

Q: How do I see how many times a code was used?

The listing shows redemption progress (redeemed / cap). For more detail, the row's deep link opens the matching Stripe dashboard page in the right mode.