Trials and intro offers¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/trials-and-intro-offers.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What trials-and-intro-offers is for¶
The invariant you must not break: processTrialConversions must execute
before findDueCharges within a single scheduled tick. If the order is ever
reversed — or a code path calls findDueCharges without first converting
ended trials — a trial's already-seeded first charge sits behind the
active-status guard forever: the subscription never converts and never
bills. This is not hypothetical; it shipped exactly this way and was filed
and fixed as #1745. (Source: scheduler.ts:1781-1827
docblock and the runScheduledTick ordering at
scheduler.ts:2495-2497.)
This domain covers three mechanisms a recipient will reason about as one capability — "how does a subscriber get a break before or during the first few charges" — but they are three deliberately separate product semantics, not variations on one idea:
- Free or paid trial before the first charge — a subscriber tries the product for N days before any real charge runs (US-5.5)
- "50% off your first order" intro pricing — a checkout-time promotional promise, honored for the cycles it named regardless of later plan edits (US-25.3)
- Standing loyalty discount ladder — a stepped, merchant-tunable discount schedule (e.g. 20% off cycles 1–2, 15% off cycles 3–6) that applies fresh at each renewal (US-25.10)
- Price locked at sign-up — a subscriber's price is frozen at the moment they subscribe, immune to later catalog or Price List changes (US-5.6)
- Choice of billing cadence, including annual — subscribers pick from merchant-configured intervals; the data model supports yearly billing end to end (US-5.1)
- Discount stacking control — merchant decides whether an intro offer and another active discount combine or the higher one wins (US-25.5)
The load-bearing decisions:
- Intro offers snapshot at creation; the ladder reads fresh at charge — two mechanisms, two product semantics, deliberately different persistence models: an intro offer is a promise made at sign-up (frozen even if the merchant edits the plan), a loyalty ladder is a standing schedule the merchant expects to tune (ADR-0052, ADR-0080).
- The two discount paths are mutually exclusive per plan, enforced in code,
not by convention —
snapshotPlanCycleDiscountreturnsnulland writes nothing the moment a plan carries acycle_discountsladder (ADR-0080 §Decision). - Locked base price follows the same snapshot-at-creation idiom as intro
discounts —
locked_price_centsis frozen independently of the cycle-discount snapshot, giving a merchant two separate, composable levers (base price vs. intro discount) that both resist mid-flight plan edits (BRD US-5.6; ADR-0052 §Context). - Trial conversion must run before the charge sweep in the same scheduler tick — restated above as the invariant; getting the order wrong is not a performance issue, it silently strands revenue.
Canonical-framing attestation (operator-ratified 2026-07-02). These are
two distinct, deliberately disjoint discount mechanisms coexisting by
design — not a superseded/residual pair. plans.cycle_discount_pct/scope/count
is the checkout-time intro-offer promise, snapshotted onto the subscription at
creation and immutable to later plan edits
(ADR-0052).
plans.cycle_discounts is the ongoing loyalty ladder, read fresh from the
plan at every renewal
(ADR-0080, which
states outright: "This ADR extends ADR-0052... It does not supersede
ADR-0052. Both patterns coexist," with a two-row comparison table).
snapshotPlanCycleDiscount enforces the split mechanically — traced at
db.ts:3978, it returns null immediately when
plan.cycle_discounts !== null, and scheduler.ts:512-519
confirms the read-at-charge branch only activates when a plan carries a
ladder. Free trials (plans.trial_days, trialing status) are a third,
independent mechanism with no ADR of its own — governed by BRD US-5.5 and
the #1745 scheduler-ordering fix, not a discount at all: the first charge is
skipped or reduced, not discounted at renewal.
How it actually works¶
Trials: creation, ending-soon notice, conversion¶
Three subscription-creation paths all branch on plan.trial_days: the
storefront checkout webhook
(routes/webhooks.ts:723-747), the
admin manual-create route
(routes/subscriptions.ts:884-902),
and (per the scheduler's own docblock) B2B enrollment. Each computes
isTrial = plan.trial_days > 0 (webhooks also requires
!intent.consent === false doesn't apply — no such gate; subscriptions.ts
additionally requires !validated.charge_immediately), sets
status: 'trialing' instead of 'active', and pushes next_charge_at to
orderIso/startIso + trial_days days. The webhook path skips the entire
cycle-0 paid-charge block for trials — a trial checkout is $0 via a
100%-automatic BC promotion — and seeds only a cycle-1 charge row at trial
end (webhooks.ts:823-841); the admin path likewise anchors next_charge_at
at trial end with no immediate charge. Both paths emit
trial.started (webhooks.ts:860-869, subscriptions.ts:930-938).
Read the trial lifecycle as: create with status='trialing' and a
cycle-1 charge parked at trial end → T-3-days-before-end, emit a one-time
trial.ending_soon notice → at trial end, flip trialing→active and
re-anchor the period → the already-parked cycle-1 charge fires the same tick.
sequenceDiagram
autonumber
participant BC as BigCommerce (store/order/created webhook)
participant Worker as Worker.fetch() (worker.ts handleFetch)
participant WH as routes/webhooks.ts::handleBcWebhook
participant Verify as standardwebhooks signature verify
participant OrderH as handleOrderCreated (order webhook handler)
participant BCApi as api.bigcommerce.com (order / cart-metafields / transactions GET)
participant DB as D1
participant Queue as EVENTS_QUEUE
BC->>Worker: POST /webhooks/bc (scope: store/order/created, signed payload)
Worker->>WH: handleBcWebhook(request, env) (worker.ts:540-542)
WH->>Verify: verify HMAC signature (v1,<base64> over raw body)
alt bad signature
Verify-->>WH: fail
WH-->>BC: 401
else signature ok
WH->>OrderH: dispatch by scope → handleOrderCreated
OrderH->>BCApi: GET order, cart-metafields, transactions (stubbed in CI · real HTTP in prod)
BCApi-->>OrderH: order + line item metafields carrying encoded subscription intent
Note over OrderH: intent decoded via decodeSubscriptionIntents (shared contract package @bc-subscriptions/storefront-contract)
OrderH->>DB: createSubscription (subscriptions row, status per plan: 'active' or 'trialing')
OrderH->>DB: createCharge (cycle 0 charge row, chain_position:'initial')
OrderH->>DB: logEvent('subscription.created', actor_kind:'system', payload:{from_order_id})
OrderH-->>Worker: 200 { subscription_id, ... }
Worker-->>BC: 200
end
Diagram provenance. Transcluded verbatim from § 2 "Subscribe — order/created webhook → subscription creation" of the canonical, code-sourced
docs/architecture/sequence-diagrams.md(derives_frompinsroutes/webhooks.ts,db.ts, among others). Its frontmatter carriessign_off: pending— accurate to the code, not yet human-attested. This is the closest canonical excerpt to a trial/intro-offer-specific sequence; no dedicated trial sequence exists yet in the source document, so read thestatusbranch (message 8, "status per plan: 'active' or 'trialing'") as where the trial/no-trial fork actually happens — the trial-specific mechanics (ending-soon notice, conversion) are not shown in this diagram and are narrated in prose above and below instead. In the handoff pipeline this is a build-time include of that one source, never a hand-copied fork.
Two scheduler passes run every tick, both before findDueCharges
(scheduler.ts:2491-2497):
processTrialEndingSoon— findstrialingsubscriptions whosenext_charge_atfalls within the next 3 days andtrial_ending_soon_sent = 0, CAS-flips the flag, and emitstrial.ending_soon(scheduler.ts:1733-1778). A trial shorter than 3 days fires this on the very first tick after signup — an intentional edge, per the function's own comment.processTrialConversions— findstrialingsubscriptions whosenext_charge_at(the trial end) has passed, flipsstatus='active', re-anchorscurrent_period_end = next_charge_at, and emitssubscription.activated+trial.converted(scheduler.ts:1790-1830). This is the function the invariant above governs.
Cancelling during a trial emits a specialised event alongside the general
one: routes/portal/cancel.ts checks current.status === 'trialing' and, if
true, emits trial.cancelled in addition to subscription.cancelled
(portal/cancel.ts:251-259).
Intro offers and the loyalty ladder: two paths through one charge handler¶
At subscription creation, createSubscription calls
snapshotPlanCycleDiscount(repo, inserted.id, plan)
(db.ts:1176-1179). That function is the fork
point for the two mechanisms:
- If
plan.cycle_discounts !== null(the ladder is declared), it returnsnullimmediately and writes nothing — the ladder plan resolves its discount at charge time instead (db.ts:3976-3980). - Otherwise, if
plan.cycle_discount_pct+plan.cycle_discount_scopeare set, it translates the plan-level scope (first_cycle_onlyorfirst_n_cycles) into a(cycle_min, cycle_max)gate and inserts onesubscription_discountsrow taggedreason: 'plan_cycle_discount_snapshot'(db.ts:3968-4000). The function is explicitly not idempotent — the docblock warns callers must invoke it exactly once at creation.
At charge time, scheduler.ts::processCharge reads both sources in the same
function and combines them without double-counting
(scheduler.ts:504-524):
listPendingDiscounts+resolveDiscountStackreads any snapshottedsubscription_discountsrows for the current cycle (the intro-offer path) — this iseffectivePercent.- A second, independent read re-fetches the plan
(
getPlan(repo, subscription.plan_id, ...)) and, ifplan.cycle_discountsis non-null, parses the tier JSON and callsresolveCycleDiscount(tiers, currentCycle, 0)— this isladderPct. combinedPct = Math.min(100, effectivePercent + ladderPct). BecausesnapshotPlanCycleDiscountguarantees a ladder plan never wrote asubscription_discountsrow,effectivePercentis structurally 0 for ladder plans andladderPctis structurally 0 for intro-offer plans — the sum can't double-apply. The code comment names this explicitly: "The paths are disjoint; combinedPct cannot double-count."
resolveCycleDiscount itself is a pure function
(db.ts:851-863): tiers are evaluated in
declared order, the first tier where cycleCount >= tier.from and
(tier.to is null or cycleCount <= tier.to) wins; an unmatched cycle
resolves to 0; a null/empty tier array resolves to fallbackPct (always
called with 0 from the scheduler, so "no ladder" reads as "no discount,"
not "fall back to something else"). validateCycleDiscount
(db.ts:782-830) co-validates the
(pct, scope, count) intro-offer triple at plan write time — all three null,
or pct + scope='first_cycle_only' with count null, or pct +
scope='first_n_cycles' with count >= 1; nothing enforces an equivalent
shape check on the cycle_discounts ladder JSON at write time beyond
schema.sql's json_valid() CHECK.
erDiagram
plans {
INTEGER trial_days
INTEGER lock_price_at_creation
INTEGER cycle_discount_pct
TEXT cycle_discount_scope
INTEGER cycle_discount_count
TEXT cycle_discounts
TEXT interval
}
subscriptions {
TEXT status
TEXT next_charge_at
INTEGER trial_ending_soon_sent
INTEGER locked_price_cents
INTEGER cycles_completed
}
subscription_discounts {
TEXT subscription_id FK
TEXT reason
INTEGER cycle_min
INTEGER cycle_max
}
plans ||--o{ subscriptions : "governs"
subscriptions ||--o{ subscription_discounts : "intro-offer snapshot only"
Diagram provenance. Trimmed excerpt (this domain's fields only) of the canonical, code-sourced
docs/architecture/data-model-erd.md(mechanically derived fromschema.sqlbytools/erd-derive/,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 fullplansentity carries 39 columns andsubscriptionscarries 39 columns; both are omitted here except the ones this domain touches.subscription_discountsis not itself in the ERD excerpt above the fold — its shape is inferred from thesnapshotPlanCycleDiscountINSERT columns (db.ts:3968-4020), not transcluded from the ERD.
Supporting pieces:
- Locked price:
locked_price_centsis set once, at subscription creation, independent of the cycle-discount snapshot —plan.lock_price_at_creation === 1 ? plan.amount_cents : null(webhooks.ts:747). At renewal,scheduler.ts:1540-1541substitutessubscription.locked_price_centsfor the plan's currentamount_centswhen the lock is set. - Annual billing:
plans.intervalaccepts'year'(schema.sql:149,210) and the scheduler/renewal machinery treats it like any other interval — there is no annual-specific branch to trace, which is itself the point: the data model and charge path already support it end to end.
Where intent and reality diverge¶
The derived coverage matrix
(_coverage-matrix.json) reports
US-5.5, US-5.6, US-25.3, US-25.5, and US-25.10 all at g4_status: "pass" —
confirmed directly (ac: "US-5.5" through "US-25.10" entries, field name is
g4_status, not g4). That is true, and it is not the whole truth. Four
typed deltas:
- Named-deferred — paid trials are entirely unbuilt. BRD US-5.5 specifies
a "Paid trial at $X" sub-option backed by
plans.trial_amount_cents; that column does not exist inschema.sqlat all, and the scheduler's own comment names it future work: "D2 paid-trial (trial_amount_cents) is a separate deferred slice" (scheduler.ts:1787). The admin form has no field for it either —OptionsFormValuecarriestrialEnabled/trialDaysbut notrial_amount_centsfield (apps/admin/src/components/forms/types.ts:27-35). Only the free-trial (first charge = $0) half of US-5.5 AC-1 is built. - Verified-but-incomplete — the subscriber portal's trial badge and
label are built and G4-tested (
trialinggets its own accent-toned badge and the "Trial" label, not a neutral/raw fallback —subscriptionStatus.ts:37,58, asserted bysubscriptionStatus.test.ts:19,41), but the fuller trial banner BRD US-5.5 calls for — a days-remaining countdown and "Your first full charge will be $X on YYYY-MM-DD" — is still absent from the portal (BRD US-5.5gaps:note). - Built-but-untrodden — the schema and API fully support annual billing
(
interval CHECK (... 'year'),schema.sql:149,210), and a live annual plan exists in the demo store, but the merchant-facing admin plan wizard's fixed cadence checklist offers only weekly through every-6-months — no yearly entry. Traced atapps/admin/src/components/forms/intervalOptions.ts:14-21(INTERVAL_OPTIONSarray): weekly, every-⅔-weeks, monthly, every-⅔/6-months — nothing yearly. The one live annual demo plan was created by the seed script (scripts/kibble-demo/gen-d1-plans.mjs:103), not through the admin UI a merchant would actually use. - Contract-verified, not live-verified — the cycle-discount ladder
(US-25.10,
cycle_discounts) is G4-verified as a pure function (resolveCycleDiscount, boundary cases including open-ended tiers) plus scenario coverage of the disjoint-path guard, but has never been exercised in the live demo store:demo-state-derive's live-D1 probe checkscycle_discount_pct(the intro-offer path) and never queriescycle_discounts(the ladder path) —tools/demo-state-derive/index.ts:71-93. No seeded plan anywhere carries a ladder. The intro-offer sibling mechanism, by contrast, does have live-demo evidence (see live-state attestation below).
Live-state attestation (operator-ratified 2026-07-02). All three
mechanisms are live in the production demo store (cdfqf9k6zf, D1
subs-api-d1) as of the last live query (2026-06-29): 4 plans with
trial_days>0, 3 plans with cycle_discount_pct>0 (intro offers), 1 plan
with interval='year' (annual) — traced in
docs/audits/derived/_demo-state.json
(query_mode: "live", generated_at: "2026-06-29T17:57:15Z"). This was not
true from the seed file alone — demo-state-derive's own OWNER-SPEC records
that on 2026-06-27 the live store carried only 1 trial plan and 0
intro/annual plans despite the seed script generating all three, until the
gap was closed. The cycle-discount ladder has no equivalent live-demo probe
at all, as noted in the delta above.
A trace-vs-recollection conflict, resolved in trace's favor: as of this
generation, epic-5-plan-design.scenario.ts carries a corrected header — its
prior claim that "no trial.* events are emitted" was stale and has already
been fixed in-tree (2026-07-02, 8e80fd20). trial.started /
trial.ending_soon / trial.converted / trial.cancelled are all emitted
(webhooks.ts:862, subscriptions.ts:933, scheduler.ts:1762/1818,
portal/cancel.ts:257) and G4-tested by
trial-lifecycle-events.test.ts and lifecycle-sweeps.scenario.ts. A
recipient reading an older checkout of this file, or a stale local cache,
would have under-claimed "trial events aren't built" had they trusted the
comment instead of grepping the actual emission sites — worth knowing this
class of trap exists in this domain even though the specific instance is
already fixed.
How to operate & extend¶
- Change the trial-ending-soon lead time: the 3-day window is hardcoded
in
processTrialEndingSoon(scheduler.ts:1734), not a per-store or per-plan setting. - Add a yearly option to the admin plan wizard: extend
INTERVAL_OPTIONSinapps/admin/src/components/forms/intervalOptions.ts— the backend already acceptsinterval='year'end to end; this is a UI-only gap. - Add paid trials: the deferred slice needs a
plans.trial_amount_centsmigration, a scheduler branch (currently the trial path always charges $0 at conversion because it skips cycle-0 entirely), and anOptionsFormValuefield plus admin form control. - The invariant you must not break:
processTrialConversionsbeforefindDueCharges, every tick, no exceptions (see above). If you add a new scheduler pass that reads or writestrialingsubscriptions, place it relative to these two calls deliberately, not by habit. - Extension seams:
on_exhaustion-style branching doesn't apply here (that's dunning's seam); this domain's seams are thecycle_discountstier-array shape (add validation at plan-write time if the format needs hardening — the ladder has none today, unlike the co-validated intro-offer triple) and the trial event family (trial.started/ending_soon/converted/cancelled) as an integration point for consumers wanting trial-aware webhooks.
Confidence notes¶
- [Resolved 2026-07-02] The admin manual-create path never set
locked_price_cents. Traced independently while reading Move 2 and filed as Hive #1889; two follow-on defects in the same class were found and filed as #1890 (fallback-capture + both B2B enrollment paths) and #1891 (fifth call site +locked_price_centsmade a requiredcreateSubscriptioninput). All three are closed: everycreateSubscriptioncall site now resolves the lock via the sharedresolveLockedPrice(plan)helper (apps/api/src/services/price-lock.ts), used identically atsubscriptions.ts:653(checkout/webhook parity call) andsubscriptions.ts:918(admin manual-create) — no call site can silently omit the field anymore. This note is left in place as the resolution record; a full corpus regeneration would fold it into Input-B's typed deltas as a closed item rather than a standalone Confidence note. - Input-B cites
apps/admin/src/pages/products/intervalOptions.tsandapps/admin/src/pages/products/types.ts:27-35; the current tree has these files atapps/admin/src/components/forms/intervalOptions.tsandapps/admin/src/components/forms/types.ts:27-35. Both files' own header comments say "Extracted from PlanWizard.tsx" (Spec #427 Slice 3a), which reads as a legitimate post-attestation move, not a fabrication — the content Input-B describes (no yearly option;OptionsFormValuehas notrial_amount_cents) matches exactly at the new paths. I cited the current paths above rather than the Input-B paths. - The
getPlanre-fetch insideprocessChargefor the ladder path is a second D1 read on the hot charge path, separate from whatever plan read already happened earlier in the same function for other purposes. I did not verify whether that duplicate read is deliberate or an unnoticed cost; ADR-0080 §Consequences acknowledges "a single plan load is added to the hot charge-handler path" as an accepted tradeoff, so this is expected behavior, not a bug — noting only because a recipient profiling the scheduler should know the ladder path costs one extra read per charge.