4. How money moves¶
Generated from a canonical source
This page is a read-only projection of docs/solution-review/04-money-movement.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
The rule that shapes everything¶
No raw card data ever touches this application. Card entry always happens inside
a hosted surface the app does not control — BigCommerce's hosted checkout, a
BigPay-hosted vault iframe, or a Stripe Elements iframe — and the app persists
only tokenized references. This keeps bc-subscriptions in PCI SAQ-A scope
(annual self-assessment) by architectural construction. A single logged PAN,
CVV, or proxied card field would re-scope the product to SAQ-D — a full
Qualified Security Assessor audit, disqualifying for the marketplace timeline.
Application logs and event records carry {store_hash, subscription_id,
charge_id, payment_method_ref} only. In-scope PCI components owned by the app:
none. Deep dive:
../decisions/0060-pci-scope-saq-a-boundary.md.
Vault-at-checkout: how a card first arrives¶
A card enters the vault once, at checkout, and the app harvests only a token:
- A signed-in shopper checks out through BigCommerce's hosted checkout and opts to save the card. BigPay sets a vault flag on the payment request and vaults the card with the underlying gateway; the app makes no Stored Instruments call at checkout time.
- On the
store/order/createdwebhook, the app harvests the vaulted token from the order's transaction record viaGET /v3/orders/{id}/transactions(payment_instrument_token) and persists it aspayment_method_ref.
The consequence is a hard product constraint: BigCommerce only vaults a card
for a signed-in customer. A guest can complete a one-time purchase but cannot
vault, which would leave a subscription with no instrument its renewals could
charge. So a cart carrying subscription intent gates on customer identity — a
guest is redirected to sign in first; one-time-purchase carts stay
guest-friendly. This is a gate on the subscribe path specifically, ratified in
ADR-0090 (subscriptions require sign-in), which reversed an earlier
guest-subscribe funnel decision after live testing showed guest subscriptions
were structurally un-renewable. Deep dive:
../decisions/0090-subscriptions-require-signin-customer-context-checkout.md.
The centerpiece: one renewal cycle¶
Recurring charges do not go gateway-by-gateway. There is one canonical charge
rail — BigCommerce's stored-instruments vault, reached at
payments.bigcommerce.com with a stored_card token — and BigCommerce routes
to whichever gateway the merchant configured underneath. The app holds no
gateway-specific charge logic. This is ADR-0037 (stored instruments as the
canonical charge rail); the endpoint contract is ADR-0035 (BC Payments
standard-rail research). The sequence below is verified against the adapter
code in apps/api/src/adapters/bc-payments.ts.
sequenceDiagram
autonumber
participant Eng as Billing engine
participant BCApi as BC Admin API
participant BCPay as BC Payments
participant Events as Events + email
Note over Eng: scheduler cron finds a due charge (order-first, ADR-0025)
Eng->>BCApi: Create Incomplete BC order → bc_order_id (status 0)
Eng->>BCApi: POST /v3/payments/access_tokens {order.id, is_recurring: true}
BCApi-->>Eng: Payment Access Token (PAT, 1h TTL)
Eng->>BCApi: GET /v3/payments/methods?order_id=N
BCApi-->>Eng: methods[].stored_instruments[] — match by token
Eng->>BCPay: POST /stores/{hash}/payments {stored_card, PAT auth}
BCPay-->>Eng: charge result, classified MREC
Eng->>BCApi: Transition order → Awaiting Fulfillment (status 11)
Eng->>Events: charge.succeeded or charge.failed → outcome events
Two sequencing details a reviewer should hold onto. First, order-first: the
BC order must exist before the charge, because both the methods lookup and the
charge call are order-scoped — the scheduler (not the adapter) creates the
Incomplete order (ADR-0025). Processors that charge independently of BC orders,
like the direct-Stripe edge adapter, run charge-first instead; the divergence is
carried on a per-adapter requiresPreExistingOrder flag. Second, rail
identity is proven by the call target, never the transaction id — a Stripe
pi_* id can appear on both rails because BigCommerce mediates to Stripe
underneath. Deep dives:
../decisions/0025-charge-sequencing-per-processor.md,
../decisions/0037-stored-instruments-as-canonical-charge-rail.md,
../handoff-corpus/canonical-charge-rail.md.
The charge amount: tax recomputed live, added on top¶
Each renewal's amount is the post-discount base plus tax recomputed for that
cycle — never a snapshot. The app runs no tax engine of its own (ADR-0016 —
defer to whatever the merchant already configured in BC admin: TaxJar / Avalara /
Vertex / BC built-in). The scheduler quotes current-jurisdiction tax from
BigCommerce's Checkout Consignments API for the subscription's stored
shipping address and adds it to the taxable base: charges.amount_cents stays
the pre-tax base and a separate tax_cents carries the tax, so the adapter is
charged base + tax (US-15.3 / Epic 15, migration 0017;
apps/api/src/services/tax-recalc-resolver.ts). Live recompute means a statutory
rate change in the merchant's BC tax config lands on the very next renewal — no
rate table, no snapshot to drift, and no third-party tax-vendor keys held by the
app.
One behavior a reviewer should hold onto: tax never fails a charge. A
Consignments lookup miss, a resolver error, or a missing/malformed shipping
address falls back to tax_source = 'fallback_zero' and the renewal charges
untaxed rather than blocking the cycle — a deliberate availability-over-tax-
completeness trade. Untaxed cycles are marked (tax_source provenance on the
charge row), but Phase 1 has no dedicated tax-mismatch exception surface to
action them (deferred to Epic 21, Phase 2). Note for readers who click through:
ADR-0016 documents tax as the post-tax order grand total BC returns at
order-create; the as-built renewal path computes it additively via the
Consignments estimate instead — same intent (defer to the merchant's BC tax
engine), different mechanism. ADR-0016 predates US-15.3 and is due an amendment.
Deep dive: ../decisions/0016-tax-engine-pass-through.md,
Epic 15 (inventory/tax/shipping recalculation at renewal).
Why two flags decide whether the money lands¶
The stored-credential indicator (MREC). Card networks classify
stored-credential transactions into cardholder-initiated and merchant-initiated
subtypes, each with distinct SCA and authorization-rate treatment. Every
fixed-interval renewal sends the merchant-initiated recurring subtype MREC
(the is_recurring: true flag on the PAT mint is how the app signals it).
Sending the wrong subtype measurably degrades decline rates and forfeits the EU
PSD2 recurring-charge exemption — a 3-D Secure challenge fires off-session, with
no shopper present to answer it, and the charge declines outright. The
cardholder-initiated first cycle is CREC; the recurring renewals are MREC.
Network transaction ID threading. Each charge carries a chain position
(initial | subsequent) and a network transaction ID persisted from the
original cardholder-initiated authorization, re-attached on every subsequent
merchant-initiated charge in the chain. This threading is what preserves the SCA
exemption across the life of the subscription. It works platform-wide with one
known exception: Worldpay/Paymetric-connected stores have a platform-side NTI
threading gap that requires a persist-and-reattach mitigation in the adapter.
That gap is a BigCommerce payments-platform matter, tracked outside this
engagement — see 06-platform-gaps-and-contract.md.
Capture timing: atomic today, split by design¶
The charge call at payments.bigcommerce.com is atomic — authorization and
capture happen in one request; there is no per-request auth-only mode. That is
correct for digital goods and immediate fulfillment, but wrong for physical-goods
merchants who must not capture before shipment (an EU PSD2 requirement and a
warehouse cash-flow need). The data shape for the fix ships from day one: a
stores.capture_timing enum with values immediate | on_fulfillment | on_ship
exists in the schema and is surfaced in the admin, but is advisory only in
Phase 1 — the scheduler still charges atomically regardless of the setting
(ADR-0038, capture-timing strategy). The Phase-2 design splits charge() into
authorize() + capture(), driven by the store/shipment/created webhook for
on_ship and an auth-expiry sweep for authorizations nearing the gateway
window. As of this writing BigCommerce's Transactions API capture/void endpoints
are not yet GA, so the BC Payments adapter's authorize()/capture()/voidAuth()
are gated behind bc_deferred_capture_not_ga; the scheduler falls back to
immediate capture for BC Payments stores. Deep dive:
../decisions/0038-charge-capture-timing-strategy.md.
Gateway coverage¶
The rail is gateway-agnostic, but a gateway only rides it if BigCommerce's stored-instrument model supports it (vault + off-session/MIT charging). At install the app detects the store's active gateway and surfaces an explicit merchant-facing compatibility signal — an unsupported gateway is never silently degraded; the merchant switches processors, adds a supported secondary, or declines install.
| Gateway | Posture | On the rail | Notes |
|---|---|---|---|
| BC Payments (PayPal/Braintree) | Primary | Yes, via the platform vault (MREC flag + re-attached network transaction id) |
The only path unified with BigCommerce's own payments dashboard |
| Stripe | Alternate | Yes, canonical stored-card charge through the platform rail; a direct Stripe-hosted vault path remains as an edge case outside BC's vault | Material secondary gateway; live-validated end to end |
| Braintree (standalone), Authorize.net, Adyen, Cybersource, Worldpay/FIS, Checkout.com | Phase ⅔ tail | Gated on the gateway's vault + off-session support | Adapter-by-adapter; Worldpay/Paymetric carries the NTI gap above |
| Klarna / Afterpay / Clearpay (BNPL) | Never | No | Structurally incompatible with unattended recurring charges |
| Amazon Pay | Never | No | Session-bound; not designed for merchant-initiated transactions |
Adding a gateway that BigCommerce already vaults usually needs no code — the
canonical rail already charges it. A direct adapter (the Stripe edge case,
stripe.ts) is only for charging a gateway outside BigCommerce's vault. Declined
charges hand off to the dunning retry policy rather than dropping the subscriber;
see the dunning deep dive at
../handoff-corpus/dunning.md. Gateway matrix
source: docs/gsi-package/05-bc-platform-integration-contract.md §5.4.