Skip to content

The canonical charge rail

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/canonical-charge-rail.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What canonical-charge-rail is for

The invariant you must not break: no raw card data ever touches this application. Card entry happens in BC's hosted checkout, BigPay-hosted iframes, or a Stripe Elements iframe on the edge path; this app persists only tokens. A single logged PAN or proxied card field re-scopes the product from PCI SAQ-A to SAQ-D — a full QSA audit, disqualifying for the marketplace timeline. (ADR-0060.)

The capability breaks into four reader-facing features:

  • Automatic renewal charging through BigCommerce — recurring charges run through BC's own payments API against the customer's saved card, whatever gateway the merchant uses (US-10.x)
  • Card saved once at checkout — a signed-in shopper ticking "Save this card" needs no further steps before their first renewal (US-9.x / checkout-CIT)
  • Add a card from the subscriber portal — guests and card-updaters vault a payment method post-purchase via BC's instrument flow (US-19.1)
  • Merchant processor connection — connect BC Payments or Stripe from the admin, with per-plan overrides supported (US-2.2, US-2.6)

Correction a newcomer needs first, because the repo's own adapter files invite the wrong conclusion: there are not "parallel rails" for BC Payments vs. Stripe. apps/api/src/adapters/stripe.ts sits right next to apps/api/src/adapters/bc-payments.ts, and reading them side by side reproduces an earlier planning-time model that a later decision explicitly corrected — three independent readers made exactly this mistake before the correction was traced (ADR-0082 §Context). The four load-bearing decisions, each carrying its own rationale:

  • One gateway-agnostic rail, not per-gateway adapters. All recurring charges route through payments.bigcommerce.com with a stored-instrument token; the engine holds no gateway-specific charge logic; BC routes to whichever gateway the merchant configured underneath. (ADR-0037.)
  • Order-first sequencing for the canonical rail. A BC Incomplete order must exist before the charge — the PAT mint and methods lookup are order-scoped. The scheduler, not the adapter, owns order creation. (ADR-0025.)
  • Stable idempotency key. ${subscription_id}:${cycle_anchor_iso}, never mutated per attempt. (ADR-0011.)
  • Empirical validation + call-target verification doctrine. The rail is now proven live, and rail identity is only ever proven by the outbound call target, never the transaction id — a Stripe pi_* id can appear on both rails because BC mediates to Stripe underneath. (ADR-0082.)

Canonical-framing attestation (operator-ratified 2026-07-02). BC's gateway-agnostic stored-instruments rail — payments.bigcommerce.com, implemented by bc-payments.ts — is the canonical charge path for all recurring charges regardless of the merchant's gateway. The direct-Stripe adapter (stripe.ts) is a sanctioned edge case for merchants tokenizing outside BC's vault and residue of a parallel-rails framing ADR-0037 corrected. Traced against ADR-0037 (ratifies the single rail; calls the parallel-rails framing "misleading") and ADR-0082 (records the as-built violation, the fix, and the live validation).

How it actually works

All adapters implement one contract, adapters/types.ts: charge / refund / authorize / capture / voidAuth, plus a requiresPreExistingOrder flag that drives sequencing and an authWindowDays value the deferred-capture sweep reads. A charge carries an idempotency key, a stored-instrument reference, and an MIT context (the network transaction ID and chain position that tell the card networks this is a recurring charge the customer pre-authorized).

The scheduler resolves the adapter per subscription's processor connection, via services/adapter-registry.ts::getAdapter. It switches on processor_connections.processor: 'bc_payments' wires resolveStoreAuth (loads the store's OAuth token via loadStoreAccessToken) into createBcPaymentsAdapter; 'stripe' decrypts the per-store Stripe secret key (HKDF, decryptForStore) and constructs createStripeAdapter; 'braintree' and 'authnet' both throw "schema-registered but not yet implemented" — the switch never reaches apps/api/src/adapters/authnet.ts even though that file exists and implements the full ProcessorAdapter contract as a throwing stub.

Read the canonical rail as: cron finds due charges → for each, create the Incomplete BC order first (order-first, ADR-0025) → the adapter mints a Payment Access Token → looks up the stored instrument by matching stored_instruments[].token → posts the charge to payments.bigcommerce.com → BC routes to the merchant's configured gateway underneath → on success, mark the charge and transition the order to Awaiting Fulfillment (adapters/bc-payments.ts:96-238). The direct-Stripe edge adapter skips the pre-existing-order branch entirely — requiresPreExistingOrder: false (adapters/stripe.ts:78-178) — and calls POST https://api.stripe.com/v1/payment_intents directly with an Idempotency-Key header carrying the same stable key (ADR-0011).

Read this diagram as the happy-path canonical rail — cron trigger through BC order creation, the three-call sequence, and the post-charge order transition.

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)

Diagram provenance. Transcluded verbatim from § 1 "Renewal — end-to-end charge sequence" of the canonical, code-sourced docs/architecture/sequence-diagrams.md (hand-authored against real Worker code + G4 scenario files, derives_from pinned). It carries sign_off: pending — accurate to the code, not yet human-attested. In the handoff pipeline this is a build-time include of that one source, never a hand-copied fork.

The stored instrument is a token reference, never a card — the data model keeps only non-PAN fields (card_last4, card_brand, expiry) plus the token and the MIT network_transaction_id:

erDiagram
    processor_connections {
        TEXT id PK
        TEXT store_hash FK
        TEXT processor
        TEXT status
        INTEGER is_default
        TEXT routing_channel_ids
    }
    payment_methods {
        TEXT id PK
        TEXT customer_id FK
        TEXT processor_connection_id FK
        TEXT payment_method_ref
        TEXT network_transaction_id
        TEXT card_brand
        TEXT card_last4
        INTEGER card_exp_month
        INTEGER card_exp_year
    }
    processor_connections ||--o{ payment_methods : "issues tokens for"

Diagram provenance. Transcluded from the canonical, code-sourced docs/architecture/data-model-erd.md (@generated by tools/erd-derive/, drift-gated in CI against apps/api/src/schema.sql). Focused excerpt — the full ERD shows 2 of 85 tables here. Unlike the sequence diagram above, this source file's own frontmatter carries no explicit sign_off field (only canonical: false + staleness_threshold_days: infinite); read it as mechanically accurate to the schema, not human-attested either way.

Onboarding a processor connection: routes/admin/processor-connections.ts handles both processors through one endpoint. A Stripe connection live-validates the pasted secret key against GET https://api.stripe.com/v1/account before persisting it (validateStripeKey, adapters/stripe.ts:477-554) — catching a revoked/wrong-mode/typo'd key at paste time instead of the first live charge. A bc_payments connection takes no secret at all: it resolves store OAuth via the adapter registry's resolveStoreAuth, so config stays NULL and creation is a straight insert. Both branches reject a second active connection of the same processor for the store (409) and enforce a single is_default connection at the DB layer (partial unique index).

Resolving the payment method at subscription-creation time: services/processor-resolution.ts::resolveBcPaymentsInstrument fetches the customer's BigPay stored instruments (GET /v3/customers/{id}/stored-instruments) and upserts the matching stored_card token. The fetch is deliberately fail-soft — a network error or empty result falls back to a fixture payment method rather than blocking subscription creation, logged as a warning.

Where intent and reality diverge

The coverage matrix (_coverage-matrix.json) reports the payment epics fully G4-verified — processor onboarding (epic-2) 7/7, charge scheduling & execution (epic-10) 9/9, payment-method management (epic-19) 6/6, zero failing. For an external-integration domain that is the most misleading kind of true. Five typed deltas:

  • Superseded-framing residuestripe.ts (direct adapter) + its spec exist because an earlier planning pass modeled parallel rails; ADR-0037 corrected the framing but kept the adapter as the outside-BC-vault edge case. A recipient reading the adapters side by side reaches the wrong model — three independent readers did (ADR-0082 §Context).
  • Contract-verified, not live-verified — per-seam: the orchestration epics (2/10/19) G4-pass against a stub adapter (epic-10-charge-scheduling.scenario.ts:658, const stubAdapter = { authWindowDays: 7 } as unknown as ProcessorAdapter); the direct-Stripe edge has a real-sandbox e2e that is env-gated (stripe-rail-e2e.scenario.ts, describe.skipIf(!shouldRun), skipped without STRIPE_SECRET_KEY=sk_test_...); the canonical rail's live proof is the ADR-0082 executions, not the always-on matrix.
  • Verified-but-incomplete — checkout-CIT vaulting requires a signed-in shopper (guest checkouts get no save-card checkbox); the ratified funnel keeps guest subscribe first-class with the portal IAT flow as the guest instrument path (PRD.md §6.3 funnel decision, operator-ratified 2026-07-02; hive decision 051b26d2).
  • Named-deferred — deferred capture on the canonical rail: authorize()/capture()/voidAuth() throw bc_deferred_capture_not_ga (adapters/bc-payments.ts:356-416; BC's transactions-capture/void endpoints are not GA per the file's dated verification note), and the scheduler fallback the adapter comment promises is missing — EU / on_ship stores would burn dunning stages (filed as Hive #1883).
  • Built-but-untroddenauthnet.ts implements the full ProcessorAdapter contract but every method throws a not-yet-implemented error (adapters/authnet.ts:72-101); it is not even reachable through adapter-registry.ts::getAdapter, which throws for the 'authnet' case before ever constructing the adapter. Braintree is enum-registered (processor_connections.processor CHECK constraint) with no adapter file at all.

How to operate & extend

  • Adding gateway support usually needs no code. If BC supports the gateway and the merchant enables Stored Payments, the canonical rail already charges it — that's the gateway-agnostic payoff. A direct adapter is only for the edge case of charging a gateway outside BC's vault; implement ProcessorAdapter (adapters/types.ts) and wire it into adapter-registry.ts::getAdapter (stripe.ts is the reference, authnet.ts the unwired stub template).
  • The invariant you must not break: no raw card data through the app (SAQ-A). Any path keeps card entry in a hosted vault/iframe and persists only a token.
  • Where rail/gateway selection lives: services/adapter-registry.ts::getAdapter dispatches on processor_connections.processor; processor-resolution.ts resolves the vaulted instrument at subscription-creation time; onboarding is routes/admin/processor-connections.ts (both processors, one endpoint, with is_default / routing_channel_ids set via the PATCH route).
  • To connect a processor: POST /api/admin/processor-connections with { processor: 'bc_payments' } (no secrets) or { processor: 'stripe', stripe_publishable_key, stripe_secret_key } (live-validated against Stripe before it persists).
  • Remaining migration scope (Hive #1882): existing subscriptions bound to the direct-Stripe connection keep charging through it until their payment methods/connections migrate to the vault — tokens can't move without card re-entry. New subscriptions should drive the subscribe UX through signed-in save-card checkout so the CIT vault path fires.
  • Verifying which rail actually ran: never trust the transaction id — a Stripe pi_* id appears on both rails. Trace the outbound call target: payments.bigcommerce.com (canonical) vs. api.stripe.com (direct edge).

Confidence notes

  • authnet.ts "wired" is ambiguous. Input-B's typed delta describes it as "a wired stub (contract present, charge() throws)." The ProcessorAdapter contract is fully implemented there, but tracing adapter-registry.ts::getAdapter shows the 'authnet' case throws "schema-registered but not yet implemented" before ever calling createAuthnetAdapter — the stub file exists and type-checks but is not reachable through the only production dispatch path. I read "wired" as "the adapter contract is implemented," not "wired into the registry," and worded the mechanism section to make that distinction explicit rather than silently reconcile it.
  • ERD sign_off claim. Input-B's visual-aid pointer states sign_off: pending for both diagrams. docs/architecture/sequence-diagrams.md carries that field in its own frontmatter; docs/architecture/data-model-erd.md does not declare a sign_off field at all (only canonical: false + staleness_threshold_days: infinite). I carried Input-B's characterization forward but flagged the source-level discrepancy in the diagram's provenance note above rather than assert a field that isn't in the file.
  • Live-state attestation evidence. I read and cite ADR-0082 §Evidence (orders 190/191/193/194) as the openable source for the live-state claim; I did not independently re-run the live D1 processor_connections/charges queries or the sandbox harness myself in this session. The Input-B contract treats the live-state attestation as human-ratified (traced by the tracing procedure, confirmed by the operator on 2026-07-02) — I am relaying that ratified claim, not re-deriving it from scratch.
  • Guest-checkout funnel decision. Confirmed by reading PRD.md §6.3 directly (the "Funnel decision (operator-ratified 2026-07-02)" note); I did not separately open Hive decision 051b26d2 to re-verify its content beyond the PRD's citation of it.