Skip to content

Promotions and discounts

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/promotions-and-discounts.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What promotions-and-discounts is for

The invariant you must not break: the combined discount applied at charge time must never drive the charge amount below zero, and must be explicitly capped, at every stacking layer. Concretely: combinedPct = Math.min(100, effectivePercent + ladderPct) caps the subscription_discounts+ladder layer at 100% (scheduler.ts:521), and discountedCents = Math.max(0, afterPctDiscountCents - promoEvaluation.total_discount_cents) floors the subscription_promotions layer at zero (scheduler.ts:554-557). If either guard were removed, a subscription stacking a stackable ladder tier, a stackable churn-intervention discount, and one or more stackable campaign promos could compute a negative amount_cents — which the payment adapter or BC checkout would reject outright or, worse, attempt to process as a negative charge against a live processor connection. This isn't hypothetical: resolveDiscountStack's stackable branch (db.ts:3939-3943) and evaluateSubscriptionPromotions's stackable branch (db.ts:4861-4869) both independently sum unbounded discount lists before any cap is applied — the caps live one layer up, in scheduler.ts, not co-located with the summing.

This domain covers everything a recipient reasons about as "how does a merchant run campaign promotions on subscriptions" — distinct from the per-plan cycle-discount ladder's own mechanics, which belongs to trials-and-intro-offers and is only referenced here at the point where the two combine:

  • Subscription-only coupon codes — a coupon code that discounts only subscription line items, distinct from BC's storewide one-time coupons (US-25.1)
  • Merchant-defined stacking rules — the merchant decides whether two campaign promotions on the same subscription combine (additive, capped) or only the higher-value one applies (US-25.5)
  • Tenure/loyalty campaign discounts — a merchant-configured, coupon-driven loyalty schedule distinct from the plan-level ladder (US-25.4)
  • Promotional free shipping — shipping zeroed on a renewal order once a cycle or order-value condition is met (US-25.6)
  • Recurring subscriber-exclusive gift perks — a free gift SKU injected on every Nth renewal cycle (US-25.7)
  • One-time gift with new subscription — a free gift SKU on the subscriber's first order only (US-25.8)
  • Promotion performance reporting — per-promotion redemptions, discount given, attributed MRR, and retention lift (US-25.9)

The load-bearing decisions:

  • BC's native promotion engine is deliberately kept cart-scoped; all cycle-aware promotion logic lives in our layer — spike #117 established BC's /v3/promotions has no cycle/recurrence dimension at all, so a standalone subscription_promotions substrate was ratified rather than stretching BC's engine to do something it structurally cannot (ADR-0053 §Context, citing docs/spikes/epic-25-26-promo-engine.md §F1).
  • subscription_promotions and subscription_discounts are two tables with different lifecycles, evaluated in a fixed two-pass order, not merged — merging would force the CSR-driven churn-intervention path to inherit the full merchant-facing promo CRUD lifecycle before it needs to (ADR-0053 §Decision; promo-discount-coexistence.md §2).
  • A promo's lock_policy is mechanically derived from application_window, never merchant-settablefirst_cycle_only/ first_n_cycleslock_at_creation; all_cycles/tiered_cycles/ event_triggeredre_resolve; this makes an invalid combination (e.g. first_cycle_only + re_resolve) structurally impossible rather than documentation-enforced (deriveLockPolicy, db.ts:4139-4151; ADR-0053 §"Lock policy is DERIVED").
  • Charge-time combination order is discount-pass-then-promo-pass — and it is not what the architecture doc most likely to be read says. subscription_discounts (churn intervention + the ADR-0052 intro-offer snapshot + the ADR-0080 ladder, combined and capped) reduces the raw charge amount first; subscription_promotions (coupons, tenure campaigns, free-shipping/gift-item surface effects) reduces the already-discounted amount second (scheduler.ts:486-557; ADR-0053 §"Scheduler integration"). See the canonical-framing attestation and the first typed delta below — promo-discount-coexistence.md states the reverse order.

Canonical-framing attestation (operator-ratified 2026-07-02). subscription_promotions (our layer) is canonical for all cycle-aware/recurring subscription promotion logic — coupon codes, tenure/loyalty campaigns, free shipping, and gift-with-subscription perks. BC's native /v3/promotions engine is deliberately kept to two narrow, non-overlapping roles that never express a cycle/recurrence dimension: (1) bc/promotions.ts's AUTOMATIC per-product promotion that money-moves the checkout-time intro offer at first-cycle checkout (owned by trials-and-intro-offers, not this domain), and (2) promotion_settings (migration 0039) — a flag-overlay table annotating the merchant's own pre-existing BC coupons with a subscription_only boolean for PDP/cart gating. Neither BC role expresses cycle/recurrence — structurally impossible in BC's engine (spike #117 finding F1) — which is why ADR-0053 ratifies a standalone, fully-in-our-layer substrate for everything cycle-aware. Separately: docs/architecture/promo-discount-coexistence.md §3.1's written execution order (promo pass reduces baseCents first, churn-intervention subscription_discounts reduces the post-promo amount second) is residue of a pre-implementation design, superseded by ADR-0053's ratified order — the reverse — which scheduler.ts:486-557 implements. The coexistence doc was never corrected; see the first typed delta below for the citation-attribution trap this produced in tenure-stacking.test.ts.

How it actually works

The two-pass charge-time combination

scheduler.ts::processCharge runs two passes in one function, in this fixed order (scheduler.ts:486-557):

  1. Pass 1 — subscription_discounts. listPendingDiscounts reads pending discount rows for the subscription's current cycle (cycles_completed + 1), then resolveDiscountStack applies the stacking rule — exclusive (default: highest-percent row only) or stackable (additive sum, capped at 100% — db.ts:3926-3948) — producing effectivePercent. Separately, the ADR-0080 ladder is read fresh from the plan (getPlan) and resolved to ladderPct if the plan carries a cycle_discounts schedule. `combinedPct = Math.min(100, effectivePercent
  2. ladderPct); the code comment names the two sources as structurally disjoint ("The paths are disjoint; combinedPct cannot double-count" — [scheduler.ts:507-511](https://github.com/nino-chavez/bc-subscriptions/blob/main/apps/api/src/scheduler.ts)), becausesnapshotPlanCycleDiscountrefuses to write asubscription_discountsrow for any plan that carries a ladder ([db.ts:3976-3980](https://github.com/nino-chavez/bc-subscriptions/blob/main/apps/api/src/db.ts)).afterPctDiscountCentsis the charge amount after this pass. This domain does not re-derive the ladder's own resolution mechanics — see [trials-and-intro-offers](trials-and-intro-offers.md) forresolveCycleDiscount` and the disjoint-path guard in full.
  3. Pass 2 — subscription_promotions. evaluateSubscriptionPromotions is called against afterPctDiscountCents, not the raw charge amount (scheduler.ts:539-547). It loads every promotion ID in subscriptions.active_promotion_ids (a denormalized JSON array, default '[]' so subscriptions with no active promos skip the DB read entirely — db.ts:4803-4812), filters each by status === 'active', expiry (lock_at_creation promos run to cycle completion past expiry; re_resolve promos stop on the next renewal — db.ts:4820-4829), the cycle window, and the recurring flag. Stacking follows the same exclusive/stackable convention as Pass 1, taken from the first eligible promo (db.ts:4855-4884). For exclusive, "higher value" means higher computed amount_cents for this specific charge, not higher abstract percent — deliberate, per Hive #900, so a flat $5 promo can beat a 10% promo on a low-cost cycle (db.ts:4877-4881). discountedCents = Math.max(0, afterPctDiscountCents - promoEvaluation.total_discount_cents).

Coupon-code resolution and attachment

createSubscription accepts an optional coupon_code. If present, it resolves the code against subscription_promotions (tenant-scoped), classifies any attach failure (not found, paused, expired, wrong applies_to, redemption cap hit), and on success calls attachPromotionToSubscription + increments current_redemptions (db.ts:1215-1256). The attach is best-effort and does not roll back the subscription — a miss emits a structured subscription.promotion_attach_failed event instead of failing the create, because by the time createSubscription runs the subscription already exists and unwinding would compound the problem. The storefront-facing fail-fast surface is a separate endpoint: POST /api/v1/storefront/subscription-promotions/validate-coupon (subscription-promotions.ts:521, wired at worker.ts:2670) — a shopper validates a code at cart time before it's ever attached to a subscription.

Free shipping and gift-item promos are surface effects, not cents reductions

evaluateFreeShippingPromos and evaluateGiftItemPromos (db.ts:4994-5122) are separate evaluators from evaluateSubscriptionPromotions by design — the code's own header explains why: free-shipping and gift-item promos change the BC order body shape (shipping line zeroed; gift line injected at $0) rather than reducing base_cents, so folding them into the cents-reduction evaluator would force it to return non-cents semantics it doesn't model (db.ts:3775-3785). Gift-item eligibility has two mutually exclusive cycle-gating modes: first_cycle_only fires once at cycle_index === 0 (US-25.8, one-time gift); all_cycles + config.cycle_modulo: N fires every Nth cycle (US-25.7, recurring perk — db.ts:5027-5035).

Lock policy is derived, never merchant-set

deriveLockPolicy maps application_window to lock_policy mechanically — first_cycle_only/first_n_cycleslock_at_creation; all_cycles/ tiered_cycles/event_triggeredre_resolve (db.ts:4139-4151). There is no code path that lets a merchant set first_cycle_only with re_resolve — the invalid combination is structurally unreachable, not merely validated against. Merchants who want to change a promo's terms must archive and recreate it: application_window, code, and discount_type are intentionally not updatable, protecting the locked-at-creation acquisition contract for in-flight subscribers (ADR-0053 §"Lock policy is DERIVED").

erDiagram
    subscriptions {
        TEXT id PK
        TEXT active_promotion_ids
    }
    subscription_promotions {
        TEXT id PK
        TEXT store_hash
        TEXT code
        TEXT application_window
        TEXT config
        TEXT discount_type
        TEXT stacking_rule
        TEXT lock_policy
        INTEGER recurring
        TEXT status
        INTEGER max_redemptions
        INTEGER current_redemptions
        TEXT starts_at
        TEXT ends_at
    }
    subscription_promotion_applications {
        TEXT id PK
        TEXT subscription_id FK
        TEXT charge_id FK
        TEXT promotion_id FK
        INTEGER amount_cents
        TEXT discount_type
        INTEGER cycle_index_applied
    }
    promotion_settings {
        INTEGER id PK
        TEXT store_hash
        INTEGER bc_promotion_id
        INTEGER subscription_only
        TEXT stacking_rule
    }
    subscriptions ||--o{ subscription_promotion_applications : "subscription_id"
    subscription_promotions ||--o{ subscription_promotion_applications : "promotion_id"

Diagram provenance. Trimmed excerpt (this domain's tables only) of the canonical, code-sourced docs/architecture/data-model-erd.md (mechanically derived from schema.sql, as_of_commit: 80fc35f4, staleness_threshold_days: infinite — this source has no sign_off field; its own staleness marker is the CI-enforced drift gate instead). The full subscription_promotions entity carries 19 columns and subscriptions carries dozens; both are omitted here except the ones this domain touches. promotion_settings is shown for BC-boundary contrast (the flag-overlay role, not the cycle-aware substrate). No dedicated sequence diagram exists yet for the charge-time discount/promo two-pass order this page's canonical framing traces — the closest canonical excerpt, docs/architecture/sequence-diagrams.md § the renewal/charge sequence, does not currently break out the pass ordering. That gap is a documentation opportunity this page's own typed deltas suggest closing alongside a promo-discount-coexistence.md correction — narrated in prose above and below instead of transcluded.

Where intent and reality diverge

The derived coverage matrix (_coverage-matrix.json) reports US-25.1, US-25.3, US-25.4, US-25.5, US-25.6, US-25.7, US-25.8, US-25.9, and US-25.10 all at g4_status: "pass" (field name is g4_status, not g4); US-25.2 and US-25.11 are g4_status: null (US-25.2 is untagged-not-unbuilt per its own BRD gaps note; US-25.11 is genuinely unbuilt, "Deferred until usage data shows need"). That is true, and it is not the whole truth. Five typed deltas:

  • Superseded-framing residuedocs/architecture/promo-discount-coexistence.md §3.1's written execution order (promo pass reduces baseCents first; churn-intervention subscription_discounts pass reduces the post-promo amount second) is the residue of a pre-implementation design. ADR-0053 ratified — and scheduler.ts:486-557 implements — the reverse: subscription_discounts (+ladder) first, subscription_promotions second. The doc was never corrected, and apps/api/src/__tests__/tenure-stacking.test.ts:544-548 cites the doc's §3.1 as authority for a step sequence that is actually the ADR-0053 order — a citation that documents the right behavior while crediting the wrong, self-contradicting source. A recipient who reads the coexistence doc before the code, and trusts it, will build against the wrong pass order.
  • Verified-but-incomplete — the subscription_promotions backend (full CRUD, coupon-code resolution at subscription creation, stacking_rule/ lock_policy derivation) is built and G4-tested (apps/api/src/routes/admin/subscription-promotions.test.ts), but there is no merchant-facing admin surface to create a subscription_promotions row of any kind. PromotionsList.tsx only toggles the subscription_only flag on promotions that already exist in BigCommerce — a different table, promotion_settings — and apps/admin/src/pages/settings/store/Promotions.tsx is an explicit "Coming soon" stub (Promotions.tsx:39-50); PromotionReport.tsx only reads. A merchant cannot create a subscription-only coupon, a tenure-loyalty campaign, a free-shipping rule, or a gift-with-subscription perk without a direct API call.
  • Built-but-untrodden — the storefront cart never surfaces a coupon-code entry field. validate-coupon and the coupon_code attach path in createSubscription are both G4-route-tested, but apps/storefront-svelte/src/ has no cart-level coupon input anywhere — the only place "coupon" appears in the storefront is a test explicitly asserting US-25.2's auto-promotion needs no coupon field (subscribe-and-save-coverage.test.ts:142-170). The reachable, tested branch is exercised only by direct API calls today.
  • Verified-but-incomplete — the gift-item OOS-substitute fallback is accepted at the config layer (config.substitute_product_id/ substitute_variant_id) and validated by the admin route validator (subscription-promotions.ts:232-234), but evaluateGiftItemPromos does not propagate the substitute onto the persisted ChargeOrderOverrides row order materialization reads (db.ts:5083-5121). Until that gap closes, the substrate silently falls back to skip-with-attribution instead of honoring a merchant's configured substitute — self-documented as a known gap, filed as a Hive [Spec] follow-up, at routes/orders.ts:171-232.
  • Named-deferred — US-25.11 (ladder_lock, a per-plan opt-out that snapshots the ADR-0080 ladder at creation instead of letting it read at charge) is explicitly Phase-3 and unbuilt: "Deferred until usage data shows need" (BRD epic-25 view, US-25.11). No schema column, no admin control, no scheduler branch exist.

Live-state attestation (operator-ratified 2026-07-02). The subscription_promotions substrate is deployed live (the table exists and the storefront validate-coupon route responds) but carries zero rows in the production/demo store as of the trace date: SELECT COUNT(*) FROM subscription_promotions against the live remote subs-api-d1 returned 0. More significantly, the ADR-0080 cycle-discount ladder this domain's charge-time combination logic depends on cannot run live at all right now — the live plans table has no cycle_discounts column at all. PRAGMA table_info(plans) lists cycle_discount_pct/cycle_discount_scope/ cycle_discount_count (ADR-0052) but not cycle_discounts (ADR-0080), even though schema.sql:235-236 — canonical, what local/CI D1 and G4 tests build from — has carried it since PR #1837. Root cause: migration 0043_cycle_discount_ladder.sql merged to dev in commit 6dd32085 on 2026-06-28, but .github/workflows/deploy-api.yml only triggers on a version-tag push or manual workflow_dispatch — not on dev push. The most recent relevant deploy tag, v2026.05.18-try-tour, is over five weeks older than the migration. So the entire ladder feature — G4-verified as a pure function and via scenario tests against the correctly migrated local/CI schema — is dormant in production/demo today: getPlan() on the live DB returns rows with no cycle_discounts field, so ladderPlan?.cycle_discounts (scheduler.ts:512) is always undefined/falsy live, and ladderPct is always 0 on the live path regardless of what a merchant configures. This is a live-blocking deploy gap, not merely an untested one — deploy-api needs a run against subs-api-d1, or the next tag needs to include this migration, independent of this page's ratification.

How to operate & extend

  • Create a subscription-only coupon, tenure campaign, free-shipping rule, or gift perk today: there is no admin UI — use the /api/v1/admin/subscription-promotions CRUD endpoints directly. Building the admin surface is the single highest-leverage gap in this domain (see the verified-but-incomplete delta above).
  • Change how two campaign promotions combine on one subscription: set stacking_rule (exclusive or stackable) on the promo row at creation — it's immutable afterward. exclusive picks the single highest-computed-cents discount; stackable sums all eligible promos, capped at base_cents (db.ts:4855-4884).
  • Change a promo's terms after it's live: you can't — application_window, code, and discount_type are intentionally not updatable. Archive and recreate. This protects in-flight subscribers' locked-at-creation contract.
  • Add a gift SKU that recurs every Nth cycle: set application_window: 'all_cycles' and config.cycle_modulo: N (db.ts:5027-5035). For a one-time first-order gift, use application_window: 'first_cycle_only' instead.
  • Close the gift-item substitute gap: propagate cfg.substitute_product_id/substitute_variant_id from evaluateGiftItemPromos onto the persisted ChargeOrderOverrides row — tracked as Hive [Spec] follow-up per routes/orders.ts:171-232.
  • Fix the live ladder deploy gap first, before relying on ADR-0080 in production: run deploy-api against subs-api-d1 (or cut a tag that includes migration 0043_cycle_discount_ladder.sql) — until then, any plan configured with a cycle_discounts ladder silently discounts nothing at charge time in the live store.
  • The invariant you must not break: every stacking layer must be explicitly capped before it reaches the charge amount — combinedPct capped at 100 in scheduler.ts:521, discountedCents floored at 0 in scheduler.ts:554-557. If you add a new discount source, cap it at the scheduler call site, not inside the evaluator that sums it — that's where today's two caps live, one layer up from the summing.
  • Extension seams: the free-shipping and gift-item evaluators (evaluateFreeShippingPromos, evaluateGiftItemPromos) are the pattern to follow for a new surface-effect promo type that changes the order body rather than the charge amount. eligibility_rules (Epic 26's rule engine) is currently a pass-through stub in evaluateSubscriptionPromotions — the documented integration point for conditional eligibility beyond cycle/expiry/window.

Confidence notes

  • The promo-discount-coexistence.md correction is not something I resolved. The typed delta above and the canonical-framing attestation both state that the doc's §3.1 pseudocode is superseded residue, but the file itself still reads as the current design (no deprecation banner, no pointer to ADR-0053's reversed order). I did not edit it — Input-B's own instruction is to report contradictions, not resolve them, and the doc correction is explicitly named in Input-B's visual-aids section as a gap "this file's own typed-delta findings suggest should be closed."
  • I did not independently re-run the live D1 trace. The zero-rows count and the missing cycle_discounts column are Input-B's attested claims, traced by the generating session against subs-api-d1 on 2026-07-02. I verified the static side independently — schema.sql:235-236 does declare cycle_discounts, and deploy-api.yml's trigger is tag-push/ workflow_dispatch only, matching the root-cause explanation — but I did not re-run wrangler d1 execute --remote myself in this session.
  • evaluateFreeShippingPromos and evaluateGiftItemPromos return type details (e.g. the exact shape of EvaluatedFreeShipping) are read from their call sites and docblocks, not exhaustively traced through every downstream consumer of ChargeOrderOverrides. The substitute-propagation gap is confirmed at its cited lines; I did not audit the rest of the materialization path for similar gaps.