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/promotionshas no cycle/recurrence dimension at all, so a standalonesubscription_promotionssubstrate was ratified rather than stretching BC's engine to do something it structurally cannot (ADR-0053 §Context, citingdocs/spikes/epic-25-26-promo-engine.md§F1). subscription_promotionsandsubscription_discountsare 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_policyis mechanically derived fromapplication_window, never merchant-settable —first_cycle_only/first_n_cycles→lock_at_creation;all_cycles/tiered_cycles/event_triggered→re_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.mdstates 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):
- Pass 1 —
subscription_discounts.listPendingDiscountsreads pending discount rows for the subscription's current cycle (cycles_completed + 1), thenresolveDiscountStackapplies the stacking rule —exclusive(default: highest-percent row only) orstackable(additive sum, capped at 100% —db.ts:3926-3948) — producingeffectivePercent. Separately, the ADR-0080 ladder is read fresh from the plan (getPlan) and resolved toladderPctif the plan carries acycle_discountsschedule. `combinedPct = Math.min(100, effectivePercent - 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. - Pass 2 —
subscription_promotions.evaluateSubscriptionPromotionsis called againstafterPctDiscountCents, not the raw charge amount (scheduler.ts:539-547). It loads every promotion ID insubscriptions.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 bystatus === 'active', expiry (lock_at_creationpromos run to cycle completion past expiry;re_resolvepromos stop on the next renewal —db.ts:4820-4829), the cycle window, and therecurringflag. Stacking follows the sameexclusive/stackableconvention as Pass 1, taken from the first eligible promo (db.ts:4855-4884). Forexclusive, "higher value" means higher computedamount_centsfor this specific charge, not higher abstract percent — deliberate, per Hive #900, so a flat$5promo can beat a10%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_cycles → lock_at_creation; all_cycles/
tiered_cycles/event_triggered → re_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 fromschema.sql,as_of_commit: 80fc35f4,staleness_threshold_days: infinite— this source has nosign_offfield; its own staleness marker is the CI-enforced drift gate instead). The fullsubscription_promotionsentity carries 19 columns andsubscriptionscarries dozens; both are omitted here except the ones this domain touches.promotion_settingsis 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 apromo-discount-coexistence.mdcorrection — 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 residue —
docs/architecture/promo-discount-coexistence.md§3.1's written execution order (promo pass reducesbaseCentsfirst; churn-interventionsubscription_discountspass reduces the post-promo amount second) is the residue of a pre-implementation design. ADR-0053 ratified — andscheduler.ts:486-557implements — the reverse:subscription_discounts(+ladder) first,subscription_promotionssecond. The doc was never corrected, andapps/api/src/__tests__/tenure-stacking.test.ts:544-548cites 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_promotionsbackend (full CRUD, coupon-code resolution at subscription creation,stacking_rule/lock_policyderivation) is built and G4-tested (apps/api/src/routes/admin/subscription-promotions.test.ts), but there is no merchant-facing admin surface to create asubscription_promotionsrow of any kind.PromotionsList.tsxonly toggles thesubscription_onlyflag on promotions that already exist in BigCommerce — a different table,promotion_settings— andapps/admin/src/pages/settings/store/Promotions.tsxis an explicit "Coming soon" stub (Promotions.tsx:39-50);PromotionReport.tsxonly 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-couponand thecoupon_codeattach path increateSubscriptionare both G4-route-tested, butapps/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), butevaluateGiftItemPromosdoes not propagate the substitute onto the persistedChargeOrderOverridesrow 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, atroutes/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-promotionsCRUD 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(exclusiveorstackable) on the promo row at creation — it's immutable afterward.exclusivepicks the single highest-computed-cents discount;stackablesums all eligible promos, capped atbase_cents(db.ts:4855-4884). - Change a promo's terms after it's live: you can't —
application_window,code, anddiscount_typeare 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'andconfig.cycle_modulo: N(db.ts:5027-5035). For a one-time first-order gift, useapplication_window: 'first_cycle_only'instead. - Close the gift-item substitute gap: propagate
cfg.substitute_product_id/substitute_variant_idfromevaluateGiftItemPromosonto the persistedChargeOrderOverridesrow — tracked as Hive [Spec] follow-up perroutes/orders.ts:171-232. - Fix the live ladder deploy gap first, before relying on ADR-0080 in
production: run
deploy-apiagainstsubs-api-d1(or cut a tag that includes migration0043_cycle_discount_ladder.sql) — until then, any plan configured with acycle_discountsladder 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 —
combinedPctcapped at 100 inscheduler.ts:521,discountedCentsfloored at 0 inscheduler.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 inevaluateSubscriptionPromotions— the documented integration point for conditional eligibility beyond cycle/expiry/window.
Confidence notes¶
- The
promo-discount-coexistence.mdcorrection 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_discountscolumn are Input-B's attested claims, traced by the generating session againstsubs-api-d1on 2026-07-02. I verified the static side independently —schema.sql:235-236does declarecycle_discounts, anddeploy-api.yml's trigger is tag-push/workflow_dispatchonly, matching the root-cause explanation — but I did not re-runwrangler d1 execute --remotemyself in this session. evaluateFreeShippingPromosandevaluateGiftItemPromosreturn type details (e.g. the exact shape ofEvaluatedFreeShipping) are read from their call sites and docblocks, not exhaustively traced through every downstream consumer ofChargeOrderOverrides. The substitute-propagation gap is confirmed at its cited lines; I did not audit the rest of the materialization path for similar gaps.