Subscription Billing Architecture & Pricing Models

A subscription billing engine is the part of a SaaS platform where correctness is non-negotiable: every state transition either collects revenue you are owed or refunds money you are not. Integrating a payment gateway is the easy 10%. The hard 90% is the deterministic machinery around it — idempotent event processing, a finite-state model of every subscription, and an immutable ledger that survives gateway outages and still reconciles to the cent. This page is the architectural reference for that machinery. It covers how a billing engine models money and time, where it commonly breaks, and the patterns that keep it auditable as you scale from a handful of customers to six figures of active contracts.

The engine has to do several things at once. It must represent flexible monetization — flat, tiered, and metered plans combined through Hybrid Pricing Models — without letting pricing logic leak into payment execution. It must move subscriptions through Subscription Lifecycle States only along legal transitions. It must reconcile partial billing periods through Proration Logic & Calculations when customers upgrade or downgrade mid-cycle. It must ingest consumption at volume through Usage-Based Billing Implementation, and it must convert trials cleanly through disciplined Trial Period Management. Each of those is its own subsystem, but they share one spine: a price book, a state machine, and a ledger, wired together by an event bus that guarantees at-least-once delivery with exactly-once effects.

Architecture Overview

The billing engine is best understood as a pipeline. A signed event arrives from the payment gateway, passes a signature check and an idempotency gate, mutates subscription state inside a single transaction, and writes both a ledger entry and an outbox row atomically. A separate relay drains the outbox to the event bus, where the tax service, invoicing service, and analytics consumers subscribe. The ledger — not the gateway — is the system of record.

Billing engine architecture Gateway events pass signature verification and an idempotency gate, mutate the FSM and ledger in one transaction with an outbox row, then a relay fans out to tax, invoicing, and analytics consumers. Payment gateway Signature + idempotency FSM state transition One transaction: ledger + outbox (system of record) Outbox relay -> bus Tax service Invoicing Analytics
Signed events are de-duplicated, applied to state and ledger in one transaction, then fanned out via the outbox relay.

The table below summarizes the responsibility of each service and what it must never do.

Service Owns Must not
Billing engine FSM transitions, proration math, invoice assembly Hold card data; recompute history from live FX
Double-entry ledger Immutable debit/credit pairs, balances Allow updates or deletes to posted rows
Outbox relay At-least-once event publication Publish before the source transaction commits
Tax service Jurisdiction resolution per line item Cache rates past their validity window
Webhook ingress Signature verification, idempotency gate Process an event before deduplicating it

Decoupling these lets each scale and fail independently. A tax provider outage degrades to a queued fallback rate; it does not block invoice generation across every tenant.

Core Data Model

Money is stored as integer minor units (BIGINT cents), never floats. Prices are versioned and immutable: a price record is never edited, only superseded, so historical invoices always reconstruct against the price that was in effect. Subscriptions reference a price_id snapshot rather than a mutable plan row.

-- Versioned, immutable price records (minor units / cents)
CREATE TABLE prices (
  price_id        UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  product_code    TEXT         NOT NULL,
  version         INT          NOT NULL,
  currency_code   CHAR(3)      NOT NULL,
  unit_amount     BIGINT       NOT NULL CHECK (unit_amount >= 0),  -- cents
  billing_model   TEXT         NOT NULL CHECK (billing_model IN ('flat','tiered','metered')),
  tier_structure  JSONB,                                          -- NULL for flat
  effective_at    TIMESTAMPTZ  NOT NULL,
  created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (product_code, version)
);

CREATE TABLE subscriptions (
  subscription_id      UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id          UUID         NOT NULL,
  price_id             UUID         NOT NULL REFERENCES prices(price_id),
  status               TEXT         NOT NULL
                         CHECK (status IN ('trialing','active','past_due','canceled')),
  current_period_start TIMESTAMPTZ  NOT NULL,
  current_period_end   TIMESTAMPTZ  NOT NULL,
  state_version        BIGINT       NOT NULL DEFAULT 0,  -- optimistic lock
  CHECK (current_period_end > current_period_start)
);

-- Append-only double-entry ledger; posted rows are immutable
CREATE TABLE ledger_entries (
  ledger_entry_id  UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id       UUID,
  customer_id      UUID         NOT NULL,
  account_code     TEXT         NOT NULL,         -- e.g. 1000_CASH, 4000_REVENUE
  direction        TEXT         NOT NULL CHECK (direction IN ('debit','credit')),
  amount           BIGINT       NOT NULL CHECK (amount > 0),  -- cents
  currency_code    CHAR(3)      NOT NULL,
  idempotency_key  TEXT         NOT NULL,
  posted_at        TIMESTAMPTZ  NOT NULL DEFAULT now(),
  UNIQUE (idempotency_key, account_code, direction)
);

The state_version column drives optimistic concurrency on lifecycle transitions; the idempotency_key uniqueness on ledger_entries makes every posting replay-safe. Storing tier_structure as JSONB keeps tiered and flat plans in one table while leaving threshold math to the rating engine.

Key Design Patterns

Outbox pattern

Write the domain mutation and the event to publish in the same transaction, then relay the event asynchronously. This makes “state changed” and “event published” atomic without distributed transactions.

BEGIN;
UPDATE subscriptions
   SET status = 'active', state_version = state_version + 1
 WHERE subscription_id = $1 AND state_version = $2;  -- ✅ optimistic guard

INSERT INTO outbox (event_id, aggregate_id, event_type, payload)
VALUES (gen_random_uuid(), $1, 'subscription.activated', $3);
COMMIT;  -- ⚠️ if this fails, neither the state nor the event escapes

Use it whenever a state change must reliably produce a downstream event. A separate relay polls outbox and publishes with at-least-once delivery; idempotent consumers make the net effect exactly-once.

Idempotency keys

Every mutating endpoint and every consumer keys on a deterministic token. Duplicate deliveries return the cached result instead of re-charging.

def process_billing_event(event_id: str, payload: dict) -> dict:
    if (cached := idempotency_store.get(event_id)) is not None:
        return cached  # ✅ replay returns prior result, no side effects
    with db.transaction():
        result = apply_business_logic(payload)
        idempotency_store.set(event_id, result, ttl=86400)  # ≥ retry window
    return result

Use it on webhook ingress and any client-facing POST that moves money.

Finite-state machine

Subscription status changes only along declared edges. Illegal jumps are rejected before any ledger effect.

LEGAL = {
    "trialing": {"active", "canceled"},
    "active":   {"past_due", "canceled"},
    "past_due": {"active", "canceled"},
    "canceled": set(),  # ✗ terminal
}

def assert_transition(current: str, target: str) -> None:
    if target not in LEGAL[current]:
        raise ValueError(f"illegal transition {current} -> {target}")

Use it to keep out-of-order gateway events from corrupting state — a late payment_failed cannot reactivate a canceled subscription.

Double-entry posting

Every financial movement posts a balanced debit and credit. Balances are derived, never stored mutably.

INSERT INTO ledger_entries (customer_id, account_code, direction, amount, currency_code, idempotency_key)
VALUES
  ($1, '1000_CASH',    'debit',  $2, 'USD', $3),
  ($1, '4000_REVENUE', 'credit', $2, 'USD', $3);

Use it for every charge, credit, refund, and tax movement so the books always reconcile to zero.

Compliance & Regulatory Boundaries

PCI-DSS scope is minimized by never letting a primary account number touch your servers. The gateway tokenizes the card; you store only a token reference. This is the single most important architectural boundary in the payment path. GDPR and CCPA impose data minimization and right-to-erasure — but financial records carry a statutory retention period (commonly seven years), so erasure must pseudonymize the customer while preserving the ledger.

Tax is resolved per line item at invoice generation, because a flat subscription fee and a usage overage can fall under different tax codes and jurisdictions. The exact rate applied must be snapshotted onto the invoice; never recompute historical tax from current rules. Revenue recognition follows ASC 606 / IFRS 15: cash collected up front sits in a deferred-revenue liability account and is recognized over the service period, not at the moment of payment. The ledger schema above gives you the audit trail regulators expect — immutable, timestamped, and traceable to a source event.

Scalability & Failure Modes

At a few thousand subscriptions, a naive nightly batch that iterates every subscription in one transaction works. At 10k it starts to time out; at 100k it will not finish inside the billing window. The fix is to shard invoice generation by customer_id hash and process shards in parallel, each in its own short transaction. Renewal due-dates should be jittered across the day rather than all firing at midnight UTC, or you create a thundering herd against the gateway and your own database.

The dangerous cascade is the tax or payment provider going slow rather than down. Synchronous calls pile up, connection pools exhaust, and an unrelated tenant’s request blocks. Wrap every external call in a circuit breaker with a timeout well below your pool’s patience, and fall back to a queued retry with a default jurisdictional rate so invoices still generate. Retries use exponential backoff with jitter; the idempotency key guarantees a retried charge is never a double charge. Out-of-order webhooks are absorbed by the FSM and a monotonic event_sequence cursor, so a stale event is dropped rather than applied.

Operational Runbook

Monitor the outbox lag (rows where published_at IS NULL older than N seconds) — sustained growth means the relay is stuck and downstream state is drifting. Alert when the dead-letter queue for webhook processing crosses a small threshold; a spike usually means a deploy broke a consumer. Track the gateway settlement-to-ledger reconciliation gap daily: a non-zero unmatched balance is a correctness bug, not a rounding artifact, and should page someone.

Run a nightly reconciliation job that sums ledger debits and credits per currency and asserts they net to zero, and that compares gateway payout reports against 1000_CASH postings. A daily “fractional cent” sweep reconciles accumulated rounding remainders so they never silently leak. Keep period-close locking strict: once a financial period is closed, no row may post into it; corrections post as new dated reversing entries. Every alert threshold should be a number you can defend in an audit, not a vibe.

Frequently Asked Questions

How do you guarantee exactly-once effects across distributed billing services? Combine an idempotency-key store with the outbox pattern. The idempotency store makes any single consumer replay-safe — duplicate deliveries return the cached result. The outbox makes the state change and the event publication atomic in one transaction, so you never publish an event for a change that rolled back. Together they give at-least-once delivery with exactly-once effect.

Where should pricing logic live — in the gateway or your own engine? Keep pricing rules in your own versioned price book and treat the gateway purely as a payment executor. Embedding tiers, entitlements, and proration in the gateway couples your monetization to one vendor and makes audits harder. Resolve the amount yourself, then hand the gateway a final figure to capture.

Why store money as integer cents instead of a decimal type? Integer minor units eliminate an entire class of floating-point drift and serialization bugs across language boundaries. You do high-precision intermediate math (proration, tiered rating) in a decimal type, then round once to integer cents at the line-item boundary before persisting.

How do you handle a subscription that changes plan twice in a few seconds? Serialize the changes with optimistic concurrency (state_version) or a row lock, and key each resulting ledger posting on subscription_id + change_timestamp + target_price_id. The FSM rejects illegal intermediate transitions, and the idempotency keys prevent the rapid toggling from producing duplicate proration lines.

What is the minimum viable compliance posture before charging real cards? Tokenize through a PCI-compliant gateway so PANs never reach your servers, snapshot tax rates per invoice line, post all money movements to an immutable double-entry ledger, and defer recognized revenue per ASC 606. Those four boundaries cover the bulk of what an early audit will ask for.