Subscriptions
What this is: why recurring billing moves off Stripe Billing onto a Loop-owned, provider-agnostic engine that runs on vault tokens, and what that buys.
Who it’s for: anyone building or migrating a recurring product (membership, RUO refills, supplements), or reasoning about dunning and card updates.
What to read next: The two seams · Vault & tokens · Routing & failover.
Status. Moving recurring billing off Stripe Billing is decided
(ADR-0084)
and the PaymentProvider interface has been de-Striped to support it
(LOO-2225, built). The own-clock engine running on vault tokens is Phase B —
planned. Today services/payments still drives subscription lifecycle
through the provider’s subscription capability (Stripe Billing in practice).
Why own the clock
Stripe Billing owns the renewal clock, the retry/dunning schedule, the price catalog, and the card-on-file. That is convenient until you need to:
- Survive a processor swap — a Stripe-Billing subscription cannot be moved to NMI; it is welded to Stripe’s clock and Stripe’s vaulted card.
- Converge bespoke engines — Loop Bio Labs and Leo Research grew their own recurring tools;
services/paymentsuses Stripe Billing. Three engines, three behaviours. - Own dunning policy — retry timing, grace periods, and failure messaging are product decisions, not a vendor’s defaults.
The answer (ADR-0084 + ADR-0093): a provider-agnostic subscription engine that holds the clock itself and bills a vault token through the routing layer.
The provider-neutral subscription surface
A subscription is referenced by an opaque providerSubscriptionRef; prices by a canonical priceKey + opaque providerPriceRef. Subscription operations are an optional capability — a provider implements SubscriptionCapableProvider only if it runs its own lifecycle.
From services/payments/src/lib/payment-provider.ts:
/** Optional: provider runs its own subscription lifecycle. */
export interface SubscriptionCapableProvider {
/** Resolve (or lazily create) a price by its canonical key. Idempotent. */
ensurePrice(params: EnsurePriceParams): Promise<Result<EnsuredPrice>>;
updateSubscription(params: UpdateSubscriptionParams): Promise<Result<UpdateSubscriptionResult>>;
getSubscriptionPriceInfo(ref: string): Promise<Result<SubscriptionPriceInfoResult>>;
pauseSubscription(params: PauseSubscriptionParams): Promise<Result<PauseSubscriptionResult>>;
cancelSubscription(params: AdminCancelSubscriptionParams): Promise<Result<AdminCancelSubscriptionResult>>;
}UpdateSubscriptionParams carries interval (monthly | quarterly | annual), cancel, and reactivate — the lifecycle verbs — keyed on providerSubscriptionRef, never a Stripe id. This is the surface the own-clock engine will drive; in the interim it is also how the current Stripe-backed flow is expressed without leaking Stripe.
Dunning & retry
When a renewal charge fails, the engine — not the vendor — decides what happens. Because a renewal is just a charge through the routing layer, dunning composes the existing safety guarantees:
- A renewal is a
chargeTokenagainst the subscription’s vault token, with a deterministic logical attempt id so a retried renewal cannot double-charge. - A declined renewal can fail over to an eligible standby MID for the same entity — recurring revenue survives an acquirer hiccup.
- A timeout HALTs (no double charge) and is reconciled, exactly as a one-shot charge would be.
- Retry schedule, grace period, and customer messaging are Loop policy.
Dunning re-uses the cascade’s NO-DOUBLE-CHARGE invariant — there is no separate “retry a renewal” code path that could violate it. A renewal retry is the same deterministic-idempotency-keyed cascade as any other charge.
Account-updater (future)
Cards expire and get reissued. An account-updater keeps a subscription billable across reissues without the customer re-entering anything. Two layers cooperate:
- Network tokens under Loop’s own TRID (Vault & tokens) — the issuer updates the network token behind the scenes, so a reissued card keeps working.
- Account-updater services (card-network / processor programs) — refresh stored credentials proactively.
Account-updater is planned (Phase B), listed in ADR-0093 alongside the off-Stripe-Billing migration (“migrate recurring off Stripe Billing with account-updater + dunning”). It depends on the Loop-owned-TRID network-token work (LOO-2207).
Migration shape
Converging the three engines is staged, not a big-bang cutover:
Existing cards reach the portable vault via card migration (Stripe export + Stripe→Basis Theory forwarding), then the own-clock engine takes over billing. No customer re-enters a card.
See also
- The two seams —
SubscriptionCapableProvider - Vault & tokens — the token a subscription bills
- Card migration — getting recurring cards into the vault
- Routing & failover — the cascade dunning re-uses
Source
services/payments/src/lib/payment-provider.ts(SubscriptionCapableProvider)- ADR-0084 — Own-system subscription billing · ADR-0093