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.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you prevent double-charging a customer in a subscription billing system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Use a compound idempotency key: {subscription_id}_{period_end_timestamp}. Before creating a charge, query the invoices table for an existing invoice with the same subscription_id and period_end. Use a unique database constraint on (subscription_id, period_end) u2014 a duplicate insert raises an error, preventing a second invoice. Also pass the idempotency key to the payment gateway (Stripe supports this natively) u2014 the gateway returns the result of the first charge on any retry with the same key. This makes the entire billing path safe to run multiple times.”
}
},
{
“@type”: “Question”,
“name”: “How does proration work when a user upgrades their subscription plan?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Proration gives credit for the unused portion of the old plan and charges for the remaining time on the new plan. Formula: period_remaining_fraction = (period_end – now) / (period_end – period_start). Credit = old_price * period_remaining_fraction. New charge = new_price * period_remaining_fraction. Proration amount = new_charge – credit. If positive, charge immediately (upgrade). If negative, apply as account credit for the next invoice (downgrade). This is fair to both the user and the business, and matches how Stripe, Chargebee, and Recurly handle plan changes.”
}
},
{
“@type”: “Question”,
“name”: “What is dunning and how does retry logic work in billing systems?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Dunning is the process of retrying failed payments and notifying customers to recover revenue. Standard retry schedule: attempt on day 0 (original failure), day 3, day 5, day 7. Each retry uses a unique idempotency key (e.g., retry_{invoice_id}_day3) to avoid duplicate charges. Between retries, email the customer to update their payment method. After the final retry (typically day 7-14), cancel the subscription and mark the invoice as UNCOLLECTIBLE. Smart dunning varies retry timing based on failure reason: retry “insufficient funds” on payday (1st/15th); for “card expired,” prompt the user to update immediately.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle subscription trials and the trial-to-paid conversion?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “At signup, set trial_end = now + trial_days. The subscription status is TRIALING. The billing engine checks daily for subscriptions where trial_end <= now. On trial expiry: if the user has a valid payment method, create the first invoice and charge it u2014 on success, transition to ACTIVE. If no payment method or charge fails, transition to PAST_DUE or CANCELLED depending on business policy. Never charge during a trial period u2014 check status before billing. For credit card trials (capture card at signup but don't charge), use gateway authorize-only, then capture at trial end."
}
},
{
"@type": "Question",
"name": "How would you implement usage-based (metered) billing?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Track usage events throughout the billing period. Each event: {subscription_id, metric, quantity, timestamp}. Store in a fast write store u2014 append to a usage_events table or increment a Redis counter (INCRBY subscription:{id}:metric quantity). At billing time, aggregate total usage for the period: SELECT SUM(quantity) FROM usage_events WHERE subscription_id=X AND period_start <= timestamp < period_end. Apply pricing tiers: first 1000 units free, 1001-10000 at $0.01/unit, above 10000 at $0.005/unit. Add the usage line item to the invoice before charging. Store the aggregated total for idempotency."
}
}
]
}

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