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.
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