Skip to content

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:

  1. 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.
  2. On the store/order/created webhook, the app harvests the vaulted token from the order's transaction record via GET /v3/orders/{id}/transactions (payment_instrument_token) and persists it as payment_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.