Ecommerce Boilerplate
Features

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, or the cart applies an automatic promotion for them. 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:

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;
FieldTypeNotes
idstring?The Firestore document id (the uppercase code); set only when read back.
codestringThe code shoppers type. Trimmed and uppercased on save; only letters, digits, and dashes are allowed.
type"percent" | "fixed"Percentage-off or fixed-dollar-off.
valuenumberFor percent, a whole percent 1–100. For fixed, a dollar amount > 0.
activebooleanInactive codes are kept but cannot be applied.
expiresAtDate | nullOptional expiry instant. A missing/null value never expires.
maxUsesnumber | nullOptional total-redemption cap. Missing/null/non-positive means unlimited.
usedCountnumber?Redemptions so far. System-maintained — incremented on quote submission, never written by save. Reads default it to 0.
automaticboolean?When true the cart applies this discount with no code entry.
minSubtotalnumber | nullMinimum subtotal for an automatic promotion. The boundary is inclusive: a 50 threshold applies to a $50 cart.
createdDateWhen the code was created; the admin list is ordered newest first.

Discount math

All discount logic lives in src/lib/discounts.ts as 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, not past its expiresAt, and under its usage cap:

src/lib/discounts.ts
export function isDiscountUsable(
  discount: DiscountUsability,
  now: Date = new Date()
): boolean {
  if (!discount.active) return false;
  if (discount.expiresAt && discount.expiresAt.getTime() < now.getTime()) {
    return false;
  }
  if (
    typeof discount.maxUses === "number" &&
    discount.maxUses > 0 &&
    (discount.usedCount ?? 0) >= discount.maxUses
  ) {
    return false;
  }
  return true;
}

computeDiscountedTotal applies a code to a subtotal and returns the amount taken off and the new total:

  • "percent" takes value% of the subtotal, rounded to cents.
  • "fixed" takes value dollars, clamped to the subtotal so the total never goes negative.
  • Non-finite or negative inputs are treated as 0, so the result is always 0 <= discountAmount <= subtotal and total = subtotal - discountAmount.
src/lib/discounts.ts
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:

Applying a code
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 five methods. Two are public; three are admin-only:

MethodAccessWhat it does
findUsable(code)publicLooks up a code (case-insensitive) and returns it only when currently usable, else null.
listAutomatic()publicLists the currently-usable automatic promotions. Public by design — they need no secret code.
list()adminLists all discount codes, newest first.
save(discount)adminCreates or updates a code. The uppercase code is the record key, so saving an existing code overwrites it in place. Never writes usedCount.
remove(code)adminDeletes a code by its code (case-insensitive).

findUsable deliberately collapses "doesn't exist", "inactive", "expired", and "used up" into a single null result — the storefront (and shoppers) never learn which of the four 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:

src/app/api/commerce/[service]/[method]/route.ts
discounts: {
    findUsable: "public",
    // Automatic promotions are public by design (they need no secret code —
    // the cart applies them for everyone), unlike the full `list`.
    listAutomatic: "public",
    list: "admin",
    save: "admin",
    remove: "admin",
  },

See Commerce RPC for how public / user / admin are checked on the server.

Usage limits

Give a code a max uses cap and it stops validating once its redemptions reach that number — the shopper sees the same "invalid code" message as for an expired one.

A redemption is counted when a quote carrying the discount is submitted, not when the code is typed. Every adapter increments the counter atomically:

BackendHow the counter moves
FirebaseFieldValue.increment(1), written by the browser after the quote
Postgres / SQLiteUPDATE ... SET used_count = used_count + 1 inside quotes.submit
Supabasethe increment_discount_usage() SQL function (PostgREST has no arithmetic update)

The increment is best-effort and runs after the quote persists — a failed increment never fails a submitted quote. And save never writes usedCount, so editing a code in the admin cannot reset a live counter.

Enforcement boundary, stated honestly. On the default client-side Firebase backend the increment is a client write. firestore.rules constrains its shape — only usedCount, only +1, and it must stay open to unauthenticated clients because guests submit quotes — but rules cannot tie that write to the quote that was just submitted. A determined client can therefore skip the increment (redeeming past the cap) or spam it (burning a code early). Quotes are human-reviewed before conversion, which catches over-redemption in practice.

On the SQL backends the increment runs server-side inside quotes.submit, so neither hole exists. If usage caps are load-bearing for you, run a SQL backend. See SECURITY.md.

Automatic promotions

Mark a discount automatic and the cart applies it with no code entry, whenever it is usable and the subtotal meets its optional min. subtotal threshold. The shopper sees a "Promotion applied" banner instead of typing anything.

Selection is pure and lives beside the rest of the math:

src/lib/discounts.ts
export function isPromotionApplicable(
  promotion: PromotionCandidate,
  subtotal: number,
  now: Date = new Date()
): boolean {
  if (!promotion.automatic) return false;
  if (!isDiscountUsable(promotion, now)) return false;
  const threshold = promotion.minSubtotal;
  if (typeof threshold === "number" && threshold > 0 && subtotal < threshold) {
    return false;
  }
  return true;
}

When several promotions qualify, pickBestAutomaticPromotion takes the one with the highest savings on the current subtotal. Ties break deterministically — older created first, then the lexicographically smaller code — so a re-render never flips which promotion is showing. A promotion that would save nothing is never applied.

The cart fetches the candidate list once and re-derives the winner on every render, so changing quantities moves the cart across a minSubtotal threshold correctly. Typing a manual code always overrides the automatic promotion; removing the code brings it back.

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, activeDiscount) runs against the current subtotal, so the discount stays correct as the shopper changes quantities after applying the code. activeDiscount is the typed code when there is one, otherwise the best automatic promotion.

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):

src/lib/firebase/firebaseDataConverters.ts
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:

FieldRule
CodeRequired. Uppercased as you type; may contain only letters, numbers, and dashes (/^[A-Z0-9-]+$/).
TypePercent (%) or Fixed ($).
ValueA positive number. For percent it must be between 1 and 100.
Expiry dateOptional. Parsed as the end of that local day (23:59:59.999), so a code expiring 2026-07-31 stays usable through July 31st.
Max usesOptional. A positive integer; blank means unlimited. The table shows used / max per code.
AutomaticCheckbox. When on, the cart applies the discount with no code entry.
Min. subtotalOptional, automatic promotions only. Blank means no threshold; the boundary is inclusive.
Active immediatelyCheckbox; 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 turns on Stripe's own promotion-code field:

src/app/api/checkout/route.ts
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 native engine is intentionally minimal. Still open:

  • Per-customer limits — maxUses is a single global cap. There is no "once per customer" rule.
  • Cart-condition promotions beyond a subtotal threshold — no "buy X get Y", no category or product targeting.
  • Stacking — exactly one discount applies at a time; a typed code replaces the automatic promotion rather than combining with it.

(These constraints apply to the native quote-flow engine only — Stripe promotion codes enforce their own rules on the card path.)

On this page