Subscriptions & Billing
Subscriptions & Billing
This page documents the MVS-owned billing layer: the catalog (Product,
Price), the customer record (BillingCustomer), recurring subscriptions
(BillingSubscription + BillingSubscriptionItem), invoices (BillingInvoice),
and the checkout funnel (CheckoutSession + CheckoutSessionItem). All of these
models live in the dedicated mvs-pay extensions database
(packages/mvs-pay/prisma/schema.prisma) and are served by the Next.js route
handlers under apps/mvs-pay/app/api/billing/.
Billing is one of the few domains where MVS owns the canonical relational data
rather than treating Hyperswitch as the source of truth. Subscriptions, products,
prices, customers, and invoices are modelled and stored locally; Hyperswitch
is referenced only by opaque ID columns (hyperswitchMandateId,
hyperswitchCustomerId, hyperswitchPaymentIntentId, …) following the sidecar
pattern described in Data Model. There is no foreign key
across the two databases.
Hosted checkout and the self-serve billing portal are 501 stubs today. The
read/write CRUD for products, prices, customers, and subscriptions is real and
operates on the local database, but the two endpoints that would mint a hosted
payment surface (POST /api/billing/checkout-sessions and
POST /api/billing/customers/[id]/portal-session) return
501 NOT_IMPLEMENTED. See Hosted checkout & portal
below for the full reasoning. There is also no automated billing engine — no
worker advances a subscription through its state machine, generates invoices, or
charges mandates on a schedule. State transitions today are operator/API-driven.
The billing data model
Seven models plus their enums make up the billing layer. Everything is
tenant-scoped by organizationId (or, for Price, transitively through its
parent Product), and every route enforces that scope explicitly with
where: { organizationId } — there is no row-level security in Postgres
(see Authentication & Tenancy).
Product & Price (the catalog)
A Product is a sellable thing scoped to an org and uniquely keyed by
(organizationId, slug). A Price attaches money and recurrence to a product.
The split mirrors Stripe’s catalog: one product can carry many prices (monthly,
annual, per-seat), and prices are immutable enough that they should be retired,
not edited, once referenced by a subscription.
Although PaymentBillingScheme defines TIERED and VOLUME and
PaymentRecurringInterval defines all four intervals, the schema models only the
identifiers for these schemes — there is no tier table, no usage aggregation,
and no engine that interprets TIERED/VOLUME pricing. Likewise trialPeriodDays
and taxBehavior are stored but nothing in the billing routes acts on them. Treat
these as data the platform can persist, not behaviour it executes.
The catalog routes:
DELETE /api/billing/products/[id] deliberately archives rather than
hard-deletes. The handler comment is explicit: Price rows cascade-delete with a
Product (onDelete: Cascade), which would destroy historical pricing referenced
by live subscriptions. Setting active=false retires a product safely.
POST /api/billing/prices re-resolves the parent product under the caller’s org
before creating the price, so a caller cannot mint a price under another tenant’s
product. The same defensive re-scoping is done in POST /api/billing/subscriptions
for the customer.
ProductFeature (entitlements, schema-only)
ProductFeature models entitlements hung off a product — keyed by
(productId, featureKey), typed BOOLEAN / METERED / STATIC
(PaymentFeatureType), with an optional includedUnits. It is returned by
GET /api/billing/products/[id] but there is no API to create, update, or
enforce features today, and no metering integration wired to it. The
openMeterCustomerId / openMeterSubscriptionId / openMeterInvoiceId columns
scattered across BillingCustomer, BillingSubscription, and BillingInvoice
point at the same unrealised OpenMeter usage-metering integration: the columns
exist, nothing populates them.
BillingCustomer
The local customer record, unique per org by (organizationId, email). It holds
contact details, a billing address, tax exemption flags/IDs, and the Hyperswitch
linkage (hyperswitchCustomerId, hyperswitchPaymentMethodId).
The POST and PUT customer routes only ever write the local row — they accept
email, name, phone, billingAddress, taxExempt, and metadata. They do
not create or sync a Hyperswitch customer; hyperswitchCustomerId is never set
by these handlers. Linking a local customer to a Hyperswitch customer is currently
out of band.
BillingSubscription & BillingSubscriptionItem
The recurring-revenue heart of the layer. A subscription belongs to one
BillingCustomer, carries a status, a billing period window, trial dates, cancel
intent, and payment-retry / revenue-recovery bookkeeping. Its line items
(BillingSubscriptionItem) each reference a Price with a quantity.
The revenue-recovery fields are written not by these billing routes but by the
extensions service: apps/mvs-pay-extensions/src/routes/webhooks-revenue-recovery.ts
updates them when Hyperswitch posts a retry-resolution webhook. See
Revenue Recovery and
ADR-011 · Revenue Recovery.
POST /api/billing/subscriptions creates the subscription row but does not
create any BillingSubscriptionItem line items — the handler writes only the
subscription’s own fields (customerId, currency, mandate/payment-method IDs,
period and trial dates, metadata). It also does not call Hyperswitch to create a
mandate; hyperswitchMandateId is whatever the caller passes in. There is no
endpoint that adds, removes, or re-prices subscription items after creation. Item
rows must be seeded out of band today.
The subscription state machine
BillingSubscriptionStatus defines eight states:
New subscriptions default to INCOMPLETE. The canonical “recurring revenue” arc
the prompt and schema describe is ACTIVE → PAST_DUE → UNPAID: a healthy
subscription whose renewal payment fails moves to PAST_DUE while dunning/recovery
retries run, and if recovery ultimately fails it lands in UNPAID.
This diagram is the intended lifecycle the schema is shaped for, not a state machine any single service enforces. Of the eight states, only four transitions have a code path that performs them today, and they are all explicit API calls:
There is no automated driver for INCOMPLETE → ACTIVE, ACTIVE → PAST_DUE,
PAST_DUE → UNPAID, trial expiry, or INCOMPLETE → INCOMPLETE_EXPIRED. No worker
charges mandates on renewal or advances status based on payment outcomes. Those
transitions happen only if a caller PUTs or otherwise writes the row — and there
is currently no general subscription-update route, only cancel/pause/resume.
The PAST_DUE → UNPAID arc in particular is a documented target backed by the
recoveryStatus columns, not a live promotion.
The three lifecycle action routes
All three follow an identical, tenant-safe shape: a single updateMany keyed by
{ id, organizationId } (so a wrong tenant simply matches zero rows → 404),
followed by a re-fetch of the updated row.
Cancel
POST /api/billing/subscriptions/[id]/cancel reads an optional body
{ cancelReason?, cancelAtPeriodEnd? }.
cancelAtPeriodEnd: true→ records the intent only: setscancelAtPeriodEnd=trueand stampscanceledAt, but leavesstatusandendedAtuntouched so the subscription keeps running until its period ends. (Note: nothing then flips it toCANCELEDat period end automatically — see the warning above.)- Otherwise → immediate:
status="CANCELED", stamps bothcanceledAtandendedAt.
BillingInvoice
BillingInvoice records a billed amount against a customer and (optionally) a
subscription. BillingInvoiceStatus is DRAFT / OPEN / PAID / VOID /
UNCOLLECTIBLE, defaulting to DRAFT. Amounts are stored in minor units
(subtotal, tax, total, amountPaid, amountDue), lineItems is a required
JSON blob, and hyperswitchPaymentIntentId links the invoice to the payment that
settled it.
There is no /api/billing/invoices route in the codebase. Invoices are read
today only as a nested relation on GET /api/billing/subscriptions/[id] (last 10,
newest first). Nothing in the billing API creates an invoice, and no worker
generates them on a billing cycle. The model is provisioned ahead of the engine
that would populate it.
CheckoutSession (the funnel)
CheckoutSession is the funnel object that would carry a buyer from a hosted
payment page to a paid subscription or one-off payment. Its enums:
PaymentCheckoutMode—PAYMENT(one-off),SUBSCRIPTION,SETUP(collect a payment method, no charge).PaymentCheckoutStatus—OPEN(default),COMPLETE,EXPIRED.
Key fields include successUrl/cancelUrl, the Hyperswitch HyperLoader handoff
(hyperswitchPaymentIntentId, hyperswitchClientSecret, hostedCheckoutUrl),
amountTotal, an expiresAt, and the outcome pointers resultingSubscriptionId /
resultingPaymentIntentId. Line items (CheckoutSessionItem) reference a Price
with a quantity.
Hosted checkout & portal (501 today)
Two endpoints are intentionally honest 501 NOT_IMPLEMENTED responses rather than
silent calls to a decommissioned gateway:
POST /api/billing/checkout-sessionsPOST /api/billing/customers/[id]/portal-session
Both still authenticate (returning 401 if there is no session) before returning
501 with the message
"not implemented: hosted checkout/portal backend not yet available".
Why these are 501 and not silently broken
The handler comments state the reasoning directly. Creating a checkout session requires a hosted-checkout backend — a payment intent + client secret minted against Hyperswitch’s HyperLoader / hosted checkout, plus the hosted UI — and a self-serve portal requires a hosted billing-portal backend (session minting + hosted UI). Neither exists in scope post-pivot:
apps/mvs-pay-extensionsmounts no checkout-session, billing-portal, subscription, product, or price route.- The
@mvs/mvs-pay-sdkexposes nocheckoutorportalnamespace.
Rather than route to the decommissioned payfac gateway (see The Hyperswitch Pivot), the endpoints fail loudly and truthfully. This is the same “honest 501” pattern used elsewhere in the platform until a real backend lands.
How billing routes are wired
Every handler under apps/mvs-pay/app/api/billing/ is a Next.js App Router route
that resolves its database client and tenant through one chokepoint:
getPaymentsContext() from apps/mvs-pay/lib/payments-db.ts.
A few consequences worth internalising as the new owner:
- Single shared database, query-level tenancy. Post-cutover (
C3.2) the context resolves one shared@mvs/mvs-pay/client, not a per-tenant client. Isolation is enforced by the explicitwhere: { organizationId }on every read and byupdateMany({ where: { id, organizationId } })on every write. Drop that clause and you leak across tenants — there is no database backstop. See Authentication & Tenancy and Data Model. - Consistent error envelope. Handlers return
{ error: { code, message } }with401(no session),400(INVALID_BODY),404(NOT_FOUND— including the zero-matchupdateManycase),500(*_FAILED), and501(NOT_IMPLEMENTED) for the two hosted stubs. Success bodies are unenveloped JSON; collection responses include{ total, limit, offset, hasMore }. - No request-body validation library. Validation is hand-rolled
typeofchecks per handler (e.g.slug/namefor products,unitAmountfor prices,emailfor customers,customerIdfor subscriptions). There is no shared Zod schema for billing payloads.
These routes are not in the published API Reference. The Fern OpenAPI fragments
under fern/openapi/fragments/ cover the Hyperswitch-fronted extensions API
(payments, payouts, disputes, mandates, customers, etc.) — there is no billing
fragment. The billing CRUD described here is part of the merchant/operator app’s
internal Next.js API, not the SDK surface. For the operations that are published,
see the API Reference tab.
Where this sits relative to Hyperswitch
The billing layer is MVS-canonical, but it is meant to ride alongside Hyperswitch via the ID columns, not replace it:
The links are aspirational where the wiring is missing: as noted, the create
routes do not yet populate hyperswitchCustomerId or call Hyperswitch to mint a
mandate, and no worker charges those mandates on renewal. The recurring-payment
recovery side of the loop is the most built-out integration — driven by
Hyperswitch’s Revenue Recovery and recorded back onto BillingSubscription.
How failed renewal retries are driven by Hyperswitch and reflected back onto the subscription’s recovery fields.
The mandate model that underpins recurring charges (hyperswitchMandateId).
The sidecar pattern and why no FK crosses into Hyperswitch.
Why the hosted checkout/portal backends were descoped and stubbed 501.
Owner’s checklist: what is real vs. provisioned
A fast mental model when triaging billing behaviour: CRUD is real, the engine is not.