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.comwith 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_frompinned). It carriessign_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(@generatedbytools/erd-derive/, drift-gated in CI againstapps/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 explicitsign_offfield (onlycanonical: 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 residue —
stripe.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 withoutSTRIPE_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 decision051b26d2). - Named-deferred — deferred capture on the canonical rail:
authorize()/capture()/voidAuth()throwbc_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_shipstores would burn dunning stages (filed as Hive #1883). - Built-but-untrodden —
authnet.tsimplements the fullProcessorAdaptercontract but every method throws a not-yet-implemented error (adapters/authnet.ts:72-101); it is not even reachable throughadapter-registry.ts::getAdapter, which throws for the'authnet'case before ever constructing the adapter. Braintree is enum-registered (processor_connections.processorCHECK 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 intoadapter-registry.ts::getAdapter(stripe.tsis the reference,authnet.tsthe 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::getAdapterdispatches onprocessor_connections.processor;processor-resolution.tsresolves the vaulted instrument at subscription-creation time; onboarding isroutes/admin/processor-connections.ts(both processors, one endpoint, withis_default/routing_channel_idsset via thePATCHroute). - To connect a processor:
POST /api/admin/processor-connectionswith{ 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)." TheProcessorAdaptercontract is fully implemented there, but tracingadapter-registry.ts::getAdaptershows the'authnet'case throws"schema-registered but not yet implemented"before ever callingcreateAuthnetAdapter— 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_offclaim. Input-B's visual-aid pointer statessign_off: pendingfor both diagrams.docs/architecture/sequence-diagrams.mdcarries that field in its own frontmatter;docs/architecture/data-model-erd.mddoes not declare asign_offfield at all (onlycanonical: 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/chargesqueries 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 decision051b26d2to re-verify its content beyond the PRD's citation of it.