PaymentsThe two seams

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>>;
}
DispositionMeaningCascade action
capturedMoney definitively movedStop — success
declinedDefinitively not captured (issuer decline / void)Fail over to next eligible MID
indeterminateTimeout / network error / unknown — money may have movedHALT — 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:

ImplementationRoleStatus
BasisTheoryVaultRented exportable vault (SAQ A), the production targetBuilt, not instantiated by default (gated on a confirmed tenant — LOO-2189 / 2224)
StubVaultIn-memory, deterministic; no secrets, no networkBuilt — backs the offline proofs
LoopVaultOwn 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

  • StripeProvider implements 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).
  • StubProvider implements FullServiceProvider & 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 typed PRECONDITION_FAILED rather than pretending to work.
  • NmiProvider is planned (LOO-2192) — the first non-Stripe provider, which proves the abstraction.

See also

Source

  • services/payments/src/lib/payment-provider.ts · services/payments/src/lib/stub-provider.ts
  • services/payments/src/orchestration/token-vault.ts · token-charge-provider.ts · payment-orchestrator.ts
  • ADR-0051 · ADR-0093