Subscription Management System Low-Level Design

What is Subscription Management?

A subscription management system handles recurring billing: plans, upgrades/downgrades, trial periods, proration, cancellations, and payment retries. Stripe, Recurly, and Chargebee are dedicated subscription billing platforms. Building your own subscription system is complex — understanding the design is essential for interviews at Stripe, Shopify, Atlassian, and any SaaS company. Key challenges: proration (charging the right amount when changing plans mid-cycle), dunning (retrying failed payments), and idempotent billing operations.

Requirements

  • Plans: monthly/annual billing, multiple tiers (free, starter, pro, enterprise)
  • Upgrades and downgrades with proration (credit for unused time on old plan)
  • Free trials with automatic conversion to paid
  • Failed payment handling: retry up to 4 times over 7 days before cancellation
  • Webhooks to application when subscription status changes
  • Revenue reporting: MRR, churn, upgrade/downgrade revenue

Data Model

Plan(plan_id UUID, name VARCHAR, price_cents INT, interval ENUM(MONTH, YEAR),
     features JSONB, is_active BOOL)

Subscription(
    subscription_id UUID PRIMARY KEY,
    user_id UUID, plan_id UUID,
    status ENUM(TRIALING, ACTIVE, PAST_DUE, CANCELLED, PAUSED),
    trial_start TIMESTAMPTZ, trial_end TIMESTAMPTZ,
    current_period_start TIMESTAMPTZ,
    current_period_end TIMESTAMPTZ,
    cancel_at_period_end BOOL DEFAULT false,
    cancelled_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ
)

Invoice(
    invoice_id UUID PRIMARY KEY,
    subscription_id UUID,
    user_id UUID,
    amount_cents INT,
    status ENUM(DRAFT, OPEN, PAID, VOID, UNCOLLECTIBLE),
    due_date TIMESTAMPTZ,
    paid_at TIMESTAMPTZ,
    attempt_count INT DEFAULT 0,
    next_retry_at TIMESTAMPTZ
)

InvoiceLineItem(line_id UUID, invoice_id UUID,
    description VARCHAR,  -- 'Pro Plan (Jan 1 - Feb 1)', 'Proration credit'
    amount_cents INT,     -- negative for credits
    period_start TIMESTAMPTZ, period_end TIMESTAMPTZ)

Proration on Plan Change

def upgrade_plan(subscription_id, new_plan_id):
    sub = db.get(subscription_id)
    old_plan = db.get_plan(sub.plan_id)
    new_plan = db.get_plan(new_plan_id)
    now = datetime.utcnow()

    days_remaining = (sub.current_period_end - now).days
    days_in_period = (sub.current_period_end - sub.current_period_start).days

    # Credit for unused time on old plan
    credit_cents = int(old_plan.price_cents * days_remaining / days_in_period)

    # Charge for remaining time on new plan
    charge_cents = int(new_plan.price_cents * days_remaining / days_in_period)

    proration_charge = charge_cents - credit_cents  # positive = charge, negative = credit

    # Create invoice for proration amount
    invoice = create_invoice(sub.user_id, proration_charge, [
        InvoiceLineItem('Pro Plan (remaining days)', charge_cents, now, sub.current_period_end),
        InvoiceLineItem('Starter Plan (unused days) credit', -credit_cents, now, sub.current_period_end)
    ])

    # Update subscription
    db.update(subscription_id, plan_id=new_plan_id)
    collect_payment(invoice)

Failed Payment / Dunning

# Retry schedule: attempt 1 (day 0) → 2 (day 3) → 3 (day 5) → 4 (day 7) → cancel
RETRY_SCHEDULE = [0, 3, 5, 7]  # days after first failure

def handle_payment_failure(invoice_id):
    invoice = db.get(invoice_id)
    sub = db.get_subscription(invoice.subscription_id)

    if invoice.attempt_count >= len(RETRY_SCHEDULE):
        # Max retries exceeded — cancel subscription
        db.update(sub.subscription_id, status='CANCELLED')
        notify_user(sub.user_id, 'subscription_cancelled')
        return

    # Schedule retry
    retry_day = RETRY_SCHEDULE[invoice.attempt_count]
    next_retry = invoice.created_at + timedelta(days=retry_day)
    db.update(invoice_id, status='OPEN', attempt_count=invoice.attempt_count+1,
              next_retry_at=next_retry)
    db.update(sub.subscription_id, status='PAST_DUE')
    notify_user(sub.user_id, 'payment_failed', retry_date=next_retry)

# Cron job runs hourly: retry all OPEN invoices with next_retry_at <= NOW()

Revenue Metrics

-- Monthly Recurring Revenue (MRR)
SELECT SUM(
    CASE p.interval
        WHEN 'MONTH' THEN p.price_cents
        WHEN 'YEAR'  THEN p.price_cents / 12
    END
) / 100.0 AS mrr_dollars
FROM Subscription s JOIN Plan p ON s.plan_id = p.plan_id
WHERE s.status IN ('ACTIVE', 'TRIALING');

-- Churn rate (subscriptions cancelled this month / active at start of month)
SELECT
    cancelled_this_month * 100.0 / active_at_start AS churn_rate_pct
FROM (
    SELECT
        COUNT(*) FILTER (WHERE cancelled_at >= date_trunc('month', NOW())) AS cancelled_this_month,
        COUNT(*) FILTER (WHERE created_at < date_trunc('month', NOW())) AS active_at_start
    FROM Subscription WHERE status IN ('ACTIVE', 'PAST_DUE', 'CANCELLED')
) stats;

Key Design Decisions

  • Day-based proration — simple and auditable; Stripe uses this approach
  • Invoice line items for proration transparency — customers see exactly what they’re charged/credited
  • cancel_at_period_end flag — user retains access until period end; no immediate service disruption
  • PAST_DUE status — service continues during retry window; prevent churn from temporary payment issues
  • Idempotent invoice creation — invoice_id based on (subscription_id + period_start) prevents duplicate charges on retry

Subscription billing and payment system design is discussed in Stripe system design interview questions.

Subscription management and SaaS billing design is covered in Atlassian system design interview preparation.

Subscription and recurring billing system design is discussed in Shopify system design interview guide.

Scroll to Top