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_failedevents. Process these instead of polling. Use idempotency on webhook handling — Stripe retries on non-200 responses. - Trial → Active: when
trial_endpasses, 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