Subscriptions & Billing

Recurring revenue

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.

FieldTypeNotes
Product.slugStringUnique per org via @@unique([organizationId, slug])
Product.activeBooleanDefaults true; archiving sets it false
Price.unitAmountIntMinor units (e.g. cents). Required on create
Price.currencyStringDefaults "USD"
Price.billingSchemePaymentBillingSchemePER_UNIT (default), TIERED, VOLUME
Price.recurringIntervalPaymentRecurringInterval?DAY/WEEK/MONTH/YEAR; null means one-time
Price.recurringIntervalCountInt?e.g. 3 + MONTH = quarterly
Price.trialPeriodDaysInt?Trial length for the price
Price.taxBehaviorPaymentTaxBehaviorINCLUSIVE/EXCLUSIVE/UNSPECIFIED (default)
Price.hyperswitchMandateTemplateIdString? @uniqueLinks a recurring price to a Hyperswitch mandate template; null for one-time prices

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:

MethodPathBehaviour
GET/api/billing/productsList products (filters: active; paginated via limit/offset), each with its prices[]
POST/api/billing/productsCreate. Requires slug + name
GET/api/billing/products/[id]Get one, including prices and features
PUT/api/billing/products/[id]Partial update (updateMany tenant-scoped)
DELETE/api/billing/products/[id]Archive, not delete — sets active=false
GET/api/billing/pricesList prices (filters: productId, active, recurring)
POST/api/billing/pricesCreate. Requires productId + unitAmount; the parent product is tenant-scoped first

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).

MethodPathBehaviour
GET/api/billing/customersList (filter: search over email/name, case-insensitive)
POST/api/billing/customersCreate. Requires email
GET/api/billing/customers/[id]Get one, including subscriptions (with their items)
PUT/api/billing/customers/[id]Partial update
POST/api/billing/customers/[id]/portal-session501 stub — see below

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.

FieldTypeNotes
statusBillingSubscriptionStatusDefaults INCOMPLETE. See state machine
hyperswitchMandateIdString? @uniqueThe recurring mandate; null for one-shot subs without a mandate
currentPeriodStart/EndDateTime?The active billing window
trialStart/EndDateTime?Trial window
cancelAtPeriodEndBooleanCancel intent without immediate termination
canceledAt / endedAt / cancelReasonStamped on cancel
paymentAttemptCount, lastPaymentAttemptAt, nextPaymentAttemptAt, lastPaymentFailureReasonDunning bookkeeping
recoveryStatus, lastRecoveryAttempt, recoveryAttemptCountHyperswitch Revenue Recovery state (per ADR-011)

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.

MethodPathBehaviour
GET/api/billing/subscriptionsList (filters: status, customerId), each with customer + items.price
POST/api/billing/subscriptionsCreate. Requires customerId (re-scoped to the org first)
GET/api/billing/subscriptions/[id]Get one with customer, items.price.product, last 10 invoices
POST/api/billing/subscriptions/[id]/cancelCancel (immediate or at period end)
POST/api/billing/subscriptions/[id]/pausePAUSED
POST/api/billing/subscriptions/[id]/resumeACTIVE, clears cancel intent

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:

INCOMPLETE · INCOMPLETE_EXPIRED · TRIALING · ACTIVE
PAST_DUE · CANCELED · UNPAID · PAUSED

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:

TransitionTrigger
* → PAUSEDPOST /api/billing/subscriptions/[id]/pause
* → ACTIVE (resume)POST /api/billing/subscriptions/[id]/resume
* → CANCELEDPOST /api/billing/subscriptions/[id]/cancel
recovery field updateswebhooks-revenue-recovery.ts (extensions)

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.

1

Cancel

POST /api/billing/subscriptions/[id]/cancel reads an optional body { cancelReason?, cancelAtPeriodEnd? }.

  • cancelAtPeriodEnd: true → records the intent only: sets cancelAtPeriodEnd=true and stamps canceledAt, but leaves status and endedAt untouched so the subscription keeps running until its period ends. (Note: nothing then flips it to CANCELED at period end automatically — see the warning above.)
  • Otherwise → immediate: status="CANCELED", stamps both canceledAt and endedAt.
2

Pause

POST /api/billing/subscriptions/[id]/pause sets status="PAUSED". The schema has no dedicated pausedAt column (the handler comment calls this out), so a pause is not separately timestamped beyond updatedAt.

3

Resume

POST /api/billing/subscriptions/[id]/resume sets status="ACTIVE" and clears the cancel intent: cancelAtPeriodEnd=false, canceledAt=null, endedAt=null, cancelReason=null. Resume always lands in ACTIVE regardless of the prior state.

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:

  • PaymentCheckoutModePAYMENT (one-off), SUBSCRIPTION, SETUP (collect a payment method, no charge).
  • PaymentCheckoutStatusOPEN (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.

MethodPathBehaviour
GET/api/billing/checkout-sessions/[id]Real. Get one with customer and items.price.product
POST/api/billing/checkout-sessions501 stub

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-sessions
  • POST /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".

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-extensions mounts no checkout-session, billing-portal, subscription, product, or price route.
  • The @mvs/mvs-pay-sdk exposes no checkout or portal namespace.

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.

1const ctx = await getPaymentsContext();
2if (!ctx) {
3 return NextResponse.json(
4 { error: { code: "UNAUTHORIZED", message: "Not authenticated" } },
5 { status: 401 },
6 );
7}
8// ctx.db → the shared dedicated mvs-pay Prisma client
9// ctx.organizationId → the tenant scope applied to every query
10const products = await ctx.db.product.findMany({
11 where: { organizationId: ctx.organizationId },
12});

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 explicit where: { organizationId } on every read and by updateMany({ 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 } } with 401 (no session), 400 (INVALID_BODY), 404 (NOT_FOUND — including the zero-match updateMany case), 500 (*_FAILED), and 501 (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 typeof checks per handler (e.g. slug/name for products, unitAmount for prices, email for customers, customerId for 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.

Owner’s checklist: what is real vs. provisioned

A fast mental model when triaging billing behaviour: CRUD is real, the engine is not.

CapabilityStatus
Product / Price CRUD (archive on delete)Real
Customer CRUD (local row only)Real
Subscription create / get / listReal (no line items created)
Subscription cancel / pause / resumeReal (explicit API calls)
Reading invoices nested under a subscriptionReal (read-only)
Reading a checkout session by idReal
Hosted checkout session creation501 stub
Self-serve billing portal session501 stub
Invoice creation / billing-cycle generationNot implemented (no route, no worker)
Automated state transitions (renewal, dunning, trial expiry)Not implemented
TIERED/VOLUME pricing, trials, taxBehavior enforcementSchema-only, no engine
ProductFeature entitlements / OpenMeter meteringSchema-only, not wired
Hyperswitch mandate/customer creation from billing routesNot wired (ID columns set out of band)
Revenue-recovery field updates on a subscriptionReal (via extensions webhook, per ADR-011)