Skip to content
chiltepin

Generated from: “Explain the data model behind subscriptions, plans, invoices, and payments.

Billing data model

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDRAFT

Billing data model

How customers, plans, subscriptions, invoices, and payments relate, and which rules keep the money consistent.

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.

SECTION 01 · Note

Assumptions

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

SECTION 02 · Entity model
ER
Entity relationship diagram: 7 entitiesCATALOGACCOUNTLEDGER1N1N1N1N1N1N1N1N1NAGGREGATE ROOTcustomers#iduuidUemailtextnametextcurrencychar(3) = USDcreated_attimestamptzENTITYplans#iduuidUcodetextnametextintervaltext enummonth · yearamount_centsintegercurrencychar(3)trial_daysinteger = 0statustext enumactive · retiredENTITYsubscriptions#iduuidcustomer_iduuid → customers.idplan_iduuid → plans.idstatustext enumtrialing · active · past_due ·canceledcurrent_period_starttimestamptzcurrent_period_endtimestamptzcancel_at_period_endboolean = falsecanceled_attimestamptzcreated_attimestamptzENTITYinvoices#iduuidUnumbertextsubscription_iduuid → subscriptions.idcustomer_iduuid → customers.idstatustext enumdraft · open · paid · void ·uncollectibleperiod_starttimestamptzperiod_endtimestamptzsubtotal_centsintegertax_centsinteger = 0total_centsintegeramount_paid_centsinteger = 0due_attimestamptzissued_attimestamptzENTITYinvoice_lines#iduuidinvoice_iduuid → invoices.idplan_iduuid → plans.iddescriptiontextquantityinteger = 1unit_amount_centsintegeramount_centsintegerENTITYpayment_methods#iduuidcustomer_iduuid → customers.idkindtext enumcard · sepa_debitUprovider_reftextlast4char(4)expires_atdateis_defaultboolean = falseENTITYpayments#iduuidinvoice_iduuid → invoices.idpayment_method_iduuid → payment_methods.idstatustext enumpending · succeeded · failed · refundedamount_centsintegerprovidertextUprovider_reftextfailure_codetextattempted_attimestamptzHOLDSPRICESBILLS EACH PERIODIS BILLED ONITEMIZESPRICE SNAPSHOT OFSAVESIS SETTLED BYFUNDS
Legend#primary keyforeign keyUuniqueindexed1 / Ncardinalityschema groupaggregate root

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.

SECTION 03 · State machine
STATE
State machine: 5 states, 8 transitionsTRIALINGACTIVEPAST_DUE12345678
Legendstartstatewaitingtransitionerror exiterror transition
FromEventGuardTo
1s0createplan.trial_days > 0TRIALING
2s0createplan.trial_days = 0 and first invoice paidACTIVE
3TRIALINGtrial endsfirst invoice paidACTIVE
4TRIALINGcustomer cancelsCANCELED
5ACTIVErenewal payment failsPAST_DUE
6PAST_DUEretry succeedsACTIVE
7PAST_DUEretries exhaustedCANCELED
8ACTIVEperiod endscancel_at_period_end = trueCANCELED

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.

SECTION 04 · Flowchart
FLOW
Flowchart: 10 stepsPeriod endsCreate invoice(open)Charge defaultpayment methodPayment succeeded?Invoice paid,subscription ACTIVE,DoneRecord failedpayment,Attempts < 4?Wait 3 daysInvoiceuncollectible,12345
1yes2no3yes4retry5no
Legendstartstepdecision (diamond)exitnextoptionalerror pathhappy path

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.

SECTION 05 · Spec

Rules the model must keep

Money
Every amount is an integer in minor units. No column stores a float or a formatted string.
Invoice total
total_cents = subtotal_cents + tax_cents, and subtotal_cents = sum of amount_cents over its lines.
Amount paid
invoices.amount_paid_cents = sum of amount_cents over payments with status succeeded, minus refunded payments.
Paid status
An invoice is paid only when amount_paid_cents = total_cents. Partial payments leave it open.
One open invoice
A subscription has at most one invoice with status open at a time. Renewal waits until the previous invoice is paid, void, or uncollectible.
Immutable lines
Invoice lines never change after issued_at is set. A correction is a new invoice or a refunded payment, never an edit.
Price snapshot
invoice_lines.unit_amount_cents copies plans.amount_cents at issue time. Retiring or repricing a plan never changes an issued invoice.
Renewal transaction
Create invoiceCreate payment (pending)Charge providerUpdate payment statusUpdate invoice amount_paid_cents and statusUpdate subscription status and period
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"] }
```