System Design Interview: Design a Subscription Billing System

What Is a Subscription Billing System?

A subscription billing system automates recurring charges for SaaS products, streaming services, and membership platforms. It handles plan management, trial periods, proration, dunning (failed payment retries), and invoicing. Examples: Stripe Billing, Recurly, Chargebee. Core challenges: idempotent charge processing, clock-based subscription state machines, and handling partial billing periods.

  • Airbnb Interview Guide
  • Coinbase Interview Guide
  • Atlassian Interview Guide
  • Netflix Interview Guide
  • Shopify Interview Guide
  • Stripe Interview Guide
  • System Requirements

    Functional

    • Create/upgrade/downgrade/cancel subscriptions
    • Charge on billing cycle (monthly/annual)
    • Prorate mid-cycle plan changes
    • Trial periods with automatic conversion
    • Dunning: retry failed payments 1, 3, 7 days after failure
    • Generate invoices and send receipts

    Non-Functional

    • Exactly-once charge guarantee (never double-charge)
    • 100M subscriptions, 5M daily billing events
    • 99.99% uptime during billing cycles

    Core Data Model

    plans: id, name, price_cents, interval(month/year), trial_days, features
    subscriptions: id, customer_id, plan_id, status, current_period_start,
                   current_period_end, trial_end, cancel_at_period_end
    invoices: id, subscription_id, amount_due, amount_paid, status, due_date
    invoice_items: id, invoice_id, description, amount, period_start, period_end
    payment_methods: id, customer_id, stripe_pm_id, is_default
    charges: id, invoice_id, amount, status, idempotency_key, created_at
    

    Subscription State Machine

    trialing ──trial_end──► active ──payment_fail──► past_due ──retries_exhausted──► canceled
                 │                                                                        │
                 └──cancel_now──────────────────────────────────────────────────────► canceled
    active ──cancel_at_period_end──► (stays active until period_end) ──► canceled
    active ──upgrade──► active (new plan, prorate immediately)
    

    Billing Engine: The Core Loop

    A daily cron job queries subscriptions where current_period_end <= NOW() and status = active. For each:

    1. Create invoice with period start/end
    2. Add invoice items (subscription fee, usage charges)
    3. Attempt charge with idempotency_key = “invoice_” + invoice_id
    4. If success: advance current_period_start/end, mark subscription active
    5. If failure: set status = past_due, schedule dunning retries

    Idempotent Charges

    Every charge to the payment processor includes an idempotency key. Stripe: pass Idempotency-Key header. If the network times out and you retry, Stripe detects the duplicate key and returns the original charge result without double-charging. The idempotency key should encode invoice ID (or subscription ID + billing period) — something deterministic that is stable across retries.

    response = stripe.charge.create(
        amount=amount_cents,
        customer=stripe_customer_id,
        idempotency_key=f"charge-inv-{invoice_id}"
    )
    

    Proration

    When a user upgrades mid-cycle from Plan A ($10/month) to Plan B ($20/month) on day 15 of 30: credit remaining Plan A = $10 * (15/30) = $5. Charge Plan B for remaining period = $20 * (15/30) = $10. Net charge = $5. Add these as invoice_items: one credit item (negative) and one charge item (positive). This shows the full math on the invoice while netting to the correct amount.

    Dunning — Failed Payment Recovery

    Dunning is the process of retrying failed payments and notifying customers. Schedule:

    • Day 0: payment fails → mark past_due, send “payment failed” email
    • Day 1: retry charge, send reminder if still failed
    • Day 3: retry, escalate email
    • Day 7: final retry, send “subscription will be canceled in 3 days”
    • Day 10: cancel subscription, send cancellation confirmation

    Use a dunning_attempts table to track retry count and next_retry_at. A separate dunning worker queries for past_due subscriptions where next_retry_at <= NOW() and retries them.

    Scaling Billing

    • Spread billing events across the day to avoid midnight spike — add jitter to current_period_end by up to 4 hours
    • Partition subscriptions table by billing_anchor_day for efficient batch queries
    • Use async queues (SQS/Kafka) for invoice generation and email sending — decouple from charge processing

    Interview Tips

    • Idempotency key is the single most important reliability concept — say it first.
    • Draw the state machine before writing any DB schema.
    • Proration formula: credit = plan_price * (days_remaining / days_in_period).
    • Dunning shows you understand the full product, not just the happy path.

    {
    “@context”: “https://schema.org”,
    “@type”: “FAQPage”,
    “mainEntity”: [
    {
    “@type”: “Question”,
    “name”: “How do you guarantee exactly-once charging in a subscription billing system?”,
    “acceptedAnswer”: { “@type”: “Answer”, “text”: “Exactly-once charging relies on idempotency keys passed to the payment processor. Every charge attempt includes a deterministic idempotency key derived from the invoice ID: "charge-inv-{invoice_id}". Payment processors like Stripe store the result associated with that key for 24 hours. If the network times out and you retry, Stripe detects the duplicate key and returns the original charge result without executing a second charge. On your database side: use a unique constraint on (invoice_id, status=success) so that even if the webhook fires twice confirming payment, only one row is written. Store the charge response (charge_id, amount, created_at) in your charges table keyed by idempotency_key to detect and surface duplicates. This combination — idempotency key to the payment processor, unique constraint in your DB, and webhook deduplication — achieves exactly-once semantics end-to-end.” }
    },
    {
    “@type”: “Question”,
    “name”: “How does proration work when a customer upgrades mid-billing cycle?”,
    “acceptedAnswer”: { “@type”: “Answer”, “text”: “Proration credits the unused portion of the current plan and charges for the new plan for the remaining period. Formula: days_remaining = billing_period_end – today; days_in_period = billing_period_end – billing_period_start. Credit = old_plan_price * (days_remaining / days_in_period). Charge = new_plan_price * (days_remaining / days_in_period). Net = Charge – Credit. These become invoice line items: one negative item (credit for old plan) and one positive item (charge for new plan). The invoice total is the net amount. On the database: immediately update the subscription record to the new plan_id and schedule the next full billing at current_period_end. At the next renewal, charge the full new plan price. For annual plans: same formula but days_in_period is 365. Edge case: if the customer downgrades, the credit exceeds the charge — apply as a balance credit toward the next invoice rather than issuing a refund.” }
    },
    {
    “@type”: “Question”,
    “name”: “How does a dunning system recover failed subscription payments?”,
    “acceptedAnswer”: { “@type”: “Answer”, “text”: “Dunning is a structured retry-and-notify workflow for failed payments. When an initial charge fails (card declined, insufficient funds, expired card), the subscription moves to past_due status. A dunning scheduler (separate worker process) reads dunning_attempts where next_retry_at <= NOW(). Retry schedule example: Day 1 (immediate notification + retry in 24h), Day 3 (retry + reminder email), Day 7 (retry + urgent warning that subscription will cancel), Day 10 (final cancellation). Each retry attempt uses the same idempotency key structure but includes the attempt number: "charge-inv-{invoice_id}-attempt-{N}". After each failed retry, increment attempt_count and set next_retry_at. After max attempts: set subscription status = canceled, send cancellation confirmation. Smart dunning improvements: (1) use card updater service (Visa Account Updater) to auto-update expired card numbers before retrying; (2) retry on the day of month when the customer typically has funds (payday); (3) send in-app prompts to update payment method.” }
    }
    ]
    }

    Scroll to Top