PaymentsSubscriptions

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/payments uses 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 chargeToken against 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

Source