As-Built Process / Functional Flows¶
Generated from a canonical source
This page is a read-only projection of docs/architecture/process-flows.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
Candidate evidence, not ratified architecture. Hand-authored against the real Worker code and executable G4 scenario files in
derives_from. Every node cites a function/file or a scenario that proves it. Not yet reviewed by engineering — seesign_offabove.
1. Subscribe¶
flowchart TD
A[BC store/order/created webhook] --> B{Signature valid?<br/>standardwebhooks HMAC}
B -- no --> B1[401]
B -- yes --> C[handleOrderCreated dispatches on scope]
C --> D[GET order + cart-metafields + transactions<br/>from api.bigcommerce.com]
D --> E[decodeSubscriptionIntents<br/>shared contract package]
E --> F[createSubscription<br/>D1 subscriptions row]
F --> G[createCharge<br/>cycle 0, chain_position='initial']
G --> H[logEvent 'subscription.created']
H --> I[200 response to BC]
| Node | Handler / function | Scenario proof |
|---|---|---|
| B — signature check | routes/webhooks.ts::handleBcWebhook (standardwebhooks verify) |
subscribe-end-to-end.scenario.ts (bad-signature step asserts 401, not 404) |
| C — scope dispatch | handleOrderCreated (order-webhook handler, dispatched by scope field) |
subscribe-end-to-end.scenario.ts |
| D — BC reads | api.bigcommerce.com order / cart-metafields / transactions GETs |
subscribe-end-to-end.scenario.ts (stubbed via stubBcForOrder) |
| E — intent decode | decodeSubscriptionIntents from @bc-subscriptions/storefront-contract, paired with the storefront's encodeSubscriptionIntents |
subscribe-end-to-end.scenario.ts header comment: closes the 2026-06-25 wire-contract-drift incident |
| F — subscription row | createSubscription (apps/api/src/db.ts) |
subscribe-end-to-end.scenario.ts |
| G — cycle-0 charge | createCharge (apps/api/src/db.ts) |
subscribe-end-to-end.scenario.ts |
| H — event log | logEvent (apps/api/src/db.ts) |
subscribe-end-to-end.scenario.ts |
2. Renewal¶
flowchart TD
A[Cron Trigger, every minute] --> B[Worker.scheduled → handleScheduled]
B --> C[runScheduledTick]
C --> D[findDueCharges: status='pending', scheduled_at<=now]
D --> E{fireOnScheduledRenewal<br/>extension gate proceeds?}
E -- "mode='hold' (blocked)" --> E1[leave un-advanced · log charge.extension_vetoed]
E -- "mode='skip_advance' (prepaid)" --> E2[advancePrepaidSkippedCycle:<br/>$0 succeeded charge, advance period, schedule next cycle]
E -- proceed --> F[resolve discounts + promotions + store credit<br/>listPendingDiscounts / resolveDiscountStack / computeAndApplyCreditAtRenewal]
F --> G{adapter.requiresPreExistingOrder?}
G -- yes BC Payments --> H[createBcIncompleteOrder → BC order status_id:0]
G -- no other adapters --> I[skip pre-order]
H --> J[adapter.charge ctx]
I --> J
J --> K{result}
K -- succeeded --> L[markChargeAttempted succeeded]
L --> M[logEvent charge.succeeded]
M --> N[EVENTS_QUEUE: email.requested + sms.requested]
N --> O[transitionBcOrderStatus → merchant target status]
K -- failed/threw --> P[see Dunning flow]
| Node | Handler / function | Scenario proof |
|---|---|---|
| B — cron entry | handleScheduled (apps/api/src/worker.ts:2890) |
exercised transitively by every renewal scenario below (calls runScheduledTick) |
| C/D — tick + due-charge scan | runScheduledTick, findDueCharges (apps/api/src/scheduler.ts) |
epic-15-renewal-recalc.scenario.ts |
| E — extension gate | fireOnScheduledRenewal (apps/api/src/extensions/lifecycle-hooks.ts), consumed in scheduler.ts::processCharge |
epic-15-renewal-recalc.scenario.ts; prepaid skip-advance path documented in scheduler.ts comment block above advancePrepaidSkippedCycle |
| F — discount/credit resolution | listPendingDiscounts, resolveDiscountStack, computeAndApplyCreditAtRenewal (apps/api/src/db.ts, apps/api/src/services/store-credits.ts) |
epic-15-renewal-recalc.scenario.ts |
| H — order-first | createBcIncompleteOrder (apps/api/src/routes/orders.ts) |
covered by BC-Payments-adapter charge path; ADR-0025 |
| J — processor call | adapter.charge(ctx) → adapters/bc-payments.ts three-call sequence |
see docs/architecture/sequence-diagrams.md §1 |
| L/M/N/O — success tail | markChargeAttempted, logEvent, EVENTS_QUEUE.send, transitionBcOrderStatus |
epic-15-renewal-recalc.scenario.ts |
3. Dunning¶
flowchart TD
A[Charge attempt fails<br/>adapter throws or declines] --> B[markChargeAttempted status=failed]
B --> C{classifyDecline}
C -- hard decline --> D[updateSubscriptionStatus → 'past_due'<br/>logEvent subscription.past_due<br/>NO further retries]
C -- soft / unknown --> E[scheduleChargeRetry retry_attempt+1]
E --> F[applyDunningPolicy]
F --> G[getOrSeedDunningPolicy store_hash → stages]
G --> H{stage at index retry_attempt exists?}
H -- yes --> I[UPDATE charges: status=pending, scheduled_at=next_retry_at]
I --> J[logEvent charge.retry_scheduled]
J --> K[EVENTS_QUEUE.send email.requested, template = stage.email_template_key]
K --> L[next cron tick's findDueCharges re-picks the charge]
H -- no, stages exhausted --> M[markChargeFailedPermanently]
M --> N[logEvent charge.failed_permanently]
N --> O[EVENTS_QUEUE.send template_key='dunning_final']
O --> P[apply on_exhaustion policy, default='cancel']
Q[Subscriber updates payment method via portal] --> R[resetDunningOnPmUpdate:<br/>retry_attempt→0, status→pending, scheduled_at→now]
R --> S[UPDATE subscriptions status→'active']
S --> T[logEvent subscription.dunning_reset]
| Node | Handler / function | Scenario proof |
|---|---|---|
| A/B/C — failure classification | scheduler.ts::processCharge catch branches, classifyDecline (apps/api/src/services/decline-classifier.ts) |
exercised by epic-11-dunning.scenario.ts fixtures (seeded failed charges) |
| F/G/H — policy resolution | applyDunningPolicy, getOrSeedDunningPolicy (apps/api/src/services/dunning-retry.ts) |
epic-11-dunning.scenario.ts Scenario 2 (US-11.6) |
| I/J/K — retry reschedule + notify | same file, lines ~113-150 | epic-11-dunning.scenario.ts — asserts charge.retry_scheduled event + email.requested queue payload |
| M/N/O/P — exhaustion | markChargeFailedPermanently, on_exhaustion policy |
epic-11-dunning-digest.scenario.ts (digest aggregates failed_permanently charges) |
| Q/R/S/T — subscriber-driven reset | resetDunningOnPmUpdate, reached from handlePortalUpdatePaymentMethod (apps/api/src/routes/portal/payment-method.ts) |
epic-11-dunning.scenario.ts Scenario 1 (US-11.5) |
4. Cancel¶
flowchart TD
A[Subscriber POST /api/v1/portal/subscriptions/:id/cancel] --> B[verifyPortalSessionAndOwnership]
B -- fail --> B1[401/404]
B -- ok --> C[RATE_LIMITER check — portal-action namespace]
C -- exceeded --> C1[429]
C -- ok --> D{checkCancelLock:<br/>cycles_completed < plan.commitment_cycles?}
D -- locked --> D1[409 conflict, kind=cancel_locked,<br/>lock_expires_at in body]
D -- unlocked --> E{checkContractCancelGuard:<br/>B2B contract notice window + store mode?}
E -- "mode='block', inside window" --> E1[409 reason=contract_notice_period<br/>subscription stays active]
E -- "mode='route', inside window" --> E2[409 reason=cancel_routed_to_legal<br/>enqueueDelivery: pending cancel_routed webhook row]
E -- outside window / disabled / no contract --> F[resolveEarlyCancelPolicy — minimum-term fee/refund calc]
F --> G[UPDATE subscriptions SET status='cancelled', cancelled_at, cancel_reason]
G --> H[logEvent subscription.cancelled]
H --> I[dispatchCancelTicket — best-effort CS helpdesk ticket]
I --> J[200 response]
| Node | Handler / function | Scenario proof |
|---|---|---|
| A/B — route + session | handlePortalCancelSubscription (apps/api/src/routes/portal/cancel.ts) |
epic-18-term-cancel-lock.scenario.ts, epic-24-contract-cancel-guard.scenario.ts |
| D — commitment lock | checkCancelLock (apps/api/src/services/cancel-lock-policy.ts) |
epic-18-term-cancel-lock.scenario.ts US-18.10 — asserts 409 with kind==='conflict', reason==='cancel_locked', lock_expires_at present (impl shape; BRD-literal error/subscription_locked naming is NOT built — documented divergence in the scenario file header) |
| E — B2B contract guard | checkContractCancelGuard (apps/api/src/services/contract-cancel-guard.ts) |
epic-24-contract-cancel-guard.scenario.ts — BLOCK/ROUTE/CONTROL cases |
| F — early-cancel fee/refund | resolveEarlyCancelPolicy (apps/api/src/services/minimum-term-policy.ts) |
referenced in routes/portal/cancel.ts imports; exercised transitively by the CONTROL cases in epic-24-contract-cancel-guard.scenario.ts |
| G/H — terminal state | direct D1 UPDATE + logEvent in routes/portal/cancel.ts |
both cancel scenarios' "cancels normally" assertions |
| I — CS ticket | dispatchCancelTicket (apps/api/src/services/helpdesk/dispatch-cancel-ticket.ts) |
imported in routes/portal/cancel.ts; best-effort, not scenario-asserted in the two files above |
5. Gift — DELIVERY path, NOT a charge path¶
This is the load-bearing structural fact of the gift flow: a gift recipient's subscription
never touches charges or dunning. The giver pays once, upfront, at purchase time. The
recipient's subscription is created with next_charge_at = NULL and drives forward purely
through a delivery-instance sweep, not the billing scheduler.
Citation proving the delivery-path structure:
apps/api/test/scenarios/gift-delivery-sweep.scenario.ts header (lines 1–17): "the money-path
guarantee this scenario exists to PROVE: a gift sub driven to exhaustion through the FULL cron
tick... produces ZERO charges and ZERO dunning — because the recipient never owed money." The
scenario runs runScheduledTick + runFulfillmentSweep + runChargeRetrySweep +
runDunningDigestSweep on every tick and asserts the charge count stays at 0 throughout.
The mechanism, verified directly in apps/api/src/extensions/gift.ts (lines 163-176, comment +
code): onTokenClaim creates the recipient subscription with next_charge_at = NULL and
payment_method_id = NULL (explicitly NOT copied from the giver's gift record). The inline
comment states the invariant precisely: "This is the STRUCTURAL no-dunning guarantee: every
renewal/charge scan filters next_charge_at IS NOT NULL... so a NULL next_charge_at makes the
gift sub permanently invisible to the billing path." Concretely: apps/api/src/db.ts::findDueCharges
(line 3565) scans the charges table by scheduled_at, not subscriptions.next_charge_at — and
because no charges row is ever created for the recipient subscription (the giver's one upfront
charge is the only money that ever moves), there is nothing for findDueCharges to find. Deliveries
instead flow through the gift_delivery extension (fillForSubscription, anchored on
current_period_end, not next_charge_at), consumed by runFulfillmentSweep.
flowchart TD
A[Giver: POST /api/v1/storefront/checkout/gift<br/>plan_id, cycles, recipient_email, payment_method_id] --> B[handleGiftPurchase]
B --> C[Giver charged ONCE, upfront, for N cycles<br/>via normal adapter.charge path — ADR-0025 test-mode/synthetic in CI]
C --> D[createIncompleteOrderForGift +<br/>gift subscription created, status='paused',<br/>gift_from_customer_id set]
D --> E[subscription_extensions row, extension_type='gift'<br/>claim_token + claim_expires_at + cycles]
E --> F[Recipient emailed reveal link — out of scope here]
F --> G[Recipient: POST /api/v1/portal/gifts/:token/claim — no auth, token is the credential]
G --> H[handleGiftClaim → giftService.onTokenClaim]
H --> I{Token valid, unclaimed,<br/>within claim window?}
I -- no --> I1[404 / 409 conflict / 412 precondition_failed]
I -- yes --> J[ATOMIC claim: UPDATE subscription_extensions<br/>SET claimed_at WHERE claimed_at IS NULL<br/>— compare-and-set, race-safe]
J --> K[Create RECIPIENT subscription:<br/>status='active', next_charge_at=NULL,<br/>gift_origin_subscription_id set]
K --> L[Attach gift_delivery extension:<br/>seed N delivery instances, one per prepaid cycle]
L --> M[200: recipient subscription_id]
M --> N[runFulfillmentSweep sweeps gift_delivery instances<br/>on schedule — ships each cycle as a $0/no-charge BC order]
N --> O{cycles exhausted?}
O -- no --> N
O -- yes --> P[processGiftEndingSoon fires at N-1<br/>notifies recipient · gift-convert opt-in offered]
P --> Q{recipient opts in?<br/>convert_consent_at set}
Q -- yes --> Q1["convert to paid billing<br/>(separate flow — sets next_charge_at, exits delivery-only path)"]
Q -- no / lapses --> R[subscription ends — NO charge, NO dunning, ever]
| Node | Handler / function | Scenario proof |
|---|---|---|
| A/B — purchase | handleGiftPurchase (apps/api/src/routes/storefront/checkout/gift.ts) |
gtm-gift.scenario.ts (real HTTP route via worker.fetch), p3-jordan-gifting.scenario.ts (direct handler) |
| D/E — gift sub + extension | subscription created status='paused', subscription_extensions row extension_type='gift' |
gtm-gift.scenario.ts |
| G/H — claim route | handleGiftClaim (apps/api/src/routes/portal/gifts/[token]/claim.ts) → giftService.onTokenClaim (apps/api/src/extensions/gift.ts:115) |
gtm-gift.scenario.ts (real HTTP route), gift-delivery-sweep.scenario.ts (drives claim → sweep → exhaustion) |
| J — atomic single-use consume | UPDATE subscription_extensions ... WHERE claimed_at IS NULL compare-and-set (extensions/gift.ts:144-154) |
comment cites Hive #1754 (race-safety fix) |
K — recipient sub, next_charge_at=NULL |
giftService.onTokenClaim (structural no-dunning guarantee) |
gift-delivery-sweep.scenario.ts header comment, explicit |
| L/N — delivery instances + sweep | gift_delivery extension (apps/api/src/extensions/gift-delivery.ts), runFulfillmentSweep (apps/api/src/cron/fulfillment-sweep.ts:115) |
gift-delivery-sweep.scenario.ts |
| P — ending-soon notice | processGiftEndingSoon (apps/api/src/extensions/gift-delivery.ts), called from runScheduledTick (scheduler.ts:2508) |
referenced in scheduler.ts comment: "BRD §US-6.1 AC3" |
| Q1 — convert opt-in | handlePortalGiftConvertOptIn (apps/api/src/routes/portal/gift-convert.ts), convert_consent_at discriminator |
memory: gift-conversion-ac3-ac4 (PR #1812) — AC3/AC4, out of scope for this diagram's delivery-path proof |
| R — zero-charge guarantee | assertion across all 4 sweeps in one scenario | gift-delivery-sweep.scenario.ts — charge count asserted 0 across every tick |
What would make this diagram wrong: any arrow from the recipient subscription into
scheduler.ts::processCharge, findDueCharges, or applyDunningPolicy. None exists in the
real code — the recipient subscription never gets a charges row at all (only the giver's
single upfront charge exists), and next_charge_at = NULL + payment_method_id = NULL make
every other renewal-adjacent scan (OOS check, shipping recalc, bundle sweep) skip the row by
their own next_charge_at IS NOT NULL predicates (per the inline comment in
apps/api/src/extensions/gift.ts lines 165-168).