Skip to content

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 conventionsnapshotPlanCycleDiscount returns null and writes nothing the moment a plan carries a cycle_discounts ladder (ADR-0080 §Decision).
  • Locked base price follows the same snapshot-at-creation idiom as intro discountslocked_price_cents is 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 trialingactive 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_from pins routes/webhooks.ts, db.ts, among others). Its frontmatter carries sign_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 the status branch (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 — finds trialing subscriptions whose next_charge_at falls within the next 3 days and trial_ending_soon_sent = 0, CAS-flips the flag, and emits trial.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 — finds trialing subscriptions whose next_charge_at (the trial end) has passed, flips status='active', re-anchors current_period_end = next_charge_at, and emits subscription.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 returns null immediately 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_scope are set, it translates the plan-level scope (first_cycle_only or first_n_cycles) into a (cycle_min, cycle_max) gate and inserts one subscription_discounts row tagged reason: '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):

  1. listPendingDiscounts + resolveDiscountStack reads any snapshotted subscription_discounts rows for the current cycle (the intro-offer path) — this is effectivePercent.
  2. A second, independent read re-fetches the plan (getPlan(repo, subscription.plan_id, ...)) and, if plan.cycle_discounts is non-null, parses the tier JSON and calls resolveCycleDiscount(tiers, currentCycle, 0) — this is ladderPct.
  3. combinedPct = Math.min(100, effectivePercent + ladderPct). Because snapshotPlanCycleDiscount guarantees a ladder plan never wrote a subscription_discounts row, effectivePercent is structurally 0 for ladder plans and ladderPct is 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 from schema.sql by tools/erd-derive/, 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 plans entity carries 39 columns and subscriptions carries 39 columns; both are omitted here except the ones this domain touches. subscription_discounts is not itself in the ERD excerpt above the fold — its shape is inferred from the snapshotPlanCycleDiscount INSERT columns (db.ts:3968-4020), not transcluded from the ERD.

Supporting pieces:

  • Locked price: locked_price_cents is 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-1541 substitutes subscription.locked_price_cents for the plan's current amount_cents when the lock is set.
  • Annual billing: plans.interval accepts '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 in schema.sql at 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 — OptionsFormValue carries trialEnabled/trialDays but no trial_amount_cents field (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 (trialing gets its own accent-toned badge and the "Trial" label, not a neutral/raw fallback — subscriptionStatus.ts:37,58, asserted by subscriptionStatus.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.5 gaps: 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 at apps/admin/src/components/forms/intervalOptions.ts:14-21 (INTERVAL_OPTIONS array): 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 checks cycle_discount_pct (the intro-offer path) and never queries cycle_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_OPTIONS in apps/admin/src/components/forms/intervalOptions.ts — the backend already accepts interval='year' end to end; this is a UI-only gap.
  • Add paid trials: the deferred slice needs a plans.trial_amount_cents migration, a scheduler branch (currently the trial path always charges $0 at conversion because it skips cycle-0 entirely), and an OptionsFormValue field plus admin form control.
  • The invariant you must not break: processTrialConversions before findDueCharges, every tick, no exceptions (see above). If you add a new scheduler pass that reads or writes trialing subscriptions, 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 the cycle_discounts tier-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_cents made a required createSubscription input). All three are closed: every createSubscription call site now resolves the lock via the shared resolveLockedPrice(plan) helper (apps/api/src/services/price-lock.ts), used identically at subscriptions.ts:653 (checkout/webhook parity call) and subscriptions.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.ts and apps/admin/src/pages/products/types.ts:27-35; the current tree has these files at apps/admin/src/components/forms/intervalOptions.ts and apps/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; OptionsFormValue has no trial_amount_cents) matches exactly at the new paths. I cited the current paths above rather than the Input-B paths.
  • The getPlan re-fetch inside processCharge for 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.