Core Schema
The subscription manager tracks the relationship between users, plans, and billing periods.
CREATE TABLE subscriptions (
subscription_id UUID PRIMARY KEY,
user_id BIGINT NOT NULL,
plan_id VARCHAR(64) NOT NULL,
status ENUM('trialing','active','past_due','cancelled') NOT NULL,
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
trial_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE plans (
plan_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
price INTEGER NOT NULL, -- in cents
currency CHAR(3) NOT NULL,
interval ENUM('monthly','annual') NOT NULL,
trial_days INTEGER NOT NULL DEFAULT 0,
features JSONB NOT NULL DEFAULT '[]'
);
Renewal Flow
A scheduled job runs every hour and processes subscriptions due for renewal:
- Query:
WHERE status IN ('active','past_due') AND current_period_end <= now() + interval '1 day' - For each subscription: attempt charge via payment provider using stored payment method.
- On success: advance
current_period_startandcurrent_period_end, setstatus='active', create invoice record. - On failure: start dunning workflow, set
status='past_due'.
Use idempotency keys on payment provider calls (subscription_id + period_start) to prevent double-charging if the job runs twice.
Dunning Workflow
Dunning is the process of retrying failed payments before cancelling the subscription:
- Day 0: Payment fails → status=past_due → send “payment failed” email → log dunning attempt
- Day 1: Retry charge → success: resume; failure: send reminder email
- Day 3: Retry charge → success: resume; failure: send urgent reminder
- Day 7: Retry charge → success: resume; failure: send final warning
- Day 14: Cancel subscription → status=cancelled → revoke access → send cancellation email
Access behavior during dunning: options are grace period (full access for X days) or immediate restriction (read-only mode on first failure). For SaaS, grace period is better for retention. For content subscriptions, restrict immediately.
Proration on Plan Change
When a user upgrades mid-cycle:
- Calculate unused days in current period:
days_remaining = period_end - now() - Credit:
current_plan_daily_rate * days_remaining - Charge:
new_plan_price - credit - Start new period immediately at new plan level.
Example: User is on $30/month plan, 15 days remaining. Upgrades to $60/month. Credit = $15, charge = $60 – $15 = $45. New period starts today, runs 30 days.
On downgrade: do not charge or credit immediately. Apply the downgrade at the end of the current period (cancel_at_period_end=false but record pending plan change). At renewal, bill at new lower price.
Cancellation
Two cancellation modes:
- Cancel at period end: Set
cancel_at_period_end=true. User retains access throughcurrent_period_end. On that date, setstatus=cancelledand revoke access. No refund. - Immediate cancel: Set
status=cancelledimmediately. Calculate pro-rated refund for unused days and issue to original payment method. Access revoked at cancellation time.
Most consumer products default to cancel-at-period-end (better for retention, simpler accounting). Immediate cancel with refund is common in B2B where annual contracts are involved.
Trial Management
Trials are a first billing period with no charge:
- On signup: create subscription with
status=trialing,trial_end = now() + trial_days - No payment required to start trial (or require card on file but do not charge)
- Scheduled job: when
trial_end <= now()and payment method is on file → attempt first charge → success: switch to active; failure: dunning or cancel - When
trial_end <= now()and no payment method → send “add payment method” email → grace period of 3 days → cancel
Access Control Integration
Subscription status maps to access levels:
activeortrialing: full feature accesspast_due: restricted access (read-only or limited feature set)cancelled: downgrade to free tier or block entirely
Access decisions should read from a cache keyed on user_id with short TTL (60 seconds). Do not query the subscriptions table on every API request. On status change, invalidate the cache entry immediately.
Invoice Generation
Create an invoice record for every charge attempt (success or failure):
CREATE TABLE invoices (
invoice_id UUID PRIMARY KEY,
subscription_id UUID NOT NULL,
user_id BIGINT NOT NULL,
amount INTEGER NOT NULL,
currency CHAR(3) NOT NULL,
status ENUM('paid','failed','refunded') NOT NULL,
payment_method JSONB,
charged_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Invoice records are immutable once created. Refunds create a separate refund record rather than modifying the invoice. This gives a complete audit trail for billing disputes and tax reporting.
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering
See also: Shopify Interview Guide