As-Built Sequence Diagrams¶
Generated from a canonical source
This page is a read-only projection of docs/architecture/sequence-diagrams.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
This document is candidate evidence, not ratified architecture. It was hand-authored by re-reading the real Cloudflare Worker code and the executable G4 scenario files listed in
derives_fromabove. Every participant and message below is pinned to a real function/file/table — no invented identifiers. It has NOT yet been signed off by an engineering reviewer (seesign_offfrontmatter). Do not cite this file as authoritative untilsign_off.statusflips toconfirmed.Explicitly superseded scope:
ARCHITECTURE.md§12 (Appendix A) and §13 (Appendix B) depict the two hosting stacks evaluated at decision time — neither is what shipped; both are historical decision-time comparison content perARCHITECTURE.md's own header note. This document replaces their intent (an end-to-end renewal sequence) with the real Cloudflare Workers + D1 + Queues flow that is actually in production perARCHITECTURE.md§0.
1. Renewal — end-to-end charge sequence¶
Cron-triggered. One tick = wrangler.toml crons = ["* * * * *"] (every minute) firing
Worker.scheduled() → handleScheduled() (apps/api/src/worker.ts:2890) → runScheduledTick()
(apps/api/src/scheduler.ts:2484).
This diagram follows one due charge through the immediate-capture, BC-Payments-adapter
path (the default; adapter.charge(), not the deferred-capture authorize()/capture()
branch gated by resolveCaptureTiming). The BC Payments three-call sequence is the verified
standard rail per adapters/bc-payments.ts lines 118–221 (PAT mint → methods lookup →
BigPay charge).
sequenceDiagram
autonumber
participant Cron as Cloudflare Cron Trigger
participant Worker as Worker.scheduled()
participant Sched as scheduler.ts::runScheduledTick
participant DB as D1 (findDueCharges)
participant Proc as scheduler.ts::processCharge
participant Adapter as adapters/bc-payments.ts::charge()
participant BCPay as payments.bigcommerce.com
participant BCApi as api.bigcommerce.com (/v3/payments/*)
participant Orders as routes/orders.ts
participant Dunning as services/dunning-retry.ts::applyDunningPolicy
participant Queue as EVENTS_QUEUE / email.requested
Cron->>Worker: scheduled(event)
Worker->>Sched: handleScheduled() step 1 (worker.ts:2910)
Sched->>DB: findDueCharges(repo, now) (scheduler.ts:2538)
DB-->>Sched: due ChargeRow[] (status='pending', scheduled_at<=now)
loop for each due charge
Sched->>Proc: processCharge(env, repo, charge)
Proc->>DB: getSubscriptionAsSystem(charge.subscription_id)
Note over Proc: fireOnScheduledRenewal() extension gate (ADR-0033).<br/>proceed=false + mode='hold' → block · mode='skip_advance' → prepaid path (advancePrepaidSkippedCycle), NOT shown here.
Proc->>DB: getPaymentMethodAsSystem(charge.payment_method_id)
Proc->>DB: listPendingDiscounts + resolveDiscountStack + evaluateSubscriptionPromotions
Proc->>DB: computeAndApplyCreditAtRenewal (store credit, US-12.2)
Proc->>Orders: createBcIncompleteOrder(env, charge.id) — order-first (ADR-0025, adapter.requiresPreExistingOrder=true)
Orders-->>Proc: { bc_order_id } (status_id: 0 Incomplete)
Proc->>Adapter: adapter.charge(ctx) — ctx.bcOrderId set
Adapter->>BCApi: POST /v3/payments/access_tokens { order.id, is_recurring:true }
BCApi-->>Adapter: { data.id } (Payment Access Token)
Adapter->>BCApi: GET /v3/payments/methods?order_id=
BCApi-->>Adapter: methods[].stored_instruments[] — match by token === ctx.paymentMethodRef
Adapter->>BCPay: POST /stores/{hash}/payments { instrument.token, payment_method_id }
BCPay-->>Adapter: { data.id, data.status } (Authorization: PAT header)
Adapter-->>Proc: ChargeResult { status:'succeeded', processor_transaction_id, mit_classification:'MREC' }
Proc->>DB: markChargeAttempted(charge.id, dbResult) (scheduler.ts:1112)
Proc->>DB: logEvent('charge.succeeded') (db.ts::logEvent → INSERT events)
Proc->>Queue: EVENTS_QUEUE.send({type:'email.requested', template_key:'renewal_confirmation'})
Proc->>Queue: EVENTS_QUEUE.send({type:'sms.requested', template_key:'renewal_confirmation'})
Proc->>Orders: transitionBcOrderStatus(bc_order_id, targetStatusId, processor_transaction_id) — post-charge (ADR-0025)
Orders-->>Proc: order moved to merchant-configured status (default 11, Awaiting Fulfillment)
end
Worker->>Worker: step 2 — findRecentlySucceededChargesWithBundle(sinceIso) (worker.ts:2921)
Worker->>Orders: materializeBcOrderForCharge(env, charge.id) for non-order-first adapters (skipped here — BC Payments already order-first)
Failure branch (adapter throws / declines) — same participants, alternate path, verified
at scheduler.ts:997-1108:
sequenceDiagram
autonumber
participant Proc as scheduler.ts::processCharge
participant Adapter as adapters/bc-payments.ts::charge()
participant DB as D1
participant Dunning as services/dunning-retry.ts::applyDunningPolicy
participant Queue as EVENTS_QUEUE
Proc->>Adapter: adapter.charge(ctx)
Adapter-->>Proc: throws (HTTP 4xx/5xx from BigPay, or BcPaymentsRateLimitError on 429)
alt HTTP 429 (BcPaymentsRateLimitError)
Proc->>DB: UPDATE charges SET status='pending', scheduled_at=now+2h+jitter (scheduler.ts:1007-1023)
Proc->>DB: logEvent('charge.rate_limit_reschedule')
else terminal (401/402/403 → TerminalAdapterError, isTerminal())
Proc->>DB: markChargeAttempted(status:'failed', failure_code:'terminal_error')
Proc->>DB: logEvent('charge.failed', {terminal:true})
else soft/hard decline (classifyDecline)
Proc->>DB: markChargeAttempted(status:'failed', failure_code:'adapter_threw')
Proc->>DB: logEvent('charge.failed', {decline_classification})
alt hard decline
Proc->>DB: updateSubscriptionStatus(subscription.id, 'past_due')
Proc->>DB: logEvent('subscription.past_due')
else soft/unknown decline
Proc->>DB: scheduleChargeRetry(charge.id, retry_attempt+1, now)
Proc->>Dunning: applyDunningPolicy(repo, freshCharge, subscription, failure_code, now, EVENTS_QUEUE)
Dunning->>DB: getOrSeedDunningPolicy(store_hash) → stages[retry_attempt]
alt stage exists
Dunning->>DB: UPDATE charges SET status='pending', scheduled_at=next_retry_at
Dunning->>DB: logEvent('charge.retry_scheduled')
Dunning->>Queue: EVENTS_QUEUE.send({type:'email.requested', template_key: stage.email_template_key})
else stages exhausted
Dunning->>DB: markChargeFailedPermanently(charge.id)
Dunning->>DB: logEvent('charge.failed_permanently')
Dunning->>Queue: EVENTS_QUEUE.send({template_key:'dunning_final'})
Dunning->>DB: apply on_exhaustion policy (default 'cancel')
end
end
end
Verified by: apps/api/test/scenarios/epic-15-renewal-recalc.scenario.ts (renewal
recompute path), apps/api/test/scenarios/epic-11-dunning.scenario.ts (retry/dunning-reset
path), apps/api/test/scenarios/epic-11-dunning-digest.scenario.ts (digest aggregation).
2. Subscribe — order/created webhook → subscription creation¶
Ground truth: apps/api/test/scenarios/subscribe-end-to-end.scenario.ts drives this through
worker.fetch (the real router), not a direct handler call — proving the HTTP route, the
standardwebhooks HMAC signature verification, and the scope dispatcher are actually wired
(the "route-orphan" failure class called out in the scenario's own header comment).
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) — same encode/decode pair the storefront uses, closing the prior wire-contract drift.
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
Idempotency: the scenario replays the same signed webhook a second time and asserts the
subscription count does not increase — handleOrderCreated treats created_from_order_id
as the dedup key.
Verified by: apps/api/test/scenarios/subscribe-end-to-end.scenario.ts +
subscribe-end-to-end.scenario.def.ts.
3. Dunning — retry scheduling + subscriber-initiated reset¶
Two behaviors, both exercised at apps/api/test/scenarios/epic-11-dunning.scenario.ts:
(a) the retry-notification path inside applyDunningPolicy (already shown in §1's failure
branch), and (b) the subscriber PM-update reset path below.
sequenceDiagram
autonumber
participant Sub as Subscriber (portal)
participant PMRoute as routes/portal/payment-method.ts::handlePortalUpdatePaymentMethod
participant Reset as resetDunningOnPmUpdate
participant DB as D1
Sub->>PMRoute: PUT /api/v1/portal/subscriptions/:id/payment-method
PMRoute->>DB: verify portal-session JWT + subscription ownership
PMRoute->>DB: UPDATE subscriptions SET payment_method_id = new PM
PMRoute->>Reset: resetDunningOnPmUpdate(repo, subscription) — only when subscription.status='past_due'
Reset->>DB: UPDATE charges SET retry_attempt=0, status='pending', scheduled_at=now, next_retry_at=NULL WHERE subscription_id AND status IN dunning-in-progress states
Reset->>DB: UPDATE subscriptions SET status='active'
Reset->>DB: logEvent('subscription.dunning_reset')
Note over Reset,DB: The re-armed charge is picked up by the NEXT cron tick's findDueCharges scan (§1) — no synchronous charge call happens in this handler.
Verified by: apps/api/test/scenarios/epic-11-dunning.scenario.ts Scenario 1 (US-11.5)
and Scenario 2 (US-11.6, retry-notification payload).