Generated from: “Explain the data model behind subscriptions, plans, invoices, and payments.”
View the Markdown
```meta
title: Billing data model
subtitle: How customers, plans, subscriptions, invoices, and payments relate, and which rules keep the money consistent.
tag: DRAFT
```
A subscription is a customer's standing agreement to pay for one plan. Each billing period the subscription produces one invoice, and each charge attempt against that invoice is one payment row. Money lives on invoices and payments, never on the subscription: the subscription only says what to bill next and when.
```callout
tone: note
title: Assumptions
body: "The request left the specifics open. This doc assumes one plan per subscription and a fixed price per period, with no metered usage. It also assumes one currency per customer and a card processor behind a provider interface. Amounts are integers in minor units (cents). Change the ERD first if any of these assumptions is wrong."
```
## Entities and relationships
Plans are catalog rows and never change price in place; a price change is a new plan row, and existing subscriptions keep the old one. Invoice lines copy the plan price at issue time, so an invoice stays correct after the plan retires. A payment belongs to exactly one invoice, and a payment method belongs to exactly one customer.
```erd
id: billing-erd
dir: LR
entities:
- name: customers
columns:
- "id uuid pk"
- "email text unique !null"
- "name text !null"
- "currency char(3) !null default=USD"
- "created_at timestamptz !null"
- name: plans
columns:
- "id uuid pk"
- "code text unique !null"
- "name text !null"
- "interval text !null enum(month,year)"
- "amount_cents integer !null"
- "currency char(3) !null"
- "trial_days integer !null default=0"
- "status text !null enum(active,retired)"
- name: subscriptions
columns:
- "id uuid pk"
- "customer_id uuid fk -> customers.id !null index"
- "plan_id uuid fk -> plans.id !null"
- "status text !null enum(trialing,active,past_due,canceled)"
- "current_period_start timestamptz !null"
- "current_period_end timestamptz !null index"
- "cancel_at_period_end boolean !null default=false"
- "canceled_at timestamptz"
- "created_at timestamptz !null"
- name: invoices
columns:
- "id uuid pk"
- "number text unique !null"
- "subscription_id uuid fk -> subscriptions.id !null index"
- "customer_id uuid fk -> customers.id !null index"
- "status text !null enum(draft,open,paid,void,uncollectible)"
- "period_start timestamptz !null"
- "period_end timestamptz !null"
- "subtotal_cents integer !null"
- "tax_cents integer !null default=0"
- "total_cents integer !null"
- "amount_paid_cents integer !null default=0"
- "due_at timestamptz !null"
- "issued_at timestamptz"
- name: invoice_lines
columns:
- "id uuid pk"
- "invoice_id uuid fk -> invoices.id !null index"
- "plan_id uuid fk -> plans.id"
- "description text !null"
- "quantity integer !null default=1"
- "unit_amount_cents integer !null"
- "amount_cents integer !null"
- name: payment_methods
columns:
- "id uuid pk"
- "customer_id uuid fk -> customers.id !null index"
- "kind text !null enum(card,sepa_debit)"
- "provider_ref text unique !null"
- "last4 char(4)"
- "expires_at date"
- "is_default boolean !null default=false"
- name: payments
columns:
- "id uuid pk"
- "invoice_id uuid fk -> invoices.id !null index"
- "payment_method_id uuid fk -> payment_methods.id !null"
- "status text !null enum(pending,succeeded,failed,refunded)"
- "amount_cents integer !null"
- "provider text !null"
- "provider_ref text unique"
- "failure_code text"
- "attempted_at timestamptz !null"
relations:
- "customers ||--o{ subscriptions: holds"
- "plans ||--o{ subscriptions: prices"
- "subscriptions ||--o{ invoices: bills each period"
- "customers ||--o{ invoices: is billed on"
- "invoices ||--o{ invoice_lines: itemizes"
- "plans ||--o{ invoice_lines: price snapshot of"
- "customers ||--o{ payment_methods: saves"
- "invoices ||--o{ payments: is settled by"
- "payment_methods ||--o{ payments: funds"
groups:
- name: Catalog
entities: [plans]
- name: Account
entities: [customers, payment_methods]
- name: Ledger
entities: [subscriptions, invoices, invoice_lines, payments]
```
## Subscription lifecycle
A subscription with a trial starts in TRIALING and moves to ACTIVE only when its first invoice is paid. A failed renewal does not cancel the subscription; it moves to PAST_DUE and stays there while retries run. CANCELED is final: a customer who returns gets a new subscription row, so history is never rewritten.
```state
id: subscription-states
dir: LR
states:
- { id: s0, col: 1, row: 1, kind: start }
- { id: trialing, col: 2, row: 1, kind: wait, name: TRIALING }
- { id: active, col: 3, row: 1, kind: active, name: ACTIVE }
- { id: past_due, col: 4, row: 1, kind: wait, name: PAST_DUE }
- { id: canceled, col: 4, row: 2, kind: terminal, name: CANCELED }
transitions:
- { from: s0, to: trialing, event: create, guard: "plan.trial_days > 0" }
- { from: s0, to: active, event: create, guard: "plan.trial_days = 0 and first invoice paid" }
- { from: trialing, to: active, event: trial ends, guard: "first invoice paid" }
- { from: trialing, to: canceled, event: customer cancels }
- { from: active, to: past_due, event: renewal payment fails }
- { from: past_due, to: active, event: retry succeeds }
- { from: past_due, to: canceled, event: retries exhausted }
- { from: active, to: canceled, event: period ends, guard: "cancel_at_period_end = true" }
```
## Renewal and failed payments
Renewal is the only path that creates invoices and payments. The retry schedule is three days apart with four attempts at most, so a subscription spends no more than twelve days in PAST_DUE. Each attempt is its own payment row, which is why an invoice can carry several failed payments and one succeeded payment.
```flow
id: renewal-flow
dir: LR
nodes:
- { id: start, col: 1, row: 1, kind: start, label: Period ends }
- { id: invoice, col: 2, row: 1, kind: process, label: Create invoice (open) }
- { id: charge, col: 3, row: 1, kind: process, label: Charge default payment method }
- { id: ok, col: 4, row: 1, kind: decision, label: Payment succeeded? }
- { id: paid, col: 5, row: 1, kind: process, label: "Invoice paid, subscription ACTIVE, next period set" }
- { id: done, col: 6, row: 1, kind: end, label: Done }
- { id: fail, col: 4, row: 2, kind: process, label: "Record failed payment, subscription PAST_DUE" }
- { id: retry, col: 5, row: 2, kind: decision, label: Attempts < 4? }
- { id: wait, col: 5, row: 3, kind: process, label: Wait 3 days }
- { id: giveup, col: 6, row: 2, kind: end, label: "Invoice uncollectible, subscription CANCELED" }
edges:
- start -> invoice
- invoice -> charge
- charge -> ok
- ok -> paid: "yes"
- paid -> done
- ok -x-> fail: "no"
- fail -> retry
- retry -> wait: "yes"
- wait --> charge: retry
- retry -x-> giveup: "no"
```
## Invariants
These rules hold on every write. The database enforces the first three with constraints; the billing service enforces the rest in one transaction per renewal.
```spec
id: billing-invariants
title: Rules the model must keep
accent: navy
rows:
- { label: Money, value: "Every amount is an integer in minor units. No column stores a float or a formatted string." }
- { label: Invoice total, value: "total_cents = subtotal_cents + tax_cents, and subtotal_cents = sum of amount_cents over its lines." }
- { label: Amount paid, value: "invoices.amount_paid_cents = sum of amount_cents over payments with status succeeded, minus refunded payments." }
- { label: Paid status, value: "An invoice is paid only when amount_paid_cents = total_cents. Partial payments leave it open." }
- { label: One open invoice, value: "A subscription has at most one invoice with status open at a time. Renewal waits until the previous invoice is paid, void, or uncollectible." }
- { label: Immutable lines, value: "Invoice lines never change after issued_at is set. A correction is a new invoice or a refunded payment, never an edit." }
- { label: Price snapshot, value: "invoice_lines.unit_amount_cents copies plans.amount_cents at issue time. Retiring or repricing a plan never changes an issued invoice." }
- { label: Renewal transaction, steps: [Create invoice, Create payment (pending), Charge provider, "Update payment status", "Update invoice amount_paid_cents and status", "Update subscription status and period"] }
```