Low-Level Design: Subscription Service — Plan Management, Billing, Dunning, and Entitlements

Core Entities

Product: product_id, name, description, type (SAAS, MEDIA, PHYSICAL). Plan: plan_id, product_id, name, billing_period (MONTHLY, QUARTERLY, ANNUAL), price, currency, trial_days, features (JSON: {api_calls_per_month, seats, storage_gb}), is_active. Subscription: subscription_id, customer_id, plan_id, status (TRIALING, ACTIVE, PAST_DUE, CANCELLED, EXPIRED, PAUSED), current_period_start, current_period_end, trial_start, trial_end, cancel_at_period_end, cancelled_at, payment_method_id. Invoice: invoice_id, subscription_id, customer_id, period_start, period_end, amount, currency, status (DRAFT, OPEN, PAID, VOID, UNCOLLECTIBLE), due_date, paid_at, payment_attempt_count. PaymentAttempt: attempt_id, invoice_id, payment_method_id, amount, status (SUCCEEDED, FAILED, PENDING), gateway_response, attempted_at. Entitlement: entitlement_id, customer_id, feature_key, value (JSON), valid_from, valid_until, source_subscription_id.

Billing Engine

class BillingEngine:
    def process_renewal(self, subscription_id: int):
        sub = self.repo.get_subscription(subscription_id)
        if sub.status not in ('ACTIVE', 'PAST_DUE'):
            return

        # Create invoice for upcoming period
        invoice = Invoice(
            subscription_id=sub.subscription_id,
            customer_id=sub.customer_id,
            period_start=sub.current_period_end,
            period_end=self._next_period_end(sub),
            amount=self._calculate_amount(sub),
            status=InvoiceStatus.OPEN,
            due_date=sub.current_period_end
        )
        self.repo.save(invoice)

        result = self._charge(invoice, sub.payment_method_id)
        if result.succeeded:
            invoice.status = InvoiceStatus.PAID
            invoice.paid_at = datetime.utcnow()
            sub.status = SubscriptionStatus.ACTIVE
            sub.current_period_start = invoice.period_start
            sub.current_period_end = invoice.period_end
            self._provision_entitlements(sub)
        else:
            invoice.payment_attempt_count += 1
            sub.status = SubscriptionStatus.PAST_DUE
            self._schedule_retry(invoice)

        self.repo.save(invoice)
        self.repo.save(sub)
        self.events.publish('invoice.' + invoice.status.value, invoice)

    def _schedule_retry(self, invoice: Invoice):
        # Smart dunning: retry on days 1, 3, 7, 14 after initial failure
        RETRY_DAYS = [1, 3, 7, 14]
        attempt = invoice.payment_attempt_count
        if attempt <= len(RETRY_DAYS):
            retry_at = datetime.utcnow() + timedelta(days=RETRY_DAYS[attempt-1])
            self.scheduler.schedule('retry_invoice', invoice.invoice_id, retry_at)
        else:
            invoice.status = InvoiceStatus.UNCOLLECTIBLE
            self.repo.get_subscription(invoice.subscription_id).status = SubscriptionStatus.CANCELLED
            self._revoke_entitlements(invoice.subscription_id)

Entitlement System

Entitlements define what a customer can access based on their subscription. Feature keys: api_calls_per_month, seats, storage_gb, feature_x_enabled. When a subscription is activated (TRIALING, ACTIVE): create Entitlement records with valid_from = now, valid_until = period_end. When subscription is cancelled or expires: set valid_until = now (revoke immediately) or valid_until = period_end (grace period). Entitlement check: before an API call or feature access, check the customer’s active entitlements. SELECT value FROM entitlements WHERE customer_id=? AND feature_key=? AND valid_from = now(). Cache in Redis with TTL matching the period_end for fast checks without DB hits. Usage metering: for quota-based features (api_calls_per_month), increment a Redis counter on each API call: INCR usage:{customer_id}:{feature_key}:{year_month}. Compare against the entitlement value before allowing access. Reset counter at the start of each billing period. Overage billing: if usage exceeds the plan quota, record overage units. Apply overage rate at invoice generation (price per 1000 calls over the limit). Added as a line item on the next invoice.

Plan Changes, Trials, and Cancellation

Upgrade (BASIC → PRO mid-cycle): (1) Calculate unused days remaining in the current period. (2) Credit the customer for unused days on the old plan (proration credit). (3) Charge for the remaining days on the new plan. (4) Update the subscription to the new plan. (5) Provision new entitlements immediately. Downgrade: takes effect at the next billing cycle (don’t remove features mid-period). Set a scheduled plan change record; apply on next renewal. Trial: subscription.status = TRIALING during trial_start to trial_end. No invoice generated. On trial end: generate first invoice, attempt charge. If payment fails: subscription moves to PAST_DUE (dunning starts). Some products send trial reminder emails at 7, 3, and 1 day before trial end. Cancellation options: cancel_at_period_end=true: subscription remains active until period_end, then expires. No refund. Immediate cancellation: revoke entitlements now, issue pro-rated refund for unused days. Pause: set status=PAUSED, stop billing, preserve data. Resume: restart billing from next period, no charge for paused period (configurable policy).

See also: Shopify Interview Prep

See also: Stripe Interview Prep

See also: Netflix Interview Prep

See also: Anthropic Interview Guide 2026: Process, Questions, and AI Safety

Scroll to Top