Low-Level Design: Subscription and Billing System (Recurring Payments, Proration, Retry)

Requirements

Functional: subscribe/unsubscribe users to plans, charge recurring fees at billing intervals, support upgrades/downgrades with proration, apply coupons and discounts, generate invoices, handle payment failures with retry logic, expose billing history.

Non-functional: idempotent billing (no double charges), exactly-once invoice generation, audit trail for all billing events, PCI compliance (no raw card storage — tokenize via Stripe/Braintree).

Core Entities

class Plan:
    plan_id: str              # 'pro_monthly', 'enterprise_annual'
    name: str
    price_cents: int          # 2999 = $29.99
    currency: str             # 'USD'
    interval: str             # 'monthly' | 'annual'
    trial_days: int           # 14
    features: List[str]

class Subscription:
    subscription_id: str
    user_id: str
    plan_id: str
    status: SubscriptionStatus  # TRIALING | ACTIVE | PAST_DUE | CANCELLED | PAUSED
    current_period_start: datetime
    current_period_end: datetime
    trial_end: Optional[datetime]
    cancel_at_period_end: bool
    payment_method_id: str
    created_at: datetime

class Invoice:
    invoice_id: str
    subscription_id: str
    user_id: str
    amount_cents: int
    tax_cents: int
    discount_cents: int
    status: InvoiceStatus     # DRAFT | OPEN | PAID | VOID | UNCOLLECTIBLE
    due_date: datetime
    paid_at: Optional[datetime]
    payment_intent_id: Optional[str]
    line_items: List[LineItem]

class LineItem:
    description: str
    amount_cents: int
    period_start: datetime
    period_end: datetime
    proration: bool

class Coupon:
    coupon_id: str
    discount_type: str        # 'percent' | 'fixed'
    discount_value: int       # 20 (%) or 500 (cents)
    duration: str             # 'once' | 'repeating' | 'forever'
    duration_months: Optional[int]
    max_redemptions: Optional[int]
    redemptions: int
    expires_at: Optional[datetime]

Subscription State Machine

VALID_TRANSITIONS = {
    'TRIALING':  ['ACTIVE', 'CANCELLED', 'PAST_DUE'],
    'ACTIVE':    ['PAST_DUE', 'CANCELLED', 'PAUSED'],
    'PAST_DUE':  ['ACTIVE', 'CANCELLED'],
    'PAUSED':    ['ACTIVE', 'CANCELLED'],
    'CANCELLED': [],
}

def transition(subscription, new_status):
    if new_status not in VALID_TRANSITIONS[subscription.status]:
        raise ValueError(f"Cannot transition from {subscription.status} to {new_status}")
    subscription.status = new_status
    emit_event(subscription, new_status)

Billing Cycle Engine

class BillingEngine:
    def run_billing_cycle(self):
        """Run by a scheduled job every hour — bills subscriptions due in this window."""
        due_subs = db.query(
            "SELECT * FROM subscriptions "
            "WHERE status IN ('ACTIVE', 'TRIALING') "
            "AND current_period_end  NOW() - INTERVAL '1 hour' "
            "AND cancel_at_period_end = FALSE"
        )
        for sub in due_subs:
            self._bill_subscription(sub)

    def _bill_subscription(self, sub):
        idempotency_key = f"bill_{sub.subscription_id}_{sub.current_period_end.isoformat()}"
        # Check if already billed for this period
        if Invoice.exists(subscription_id=sub.subscription_id,
                          period_end=sub.current_period_end):
            return  # idempotent
        invoice = self._create_invoice(sub)
        result = payment_gateway.charge(
            payment_method=sub.payment_method_id,
            amount_cents=invoice.amount_cents,
            idempotency_key=idempotency_key
        )
        if result.success:
            invoice.status = 'PAID'
            invoice.paid_at = datetime.utcnow()
            self._advance_period(sub)
            transition(sub, 'ACTIVE')
        else:
            invoice.status = 'OPEN'
            transition(sub, 'PAST_DUE')
            self._schedule_retry(sub, invoice)

    def _advance_period(self, sub):
        sub.current_period_start = sub.current_period_end
        if sub.plan.interval == 'monthly':
            sub.current_period_end = add_months(sub.current_period_end, 1)
        else:
            sub.current_period_end = add_years(sub.current_period_end, 1)
        db.save(sub)

Proration on Plan Change

def upgrade_plan(subscription, new_plan):
    now = datetime.utcnow()
    old_plan = get_plan(subscription.plan_id)
    period_total = (subscription.current_period_end - subscription.current_period_start).total_seconds()
    period_used  = (now - subscription.current_period_start).total_seconds()
    period_remaining_fraction = 1 - (period_used / period_total)

    # Credit unused time on old plan
    credit_cents = int(old_plan.price_cents * period_remaining_fraction)
    # Charge remaining time on new plan
    charge_cents = int(new_plan.price_cents * period_remaining_fraction)
    proration_cents = charge_cents - credit_cents

    if proration_cents > 0:
        create_proration_invoice(subscription, proration_cents, old_plan, new_plan, now)
    subscription.plan_id = new_plan.plan_id
    db.save(subscription)

Payment Failure Retry Logic

RETRY_SCHEDULE_DAYS = [3, 5, 7]  # retry at day 3, 5, 7 after failure

class RetryJob:
    def run(self):
        past_due = db.query("SELECT * FROM subscriptions WHERE status='PAST_DUE'")
        for sub in past_due:
            open_invoice = get_open_invoice(sub.subscription_id)
            days_past_due = (datetime.utcnow() - open_invoice.due_date).days
            if days_past_due in RETRY_SCHEDULE_DAYS:
                result = payment_gateway.charge(
                    sub.payment_method_id,
                    open_invoice.amount_cents,
                    idempotency_key=f"retry_{open_invoice.invoice_id}_day{days_past_due}"
                )
                if result.success:
                    open_invoice.status = 'PAID'
                    transition(sub, 'ACTIVE')
                    self._advance_period(sub)
            elif days_past_due > max(RETRY_SCHEDULE_DAYS):
                transition(sub, 'CANCELLED')
                open_invoice.status = 'UNCOLLECTIBLE'
                notify_user_cancellation(sub)

Coupon Application

def apply_coupon(invoice, coupon):
    if coupon.expires_at and datetime.utcnow() > coupon.expires_at:
        raise ValueError("Coupon expired")
    if coupon.max_redemptions and coupon.redemptions >= coupon.max_redemptions:
        raise ValueError("Coupon fully redeemed")
    if coupon.discount_type == 'percent':
        discount = int(invoice.amount_cents * coupon.discount_value / 100)
    else:
        discount = min(coupon.discount_value, invoice.amount_cents)
    invoice.discount_cents = discount
    invoice.amount_cents -= discount
    coupon.redemptions += 1
    db.save(coupon)
    return invoice

Key Design Decisions

  • Idempotency: the idempotency key bill_{subscription_id}_{period_end} prevents double-charging if the billing job runs twice (e.g., after a crash mid-cycle). Always check if an invoice already exists for the period before charging.
  • Webhooks from Stripe: Stripe sends invoice.paid, invoice.payment_failed events. Process these instead of polling. Use idempotency on webhook handling — Stripe retries on non-200 responses.
  • Trial → Active: when trial_end passes, the billing engine creates the first real invoice. If the user has no payment method, transition to PAST_DUE immediately.
  • Dunning: the retry schedule (3, 5, 7 days) plus user notifications is called dunning. Best practice: email user on day 1, 3, and 7; cancel on day 14.
  • Metered billing: for usage-based plans, track usage events throughout the period (in a counters table or Redis), then sum at billing time and add as a line item.

Interview Questions

Q: How do you prevent charging a customer twice if the billing job runs twice?

Use an idempotency key tied to the subscription ID and period end date. Before charging, query for an existing invoice for the same subscription + period. Use a unique constraint on (subscription_id, period_end) in the invoices table — a duplicate insert raises an error, preventing a second charge. Additionally, use the payment gateway’s idempotency key so even if two charges are attempted, the gateway returns the first result.

Q: How would you scale this to 10 million subscribers?

Shard the billing job by subscription_id mod N. Each shard independently queries and bills its subscriptions. Use a message queue (Kafka): the billing engine emits a “bill_due” event per subscription; worker pool consumers charge and update status. Redis tracks which subscriptions are in-flight to prevent duplicate processing. Invoices are write-heavy at billing cycle starts — use a write buffer (batch inserts) and avoid single-row updates in a hot loop.

Asked at: Stripe Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Asked at: Atlassian Interview Guide

Asked at: Coinbase Interview Guide

Scroll to Top