Tiered vs Flat-Rate Subscription Database Design
Architecting a billing database requires a deliberate choice between flat-rate simplicity and tiered complexity. Flat-rate models rely on static price_id foreign keys and predictable monthly cycles. Tiered architectures demand dynamic threshold evaluation, usage aggregation, and graduated rate calculations.
When engineering a Subscription Billing Architecture & Pricing Models foundation, teams must normalize pricing rules into queryable tables. This prevents schema drift as product requirements scale.
This guide provides a step-by-step implementation workflow covering unified subscription databases that scale from simple flat-rate plans to complex tiered structures. The design ensures ACID compliance, idempotent billing cycles, and sub-50ms pricing resolution.
Core Schema Design for Flat-Rate vs Tiered Models
Begin by establishing a polymorphic pricing schema. Flat-rate subscriptions map directly to a single subscriptions.plan_id. Tiered models require a pricing_tiers table with lower_bound, upper_bound, and price_per_unit columns.
Use a pricing_strategy ENUM ('flat', 'tiered', 'volume') to route query execution paths. Embedding Hybrid Pricing Models logic early prevents future table migrations when product requirements evolve.
Step 1: Create plans table with id, name, pricing_strategy, and base_amount.
CREATE TYPE pricing_strategy AS ENUM ('flat', 'tiered', 'volume');
CREATE TABLE plans (
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'
);
Step 2: Create pricing_tiers table with plan_id, tier_index, lower_bound, upper_bound, and price_per_unit.
CREATE TABLE pricing_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
tier_index INT NOT NULL,
lower_bound BIGINT NOT NULL,
upper_bound BIGINT, -- NULL indicates unbounded top tier
price_per_unit NUMERIC(19,4) NOT NULL,
UNIQUE(plan_id, tier_index)
);
Step 3: Implement subscriptions table with id, customer_id, plan_id, status, current_period_start, and current_period_end.
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
plan_id UUID NOT NULL REFERENCES plans(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)
);
Step 4: Add subscription_usage table for tiered evaluation.
CREATE TABLE subscription_usage (
id BIGSERIAL PRIMARY KEY,
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
meter_id VARCHAR(64) NOT NULL,
quantity BIGINT NOT NULL CHECK (quantity >= 0),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Step 5: Apply composite indexes for range query optimization.
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);
Dynamic Pricing Resolution & Query Workflows
Flat-rate lookups are O(1) via direct join. Tiered resolution requires window functions or application-side aggregation against pricing_tiers. Implement a deterministic pricing resolver that caches tier thresholds and validates usage boundaries before invoice generation.
Step 1: Query subscription_usage for the current billing period, grouping by meter_id.
Aggregate usage within the exact UTC window. Avoid local timezone conversions during aggregation to prevent DST boundary shifts.
Step 2: If pricing_strategy = 'flat', return plans.base_amount directly.
SELECT base_amount
FROM plans
WHERE id = $1 AND strategy = 'flat';
Step 3: If pricing_strategy = 'tiered', map aggregated usage to the correct bracket.
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
AND (upper_bound IS NULL OR u.total_qty > lower_bound)
ORDER BY tier_index ASC
) pt;
Step 4: Calculate tiered cost using NUMERIC arithmetic.
Wrap calculations in a transactional function. Use NUMERIC types exclusively to avoid IEEE-754 floating-point drift. Apply ROUND(result, 2) only at the final invoice line-item stage.
Step 5: Validate output against a pre-computed materialized view to catch drift before invoicing.
-- Materialized view for daily aggregation (refresh via cron)
CREATE MATERIALIZED VIEW mv_daily_tier_usage AS
SELECT
subscription_id,
meter_id,
DATE(recorded_at AT TIME ZONE 'UTC') AS usage_date,
SUM(quantity) AS daily_qty
FROM subscription_usage
GROUP BY 1, 2, 3
WITH DATA;
Proration & Mid-Cycle State Transitions
Mid-cycle upgrades/downgrades require precise day-weighted calculations. State machine transitions prevent double-charging or revenue leakage.
Step 1: Lock the target subscription row to prevent concurrent modifications.
BEGIN;
SELECT id, plan_id, current_period_start, current_period_end
FROM subscriptions
WHERE id = $1 FOR UPDATE SKIP LOCKED;
If SKIP LOCKED returns zero rows, abort and retry with exponential backoff to avoid deadlock timeouts under high concurrency.
Step 2: Calculate remaining days using fractional precision.
Use EXTRACT(EPOCH FROM (current_period_end - NOW())) / 86400.0 for fractional day precision. Never truncate; carry full precision through the credit calculation.
Step 3: Compute credit for unused flat-rate days or tiered usage adjustments.
For flat-rate: credit = (remaining_days / total_days) * old_plan_amount.
For tiered: Re-evaluate usage against the old tier boundaries up to the switch timestamp. Generate a delta adjustment.
Step 4: Generate a proration_line_item record with idempotency guard.
INSERT INTO invoice_line_items (
invoice_id, subscription_id, type, amount, idempotency_key
) VALUES (
$1, $2, 'proration_credit', $3, $4
) ON CONFLICT (idempotency_key) DO NOTHING;
The idempotency_key must be derived from subscription_id + plan_change_timestamp + direction.
Step 5: Update plan_id and reset period boundaries, then emit a domain event.
Commit the transaction only after all line items are persisted. Emit a SubscriptionPlanChanged event to downstream ledger services.
Implementation Patterns
- PostgreSQL
LATERAL JOINfor tier boundary mapping without application-side loops. This eliminates N+1 application queries and pushes computation to the query planner. - Idempotent invoice generation using
idempotency_keyandON CONFLICT DO NOTHING. Network retries during webhook processing must never duplicate charges. - JSONB
pricing_rulescolumn for storing tiered thresholds when schema flexibility outweighs strict relational normalization. Validate schema at the application layer using JSON Schema to prevent malformed pricing payloads. - Materialized views for daily aggregated tier calculations on read-heavy dashboards. Use
REFRESH MATERIALIZED VIEW CONCURRENTLYto avoid table locks during refresh.
Edge Cases & Failures
- Race conditions during concurrent plan upgrades causing duplicate proration charges. Mitigate with
SELECT ... FOR UPDATE SKIP LOCKEDand strictidempotency_keyconstraints. Implement optimistic locking viaversioncolumns if row-level locking causes unacceptable latency. - Floating-point precision loss when calculating fractional tier rates across high-volume usage. Enforce
NUMERIC(19,4)for all monetary and unit-rate columns. Never useFLOATorDOUBLE PRECISIONfor billing math. - Timezone boundary mismatches where
current_period_endshifts across UTC/local DST transitions. Store all timestamps inTIMESTAMPTZ(UTC). Perform period boundary calculations in UTC. Convert to local time only at the presentation layer. - Orphaned
pricing_tiersrecords after plan deletion. EnforceON DELETE CASCADEonpricing_tiers. Implement a soft-delete pattern (deleted_at) if historical invoice reconstruction requires tier snapshots. - Usage spikes exceeding all tier bounds causing resolution to return no matching row. Define an explicit
upper_bound IS NULLcatch-all tier. Validate application-side ingestion pipelines with rate limiters to prevent database write saturation.
FAQ
Should I normalize tier thresholds into separate tables or store them as JSONB? Use separate relational tables if you require strict ACID compliance, foreign key constraints, and complex analytical queries. Use JSONB if your tiers change frequently, require rapid deployment without migrations, or if your pricing engine resolves them entirely in application memory.
How do I prevent double-charging during mid-cycle plan switches?
Implement row-level locking (SELECT FOR UPDATE) during the proration workflow, generate a unique idempotency_key for each billing event, and use database transactions with SERIALIZABLE isolation to detect and abort conflicting concurrent updates.
What indexing strategy optimizes tiered usage aggregation queries?
Create a composite index on (subscription_id, recorded_at) with INCLUDE (quantity, meter_id). For high-throughput systems, partition the subscription_usage table by month and maintain a materialized view for daily aggregated tier calculations.