PaymentsArchitecture

Architecture

What this is: the end-to-end shape of the system and where each piece sits. Read it alongside The two seams (the interfaces) and Routing & failover (the cascade).

Who it’s for: anyone who needs the mental model before reading code, or before wiring a new brand / entity / provider.

The layered shape

⚠️

Green = code merged in the foundation PR (inert, off the live path). Amber = planned / not yet written. The whole diagram is the target; today only the primitives exist and nothing routes real money. See Status & roadmap.

The five layers

1. Portable vault (card custody)

A card is tokenized once into a vault Loop owns and can contractually export. The production target is Basis Theory (BasisTheoryVault), a rented vault that keeps Loop at SAQ A while giving Loop a token it can move. Token references are vault-agnostic — { vaultId, tokenRef } — so two vaults can run during a migration and any charge can name which vault holds the card. The vault is the first thing in the chain because everything downstream charges the token, not the card.

2. Thin Loop routing layer

This is the part Loop owns and that no off-the-shelf orchestrator can own for us: the domain-specific decision of which MID to charge for which entity and product, and the failover discipline. It is deliberately thin — three small, separately-tested primitives:

  • RoutingPolicyEngine.selectMids — returns the ordered, eligibility-filtered list of MIDs for a charge (the laundering guard).
  • chargeAcrossProviders — charges one portable token across that list, failing over only on a definitive decline.
  • PaymentOrchestrator — composes the two and resolves provider instances by name.

See Routing & failover for the invariants and the proof tests.

3. De-Striped provider adapters (processing)

Each processor is an adapter behind the PaymentProvider interface. The interface has been de-Striped (LOO-2225): no method or type names a vendor; subscriptions are referenced by an opaque providerSubscriptionRef, prices by a canonical priceKey + opaque providerPriceRef. The surface is capability-segregated — a small required core (charge/refund) plus optional capability interfaces — so a new provider implements only what it actually supports instead of stubbing Stripe-only methods. See The two seams.

4. Own-clock subscriptions

Recurring billing moves off Stripe Billing onto a Loop-owned engine (ADR-0084) that runs on vault tokens, so a subscription survives a processor swap and dunning/retry policy is Loop’s, not a vendor’s. This converges the bespoke Loop Bio / Leo tools onto one engine. See Subscriptions.

5. Hosted checkout-as-a-service

Apps never build checkout UI or touch cards. They POST a declarative config to /v1/payment-sessions (“who pays, which brand + entity, what intent, hosted vs embedded”) and the service builds the payment step. The card only ever meets the Loop-hosted surface plus the vault, so PCI scope collapses to one CDE and the consuming app stays out of scope entirely. See Hosted checkout-as-a-service.

Why NMI is one gateway, not the brain

A common misread is “we’re switching from Stripe to NMI.” We are not. NMI (like Stripe) is one PaymentProvider behind the seam — one of several gateways the routing layer can choose. The intelligence — eligibility, failover, idempotency, entity governance, the portable token — lives in Loop’s thin routing layer and the vault, above any gateway.

This matters because high-risk acquiring access is the real bottleneck (4–12 weeks of underwriting per MID), and processors freeze accounts without notice. If the brain lived inside a gateway, a deplatforming would take the brain with it. Keeping NMI as a gateway — not the system — is what makes a provider/MID hot-swap a config change.

What the foundation actually verified

ADR-0093’s v2 revision was a senior red-team that verified every finding against the live code. Two corrections shaped the architecture:

  1. The “provider-agnostic foundation” was largely aspirational. The legacy payments table is Stripe-shaped (8 stripe_* columns) and only StripeProvider existed, hardcoded with no routing. So the foundation work reshaped the PaymentProvider interface (de-Striping, capability segregation) and added the routing primitives — expecting further reshaping when the first non-Stripe provider lands.
  2. The real risks are business risks, not commodity code. Acquiring access, the laundering/MATCH landmine, and an un-reconcilable ledger were under-weighted. So the architecture makes entity eligibility a hard tested invariant, adds recon-ready ledger dimensions from the start, and gates the software behind Phase 0 (banking & compliance).

The webhook layer was refuted as a problemservices/webhooks is already provider-normalized (generic provider + provider_event_id with a unique constraint, per ADR-0085). Only the legacy payments table is Stripe-shaped.

Cross-system dependencies

Orchestration does not stand alone. ADR-0093’s cross-system mapping found that payments only becomes joinable to identity, discounts, and the CDP once a canonical customer identity exists. The foundation lands the linchpin:

  • Canonical customer-identity mapidentity.user_external_ids maps loopUserId ↔ { stripe_customer_id, bigcommerce_customer_id } (LOO-2220). This is who pays, distinct from entity_id (who collects). It must be sequenced before the routing engine goes live.
  • Discount / price-resolution seam — one seam resolves final price for (customer, cart, brand, entity) by composing the five scattered discount sources. Payments calls it; payments stays about money, not pricing. (Planned.)
  • Clean payment entrypoint + handoff contract — the payment-sessions front door plus the explicit settled-vs-pending fulfillment gate.
  • CDP as a real-time customer-context API keyed on loopUserId. (Planned.)

See also

Source ADRs