Skip to content

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_from above. 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 (see sign_off frontmatter). Do not cite this file as authoritative until sign_off.status flips to confirmed.

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