Discounts & Promotions
Admin-managed discount codes for the quote flow, plus Stripe promotion codes for card checkout.
The boilerplate ships two independent discount paths, one for each checkout mode:
- The native discounts engine powers the default quote flow. Admins create codes in the dashboard; shoppers type a code in the cart before requesting a quote. The applied code and the dollar amount it took off are stored on the resulting quote and surfaced in the admin dashboard and quote emails.
- Stripe promotion codes power card checkout. When Stripe is enabled, the Checkout Session opens with
allow_promotion_codes: true, so shoppers enter Stripe-managed codes on Stripe's hosted payment page. The native codes never touch this path.
The two systems do not share codes. A code you create in the admin dashboard works only in the quote flow; a code you create in the Stripe dashboard works only on the Stripe payment page. Pick the path that matches how the store takes orders.
The native discounts engine (quote flow)
Data model
A discount is stored in Firestore at discounts/{CODE} — the document id is the uppercase code, so a lookup is a single document read and saving the same code twice overwrites it in place. The model lives in src/lib/firebase/firebaseDataConverters.ts:
export const DISCOUNT_TYPES = ["percent", "fixed"] as const;
export type DiscountType = (typeof DISCOUNT_TYPES)[number];
export interface Discount {
/** Firestore document id = the UPPERCASE code (set when read back). */
id?: string;
/** The code customers type, e.g. "WELCOME10". Stored uppercase. */
code: string;
type: DiscountType;
/** Percent 1-100 for `"percent"`, a dollar amount > 0 for `"fixed"`. */
value: number;
/** Inactive codes are kept but cannot be applied. */
active: boolean;
/** Optional expiry; the code is usable through this instant. */
expiresAt?: Date | null;
created: Date;
}| Field | Type | Notes |
|---|---|---|
id | string? | The Firestore document id (the uppercase code); set only when read back. |
code | string | The code shoppers type. Trimmed and uppercased on save; only letters, digits, and dashes are allowed. |
type | "percent" | "fixed" | Percentage-off or fixed-dollar-off. |
value | number | For percent, a whole percent 1–100. For fixed, a dollar amount > 0. |
active | boolean | Inactive codes are kept but cannot be applied. |
expiresAt | Date | null | Optional expiry instant. A missing/null value never expires. |
created | Date | When the code was created; the admin list is ordered newest first. |
Discount math
All discount arithmetic lives in src/lib/discounts.ts as two pure, backend-free functions (unit-tested in tests/discounts.test.ts). They take an injectable now clock and never touch Firebase.
isDiscountUsable decides whether a code can currently be applied — it must be active and not past its expiresAt:
export function isDiscountUsable(
discount: Pick<Discount, "active" | "expiresAt">,
now: Date = new Date()
): boolean {
if (!discount.active) return false;
if (discount.expiresAt && discount.expiresAt.getTime() < now.getTime()) {
return false;
}
return true;
}computeDiscountedTotal applies a code to a subtotal and returns the amount taken off and the new total:
"percent"takesvalue% of the subtotal, rounded to cents."fixed"takesvaluedollars, clamped to the subtotal so the total never goes negative.- Non-finite or negative inputs are treated as
0, so the result is always0 <= discountAmount <= subtotalandtotal = subtotal - discountAmount.
export interface DiscountedTotal {
/** Dollar amount taken off the subtotal (rounded to cents, never negative). */
discountAmount: number;
/** Subtotal minus the discount (rounded to cents, never negative). */
total: number;
}The commerce service API
Discounts are exposed through the commerce service layer as commerce.discounts. Import it once and stay backend-agnostic — the same calls work whether the active backend is Firebase (default) or a SQL adapter over HTTP:
import { commerce } from "@/lib/commerce";
const discount = await commerce.discounts.findUsable("WELCOME10");
// -> Discount when the code exists, is active, and has not expired;
// null otherwise.The DiscountService interface (src/lib/commerce/types.ts) has four methods. One is public; three are admin-only:
| Method | Access | What it does |
|---|---|---|
findUsable(code) | public | Looks up a code (case-insensitive) and returns it only when currently usable, else null. The one call the storefront needs. |
list() | admin | Lists all discount codes, newest first. |
save(discount) | admin | Creates or updates a code. The uppercase code is the record key, so saving an existing code overwrites it in place. |
remove(code) | admin | Deletes a code by its code (case-insensitive). |
findUsable deliberately collapses "doesn't exist", "inactive", and "expired" into a single null result — the storefront (and shoppers) never learn which of the three it was. It runs isDiscountUsable on the stored code before returning.
The access column above is enforced by the RPC route when a SQL backend is active. In src/app/api/commerce/[service]/[method]/route.ts the policy is:
discounts: {
findUsable: "public",
list: "admin",
save: "admin",
remove: "admin",
},See Commerce RPC for how public / user / admin are checked on the server.
How a code flows through the cart
Admin creates a code in the dashboard (or via commerce.discounts.save). The code is trimmed and uppercased before it is written to discounts/{CODE}.
Shopper types the code in the cart and applies it. The cart calls commerce.discounts.findUsable(code). If it returns null, the shopper sees an "invalid code" toast; otherwise the discount is held on the cart.
The cart re-prices live. computeDiscountedTotal(subtotal, appliedDiscount) runs against the current subtotal, so the discount stays correct as the shopper changes quantities after applying the code.
The applied terms are frozen onto the quote. When the quote request is submitted, the cart records an AppliedDiscount — the code's terms at application time plus the computed amount.
The record stored on the quote is the AppliedDiscount shape (distinct from the reusable Discount — it captures a point-in-time application, not the live code):
export interface AppliedDiscount {
code: string;
type: DiscountType;
value: number;
/** Dollar amount deducted from the quote subtotal. */
amount: number;
}It rides along on the quote submission as quoteData.discount and is stored on the quote verbatim (and later carried onto the order when the quote is converted). Both Quote and Order carry an optional discount?: AppliedDiscount.
Where applied discounts show up
- Admin quotes dashboard — each quote that carried a code shows
Discount: {code} (−{amount}). - Quote emails — the quote email template renders
Discount {code}: −{amount}when a discount is present (emails require Resend to be configured; see Emails).
Managing codes in the admin dashboard
The admin page at /admin/discounts (source: src/app/[locale]/admin/discounts/page.tsx) is a full CRUD table over commerce.discounts.
Creating a code. The "New discount" dialog validates before saving:
| Field | Rule |
|---|---|
| Code | Required. Uppercased as you type; may contain only letters, numbers, and dashes (/^[A-Z0-9-]+$/). |
| Type | Percent (%) or Fixed ($). |
| Value | A positive number. For percent it must be between 1 and 100. |
| Expiry date | Optional. Parsed as the end of that local day (23:59:59.999), so a code expiring 2026-07-31 stays usable through July 31st. |
| Active immediately | Checkbox; defaults to on. |
Toggling active. Each row has a switch that flips active and re-saves the code. Inactive codes stay in the table but findUsable returns null for them, so shoppers cannot apply them.
Deleting. The trash action asks for confirmation, then calls commerce.discounts.remove(code). Deletion is permanent.
Deleting or deactivating a code does not affect quotes that already captured it — the AppliedDiscount was frozen onto those quotes at submission time and is unaffected by later changes to the live code.
Card checkout: Stripe promotion codes
When Stripe is enabled, discounts on the card path are handled entirely by Stripe. The native codes are ignored there — the Checkout Session in src/app/api/checkout/route.ts simply turns on Stripe's own promotion-code field:
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: lineItems,
// Let shoppers enter Stripe promotion codes on the payment page.
allow_promotion_codes: true,
success_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
metadata: encodeItemsMetadata(items),
client_reference_id: customerId,
});To offer a card-checkout discount:
Create a coupon and a promotion code in your Stripe dashboard (Product catalog → Coupons).
Ensure Stripe is configured — without STRIPE_SECRET_KEY the checkout route returns 503 and the store falls back to the quote flow only. See Stripe payments and environment variables.
Shoppers enter the promotion code on Stripe's hosted payment page; Stripe applies and records the discount on its side.
Because Stripe validates and applies these codes, they support Stripe's own rules (redemption limits, first-time-customer restrictions, currency, and so on) independently of the native engine.
Not yet implemented
The discounts engine is intentionally minimal. Per the project ROADMAP, two capabilities are still open for the native engine:
- Usage limits — there is no per-code or per-customer redemption cap; a usable code can be applied to unlimited quotes.
- Automatic promotions — every native code is applied manually by typing it in the cart; there are no cart-condition-triggered automatic discounts.
(These constraints apply to the native quote-flow engine only — Stripe promotion codes enforce their own limits on the card path.)