Subscriptions
The subscriptions module (schema subscriptions) is a real
recurring-billing engine: a billing schedule, a renewal sweep, and a
dunning ladder driven off a vaulted merchant-initiated (MIT) credential.
The MIT/vault primitive itself lives in payments (the
PaymentProvider.chargeVault operation + the mit stored-credential
context); this module builds the schedule/renewal/dunning that
commerce explicitly dropped at its founding. It
charges through the payments provider port (the vendor-neutral seam
payments publishes) and books revenue to
accounting through the same in-process repo seam
commerce and cash use (createAccountingModule(deps).repo) — never
hand-rolled HTTP, never the bus.
Exactly-once renewal is the core invariant: a renewal is claimed as a
UNIQUE (subscription, period) invoice under a row lock, charged with the
deterministic key sub-renewal:{sub_id}:{period}, and next_bill_at
advances only inside the settle transaction — so a re-running sweep never
double-charges, and an indeterminate (transport-failure) charge is never
treated as a decline. See Money invariants.
Operator routes
x-operator-token, rate-limited before the token check.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/subscriptions | Create a subscription (tenant_ref in the body). |
GET | /v1/subscriptions/:id | Fetch one subscription. |
POST | /v1/subscriptions/:id/pause | Pause an active subscription. |
POST | /v1/subscriptions/:id/resume | Resume a paused subscription. |
POST | /v1/subscriptions/:id/cancel | Cancel a subscription. |
POST | /v1/ops/subscriptions/renewal-sweep/run | Operator-triggered run of the renewal sweep (also runs on a timer). |
POST | /v1/ops/subscriptions/renewal-reconcile/run | Operator-triggered run of the renewal reconcile pass. |
POST | /v1/ops/subscriptions/renewal-reminder/run | Operator-triggered run of the ARL/§17600 renewal-reminder sweep. |
POST | /v1/ops/subscriptions/revenue-posting-drain/run | Operator-triggered drain of the accounting revenue-posting outbox. |
GET | /v1/subscriptions/audit | Operator audit-log read (makeAuditReadRoute). |
An invalid lifecycle transition returns 409 invalid_transition; a failed
write returns 503, never a false success.
Shopper-scoped routes (storefront BFF)
Registered outside the operator gate and accepted with either the
operator token or the narrower storefront token (the same B7-4 pattern
commerce/payments use). Every route is scoped to (tenant, customer_ref)
and a mutation is ownership-checked in the repo, so a shopper can only touch
their own subscription.
| Method | Path | Purpose |
|---|---|---|
GET | /v1/tenants/:tenant/customers/:customerRef/subscriptions | List a shopper’s own subscriptions. |
POST | /v1/tenants/:tenant/customers/:customerRef/subscriptions/:id/cancel | Shopper self-serve cancel (immediate; a cancellation-confirmation email is sent). |
POST | /v1/tenants/:tenant/customers/:customerRef/subscriptions/:id/pause | Shopper self-serve pause. |
POST | /v1/tenants/:tenant/customers/:customerRef/subscriptions/:id/resume | Shopper self-serve resume (next_bill_at recomputes forward — never back-bills). |
Renewal reminders (auto-renewal law)
Every store sends a pre-billing renewal reminder; the sweep covers active
and past_due subscriptions. renewalReminderDaysBeforeBilling is
per-store (default 7) with an enforced floor of 3 — values below the floor
clamp to the default and are logged; reminders cannot be configured off. A
shopper’s marketing unsubscribe never blocks these (they are transactional);
a hard bounce records the reminder as undeliverable, visibly, never as
sent. Subscription creation at storefront checkout requires recorded consent
evidence (subscriptionConsent — disclosure version, content hash, IP hash,
user agent), persisted on the subscription row.
Merchant routes
fdk_ credential (Authorization: Bearer), tenant resolved from the
credential per credential-carries-ownership, scope manage:own_subscriptions.
The portal’s Subscriptions page. A failed read is a 503, never
an empty list.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /v1/merchant/subscriptions | manage:own_subscriptions | List the caller’s subscriptions. |
GET | /v1/merchant/subscriptions/:id | manage:own_subscriptions | Fetch one (cross-tenant/unknown are an indistinguishable 404). |
POST | /v1/merchant/subscriptions/:id/cancel | manage:own_subscriptions | Cancel. |
POST | /v1/merchant/subscriptions/:id/pause | manage:own_subscriptions | Pause. |
POST | /v1/merchant/subscriptions/:id/resume | manage:own_subscriptions | Resume. |
Events
Declared empty deliberately (see module.yaml) — this module posts
revenue to accounting via the same in-process repo seam commerce/cash
use, and records every state change to its own audit_log, so it publishes
and consumes no EventBridge events.
| Direction | Event |
|---|---|
| Publishes | (none — in-process accounting seam + audit log) |
| Consumes | (none) |
Manifest gates
Two behavior keys in the store manifest gate the storefront surfaces this module powers:
behavior.subscriptions.enabled— the Subscribe & Save switch . Default false;behavior.subscriptions.discountsmaps interval-days ("30","60") -> percent off, andbehavior.subscriptions.autoEnrollBundlesdefaults false.behavior.accounts.subscriptionSelfServe— the account-page self-serve cancel/pause surface. Defaults true.
Seams (honest notes)
- Renewal + dunning run off a sweep, not a scheduler service —
renewal-sweep/runis both a timer target and an operator-triggerable drain (the beacon dispatch/run precedent). - The MIT credential is vaulted by
payments; subscription creation at checkout is documented on the Seam closures page (subscribe-and-save).