Separate catalog, commerce, and access
A maintainable billing model keeps three concerns apart:
- Catalog: Product, ProductPrice, and Benefit describe the offer.
- Commerce: Checkout, Order, Payment, and Transaction describe collection.
- Access: Subscription and BenefitGrant describe time and entitlement.
Product 1─* ProductPrice
Product *─* Benefit
Customer 1─* Checkout 1─0..1 Order 1─* Payment
Customer 1─* Subscription 1─* BenefitGrant
Subscription *─1 Product
Preserve historical prices
When a price moves from 99,000 VND to 149,000 VND, archive the old ProductPrice and create a new one. Orders and Subscriptions keep the purchased version or a snapshot so current catalog edits never rewrite history.
type ProductPrice = {
id: string
productId: string
amount: number
currency: "VND"
interval: "month" | "year"
archivedAt: string | null
}
Use integer VND amounts rather than floating point. Keep currency and interval explicit even with one supported currency because the invariant matters.
Checkout is not Payment
Checkout orchestrates an attempt: idempotency, provider reference, redirects, and link-creation state. Payment records the monetary result that can arrive later. Their separation represents the important timeout where the provider created a link but your app missed the response.
Order stores the purchased product, price, and period snapshot. Transaction is an immutable money record; never rewrite an old transaction to represent a later state.
Subscription is a state machine
incomplete → active → past_due → canceled
↘ active after renewal
active + cancel_at_period_end → canceled at period end
Route every transition through one billing engine that validates current state and uses a transaction. Controllers, webhook handlers, and cron functions should not each update status independently.
Benefits and entitlement
A Product can include Benefits such as feature access or a customer limit. An active Subscription materializes BenefitGrants with validity periods. The merchant app reads grants or synchronizes them through signed webhooks.
This keeps authorization independent from the latest Payment row and avoids adding one Subscription column for every feature.
Tenant boundaries and constraints
Every business entity belongs to an Organization. A Public API key selects that Organization; public URLs should not accept an Organization ID supplied by the client.
Useful unique keys include (organization_id, external_customer_id), (organization_id, idempotency_key, operation), provider payment reference, and (subscription_id, period_start) for renewals.
Before adding a constraint, inspect duplicates and plan the backfill. Test price history, retry behavior, tenant isolation, late payments, and grant revocation.