Tiered vs Flat-Rate Subscription Database Design

The first irreversible decision in a billing schema is how you represent price: a flat-rate plan is one immutable number, while a tiered plan is a graduated function of usage. Pick a flat-only schema and you will be migrating production tables the day product adds a usage tier; over-engineer a tiered schema for a flat-only product and every invoice query carries needless joins. The right answer is one polymorphic schema that resolves both in sub-50ms, which is exactly what Hybrid Pricing Models demand. This page builds that schema against Subscription Billing Architecture & Pricing Models conventions: UUID keys, NUMERIC money, and idempotent, ACID-safe billing cycles.

Trade-offs

The choice between modeling tiers as relational rows versus a JSONB blob, and flat versus tiered resolution, has concrete cost and latency consequences.

Dimension Flat-rate (direct join) Tiered (relational rows) Tiered (JSONB rules)
Resolution latency ~1ms (O(1) PK lookup) 5โ€“15ms (LATERAL join + aggregate) 2โ€“6ms (in-app eval)
Schema changes Add columns rarely Migration per tier shape change No migration; deploy config
ACID / FK integrity Full Full (FK + CHECK constraints) Partial; validate in app
Analytical queries Trivial Strong (SQL over tier rows) Weak (JSON extraction)
Storage per plan Minimal One row per tier One JSONB document
Best when Pricing is stable, single price Tiers change rarely, audits matter Tiers change weekly, app resolves them

Use relational tiers when financial auditability and SQL analytics matter; reach for JSONB only when pricing changes faster than you can ship migrations and the rating engine resolves tiers entirely in memory.

Step-by-Step Implementation

1. Create the plans table with a strategy enum

A single enum routes resolution between code paths, so flat and tiered plans coexist.

CREATE TYPE pricing_strategy AS ENUM ('flat', 'tiered', 'volume');

CREATE TABLE plans (
  plan_id       UUID             PRIMARY KEY DEFAULT gen_random_uuid(),
  name          VARCHAR(64)      NOT NULL,
  strategy      pricing_strategy NOT NULL DEFAULT 'flat',
  base_amount   NUMERIC(19,4)    NOT NULL CHECK (base_amount >= 0),
  currency_code CHAR(3)          NOT NULL DEFAULT 'USD'
);

2. Add a pricing_tiers table

Each tier is a half-open range. A NULL upper_bound marks the unbounded top tier.

CREATE TABLE pricing_tiers (
  tier_id        UUID          PRIMARY KEY DEFAULT gen_random_uuid(),
  plan_id        UUID          NOT NULL REFERENCES plans(plan_id) ON DELETE CASCADE,
  tier_index     INT           NOT NULL,
  lower_bound    BIGINT        NOT NULL,
  upper_bound    BIGINT,       -- NULL = unbounded top tier
  price_per_unit NUMERIC(19,4) NOT NULL,
  UNIQUE (plan_id, tier_index)
);

3. Create the subscriptions table

The subscription references its plan and carries period boundaries used by Proration Logic & Calculations during mid-cycle changes.

CREATE TABLE subscriptions (
  subscription_id      UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id          UUID        NOT NULL,
  plan_id              UUID        NOT NULL REFERENCES plans(plan_id),
  status               VARCHAR(20) NOT NULL
                         CHECK (status IN ('active','past_due','canceled','trialing')),
  current_period_start TIMESTAMPTZ NOT NULL,
  current_period_end   TIMESTAMPTZ NOT NULL,
  CHECK (current_period_end > current_period_start)
);

4. Add a subscription_usage table

Tiered evaluation needs raw usage, recorded in UTC.

CREATE TABLE subscription_usage (
  usage_id        BIGSERIAL   PRIMARY KEY,
  subscription_id UUID        NOT NULL REFERENCES subscriptions(subscription_id),
  meter_id        VARCHAR(64) NOT NULL,
  quantity        BIGINT      NOT NULL CHECK (quantity >= 0),
  recorded_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

5. Index for range-query resolution

Composite indexes turn tier resolution and period aggregation into index scans.

CREATE INDEX idx_pricing_tiers_plan_tier ON pricing_tiers(plan_id, tier_index);
CREATE INDEX idx_usage_sub_recorded ON subscription_usage(subscription_id, recorded_at DESC);
CREATE INDEX idx_usage_meter ON subscription_usage(subscription_id, meter_id, recorded_at);

Resolve a tiered amount with a LATERAL join so the bracket math stays in the query planner instead of an application loop:

WITH period_usage AS (
  SELECT SUM(quantity) AS total_qty
  FROM subscription_usage
  WHERE subscription_id = $1
    AND recorded_at >= $2
    AND recorded_at <  $3
)
SELECT pt.tier_index, pt.price_per_unit,
       GREATEST(0, LEAST(u.total_qty, COALESCE(pt.upper_bound, u.total_qty)) - pt.lower_bound)
         AS billable_units
FROM period_usage u
CROSS JOIN LATERAL (
  SELECT * FROM pricing_tiers
  WHERE plan_id = $4 AND u.total_qty >= lower_bound
  ORDER BY tier_index
) pt;

Verification & Testing

Assert that every plan with strategy = 'tiered' has at least one pricing_tiers row and exactly one row with upper_bound IS NULL, or resolution can return no bracket for a usage spike. Test the bracket math at boundary values โ€” total_qty exactly equal to a lower_bound and one unit below an upper_bound โ€” to catch off-by-one errors in the half-open ranges. Run all monetary math in NUMERIC and assert no column is FLOAT. A reconciliation query worth scheduling:

-- Flag tiered plans with gaps or overlaps in their tier ranges
SELECT plan_id, tier_index, lower_bound, upper_bound
FROM pricing_tiers a
WHERE strategy_is_tiered(plan_id)
  AND upper_bound IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM pricing_tiers b
    WHERE b.plan_id = a.plan_id AND b.lower_bound = a.upper_bound
  );

Gotchas & Production Pitfalls

No catch-all top tier. A usage spike beyond every defined bound returns zero rows and silently under-bills. Always define an upper_bound IS NULL tier and assert its existence in CI.

Floating-point unit rates. Computing price_per_unit as FLOAT introduces drift that compounds over high-volume usage. Enforce NUMERIC(19,4) on every monetary and rate column; round only at the final line item.

Cascade deletes erasing invoice history. ON DELETE CASCADE on pricing_tiers is right for live config but destroys the tier snapshot a past invoice was computed against. Add a soft-delete (deleted_at) or snapshot tiers onto the invoice if you must reconstruct historical bills.

DST and timezone drift in aggregation windows. Aggregating usage in local time shifts the period boundary across a DST change and mis-buckets events. Store TIMESTAMPTZ, aggregate in UTC, and convert only for display.

Resolving tiers in an N+1 application loop. Iterating tiers in app code per subscription crushes throughput at invoice time. Push bracket mapping into a LATERAL join so the planner does it once per resolution.