The two seams
What this is: the two strategy interfaces the whole system composes around — PaymentProvider (processing) and TokenVault (custody) — and the contract for adding a new provider without a rewrite.
Who it’s for: anyone implementing a gateway adapter, a vault adapter, or a token-charge capability.
What to read next: Routing & failover · Vault & tokens · Architecture.
ADR-0093 deliberately reduces the system to two seams. Everything else — routing, failover, subscriptions, hosted checkout — composes around them.
Seam 1 — PaymentProvider (processing)
PaymentProvider is the vendor-agnostic interface for gateway operations (ADR-0051, extended by ADR-0093). The foundation work did two things to it:
De-Striped (LOO-2225)
No method param or result names a vendor. A subscription is referenced by an opaque providerSubscriptionRef (was stripeSubscriptionId); a catalog price by a canonical priceKey (was Stripe lookup_key) plus an opaque providerPriceRef (was priceId). Vendor-specific extras travel in providerMetadata.
Capability-segregated
Instead of one fat interface every provider must stub, the surface is a small required core plus optional capability interfaces. A new provider implements only what it actually supports.
From services/payments/src/lib/payment-provider.ts:
/**
* Core charge/refund operations. Every payment provider must implement this —
* it is the minimum required to route a charge through the orchestration layer.
*/
export interface PaymentProvider {
readonly name: string;
createPaymentIntent(params: CreatePaymentIntentParams): Promise<Result<PaymentIntentResult>>;
getChargeRefundContext(chargeId: string): Promise<Result<ChargeRefundContext>>;
createRefund(params: CreateRefundParams): Promise<Result<ProviderRefundResult>>;
}
/** Optional: provider runs its own subscription lifecycle. */
export interface SubscriptionCapableProvider { /* ensurePrice, updateSubscription, … */ }
/** Optional: provider hosts a redirect/embedded checkout surface. */
export interface HostedCheckoutCapableProvider { /* createCheckoutSession, getCheckoutSession */ }
/** Optional: provider exposes a customer billing-management surface. */
export interface BillingManagementCapableProvider { /* createPortalSession, listPaymentMethods, listInvoices */ }
/** Optional: provider manages promotion codes / coupons. */
export interface PromoCapableProvider { /* validatePromo, listAdminPromos, createAdminPromo */ }
/**
* A provider that supports the full Stripe-era surface. `StripeProvider`
* implements this; new providers implement only the subset they support.
*/
export type FullServiceProvider = PaymentProvider &
SubscriptionCapableProvider &
HostedCheckoutCapableProvider &
BillingManagementCapableProvider &
PromoCapableProvider;Domain services depend on the narrowest interface (or intersection) they need — a refund handler needs only PaymentProvider; a subscription handler needs PaymentProvider & SubscriptionCapableProvider.
TokenChargeProvider — the cascade’s processing capability
There is one more, separate capability that the cross-PSP cascade depends on: charging a portable vault token on a specific MID. Its load-bearing concept is the three-way disposition.
From services/payments/src/orchestration/token-charge-provider.ts:
export type ChargeDisposition = "captured" | "declined" | "indeterminate";
/** Optional capability: a provider can charge portable vault tokens. */
export interface TokenChargeProvider {
readonly name: string;
chargeToken(params: ChargeTokenParams): Promise<Result<ChargeAttemptResult>>;
}| Disposition | Meaning | Cascade action |
|---|---|---|
captured | Money definitively moved | Stop — success |
declined | Definitively not captured (issuer decline / void) | Fail over to next eligible MID |
indeterminate | Timeout / network error / unknown — money may have moved | HALT — never fail over; reconcile |
This split is why failover may trigger only on a positive not-captured confirmation, never on a bare timeout. See Routing & failover.
Seam 2 — TokenVault (custody)
TokenVault puts card custody behind an interface so the charging side never holds a PAN. Tokens are vault-agnostic — { vaultId, tokenRef } — so two vaults can run during a migration and a charge can name which vault holds the card (ADR-0093 rule 1).
From services/payments/src/orchestration/token-vault.ts:
/** Vault-agnostic pointer to a vaulted card. */
export type VaultTokenRef = {
/** Which vault holds the token, e.g. "basis-theory" | "stub". */
vaultId: string;
/** Opaque token id within that vault. */
tokenRef: string;
};
export interface TokenVault {
/** Stable id used in `VaultTokenRef.vaultId`. */
readonly vaultId: string;
/**
* Vault a raw card and return a portable token ref. Server-side/test/migration
* only — the hosted-checkout path tokenizes client-side instead.
*/
vaultCard(card: CardInput): Promise<Result<VaultedCard>>;
/** Fetch non-sensitive metadata for an existing token (never the PAN). */
getCard(tokenRef: string): Promise<Result<VaultedCard>>;
}vaultCard takes a raw PAN and is only for server-side tests, the spike,
and the gated migration path. The hosted-checkout flow never sends a PAN
through this process — the card is tokenized client-side against the vault,
which is what keeps the consuming surface out of PCI scope. See PCI
posture.
Implementations today:
| Implementation | Role | Status |
|---|---|---|
BasisTheoryVault | Rented exportable vault (SAQ A), the production target | Built, not instantiated by default (gated on a confirmed tenant — LOO-2189 / 2224) |
StubVault | In-memory, deterministic; no secrets, no network | Built — backs the offline proofs |
LoopVault | Own CDE (SAQ D) | Planned — a later implementation, not a rewrite |
How to add a provider
This is the payoff of two seams: a new gateway is an adapter, not a refactor.
The orchestrator resolves providers by name, so the same orchestrator drives Stripe, NMI, and own-gateway in prod and StubProviders in tests:
From services/payments/src/orchestration/payment-orchestrator.ts:
/** Resolve a provider instance by its registry name ("stripe" | "nmi" | …). */
export type ProviderResolver = (providerName: string) => TokenChargeProvider | undefined;The disposition mapping is the dangerous part. When you implement
chargeToken, mapping a gateway response to captured / declined /
indeterminate is a money-safety decision. A timeout or any response you
cannot prove means “not captured” must map to indeterminate — never
declined — or the cascade could double-charge. This is exactly what the
no-double-charge proof tests guard.
StripeProvider and StubProvider today
StripeProviderimplements the full set (FullServiceProvider). It is currently the only real provider, and in the legacy service it is a single hardcoded instance with no routing — the gap ADR-0093 documents as “operational, not structural” (and warns may need interface reshaping when the first non-Stripe provider lands).StubProviderimplementsFullServiceProvider & TokenChargeProvider. It is the honest fallback when no real gateway is configured (returns typed errors instead of a Stripe client built with an empty key) and the controllable second provider for failover / no-double-charge tests. Genuinely Stripe-specific surfaces (billing portal, promo creation) return a typedPRECONDITION_FAILEDrather than pretending to work.NmiProvideris planned (LOO-2192) — the first non-Stripe provider, which proves the abstraction.
See also
- Routing & failover — how dispositions drive the cascade
- Vault & tokens — the
TokenVaultimplementations in depth - Subscriptions — uses
SubscriptionCapableProvider - Hosted checkout-as-a-service — uses
HostedCheckoutCapableProvider