Skip to content

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 — see sign_off above.


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).