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


{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”What is a dunning strategy for failed subscription payments?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Dunning is the process of automatically retrying failed payments on a schedule. A common smart dunning schedule: retry on days 1, 3, 7, and 14 after the initial failure. Earlier retries catch temporary failures (insufficient funds on payday); later retries give customers time to update payment methods. After all retries fail, mark the invoice as UNCOLLECTIBLE and cancel the subscription. Send email notifications at each retry failure to prompt the customer to update their payment method.”}},{“@type”:”Question”,”name”:”How does proration work when a customer upgrades mid-billing-cycle?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Calculate the unused days remaining in the current period. Issue a credit for the unused portion of the old plan (unused_days / total_days * old_plan_price). Charge immediately for the remaining days on the new plan (unused_days / total_days * new_plan_price). Net charge = new_charge – credit. The new plan's features are provisioned immediately. At the next billing cycle, the customer is charged the full new plan price.”}},{“@type”:”Question”,”name”:”How do entitlements ensure feature access is accurate and fast?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”On subscription activation, create Entitlement records in the database (feature_key, value, valid_from, valid_until). Cache them in Redis with a key like ent:{customer_id}:{feature_key} and TTL = time until valid_until. Feature access checks hit Redis first (< 1ms). On cache miss, fall back to the database. When a subscription changes or expires, invalidate the relevant Redis keys immediately. This keeps feature gating fast without database round-trips on every API call.”}},{“@type”:”Question”,”name”:”What is the cancel_at_period_end flag and how does it work?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Setting cancel_at_period_end=true marks a subscription for cancellation at the end of the current billing period without affecting current access. The subscription remains ACTIVE and the customer retains full access until current_period_end. No further invoices are generated after the period end. On period end: status transitions to CANCELLED, entitlements are revoked. This is the "cancel anytime, no refund" model — customers who cancel mid-period still get the rest of their paid period.”}},{“@type”:”Question”,”name”:”How do you handle usage-based billing (metered) alongside flat subscription pricing?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Track usage in Redis counters: INCR usage:{customer_id}:{feature}:{period} on each consumed unit. At invoice generation time, read the final usage count from Redis (with a database fallback for accuracy), compare against the plan's included quota, and calculate overage: (usage – quota) * overage_rate. Add overage as a line item on the invoice. After invoicing, reset or archive the counter for the next period. Real-time usage display: the same Redis counter is exposed via API for customers to monitor their consumption.”}}]}

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