Skip to content

Subscription Billing System

Problem Statement

Design a subscription billing system that handles recurring payments, plan management, trials, proration, and dunning (failed payment recovery). Think Stripe Billing, Chargebee, or Recurly.


Requirements

Functional Requirements

  • Create and manage subscription plans (pricing, intervals, features)
  • Handle customer subscriptions (create, upgrade, downgrade, cancel)
  • Support trial periods
  • Prorate charges on plan changes
  • Generate invoices automatically
  • Process recurring payments
  • Handle failed payments with retry logic (dunning)
  • Support coupons and discounts
  • Usage-based billing (metered billing)
  • Provide billing portal for customers

Non-Functional Requirements

  • Reliability: Billing must be accurate (money involved)
  • Consistency: No duplicate charges, no missed charges
  • Scalability: Handle millions of subscriptions
  • Auditability: Complete billing history
  • Flexibility: Support various billing models

High-Level Architecture

Subscription Billing System - High-Level Architecture


Core Components

1. Plan Service

  • Define pricing tiers and features
  • Support multiple billing intervals (monthly, yearly)
  • Handle plan versioning
  • Manage feature flags per plan

2. Subscription Service

  • Manage subscription lifecycle
  • Handle plan changes (upgrade/downgrade)
  • Calculate proration
  • Track subscription status

3. Invoice Service

  • Generate invoices on billing cycle
  • Apply discounts and credits
  • Calculate taxes
  • Track payment status

4. Billing Job (Scheduler)

  • Run daily to find due subscriptions
  • Generate invoices
  • Trigger payment processing
  • Handle timezone considerations

5. Dunning Service

  • Retry failed payments
  • Send payment failure notifications
  • Implement retry schedule
  • Handle subscription suspension/cancellation

6. Usage Aggregator

  • Collect usage events
  • Aggregate for metered billing
  • Calculate usage-based charges

Subscription Lifecycle

Subscription States


Data Models

Plan

CREATE TABLE plans (
    id                  UUID PRIMARY KEY,
    name                VARCHAR(255) NOT NULL,
    description         TEXT,

    -- Pricing
    amount              BIGINT NOT NULL,        -- In cents
    currency            VARCHAR(3) NOT NULL,
    interval            VARCHAR(20) NOT NULL,   -- day, week, month, year
    interval_count      INT DEFAULT 1,          -- e.g., 2 for bi-weekly

    -- Trial
    trial_period_days   INT DEFAULT 0,

    -- Features
    features            JSONB,

    -- Metadata
    active              BOOLEAN DEFAULT true,
    created_at          TIMESTAMP NOT NULL,
    updated_at          TIMESTAMP NOT NULL
);

-- Plan versioning for existing subscriptions
CREATE TABLE plan_versions (
    id                  UUID PRIMARY KEY,
    plan_id             UUID NOT NULL,
    version             INT NOT NULL,
    amount              BIGINT NOT NULL,
    currency            VARCHAR(3) NOT NULL,
    effective_from      TIMESTAMP NOT NULL,

    FOREIGN KEY (plan_id) REFERENCES plans(id),
    UNIQUE(plan_id, version)
);

Subscription

CREATE TABLE subscriptions (
    id                      UUID PRIMARY KEY,
    customer_id             UUID NOT NULL,
    plan_id                 UUID NOT NULL,
    plan_version            INT NOT NULL,

    -- Status
    status                  VARCHAR(20) NOT NULL,   -- trialing, active, past_due, canceled, paused

    -- Billing cycle
    current_period_start    TIMESTAMP NOT NULL,
    current_period_end      TIMESTAMP NOT NULL,
    billing_cycle_anchor    TIMESTAMP NOT NULL,     -- When billing cycle starts

    -- Trial
    trial_start             TIMESTAMP,
    trial_end               TIMESTAMP,

    -- Cancellation
    cancel_at_period_end    BOOLEAN DEFAULT false,
    canceled_at             TIMESTAMP,
    ended_at                TIMESTAMP,

    -- Payment
    default_payment_method  UUID,

    -- Metadata
    metadata                JSONB,
    created_at              TIMESTAMP NOT NULL,
    updated_at              TIMESTAMP NOT NULL,

    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (plan_id) REFERENCES plans(id)
);

CREATE INDEX idx_subscriptions_customer ON subscriptions(customer_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);

Invoice

CREATE TABLE invoices (
    id                  UUID PRIMARY KEY,
    customer_id         UUID NOT NULL,
    subscription_id     UUID,

    -- Invoice details
    number              VARCHAR(50) UNIQUE,
    status              VARCHAR(20) NOT NULL,   -- draft, open, paid, void, uncollectible

    -- Amounts
    subtotal            BIGINT NOT NULL,
    tax                 BIGINT DEFAULT 0,
    discount            BIGINT DEFAULT 0,
    total               BIGINT NOT NULL,
    amount_paid         BIGINT DEFAULT 0,
    amount_due          BIGINT NOT NULL,
    currency            VARCHAR(3) NOT NULL,

    -- Period
    period_start        TIMESTAMP,
    period_end          TIMESTAMP,

    -- Payment
    due_date            TIMESTAMP,
    paid_at             TIMESTAMP,
    payment_intent_id   UUID,

    -- Dunning
    attempt_count       INT DEFAULT 0,
    next_payment_attempt TIMESTAMP,

    -- Timestamps
    created_at          TIMESTAMP NOT NULL,
    finalized_at        TIMESTAMP,

    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (subscription_id) REFERENCES subscriptions(id)
);

CREATE TABLE invoice_line_items (
    id                  UUID PRIMARY KEY,
    invoice_id          UUID NOT NULL,
    subscription_id     UUID,

    -- Line item details
    description         TEXT NOT NULL,
    quantity            INT DEFAULT 1,
    unit_amount         BIGINT NOT NULL,
    amount              BIGINT NOT NULL,
    currency            VARCHAR(3) NOT NULL,

    -- Period (for prorations)
    period_start        TIMESTAMP,
    period_end          TIMESTAMP,

    -- Type
    type                VARCHAR(20) NOT NULL,   -- subscription, usage, one_time, proration
    proration           BOOLEAN DEFAULT false,

    FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);

Key Flows

1. Subscription Creation

Subscription Creation Flow

2. Billing Cycle Processing

Billing Cycle Processing

3. Plan Change (Proration)

Proration Calculation

public class ProrationCalculator {

    public ProrationResult calculate(
            Subscription subscription,
            Plan oldPlan,
            Plan newPlan,
            Instant changeTime
    ) {
        // Calculate remaining days in period
        Duration periodLength = Duration.between(
            subscription.getCurrentPeriodStart(),
            subscription.getCurrentPeriodEnd()
        );
        Duration usedTime = Duration.between(
            subscription.getCurrentPeriodStart(),
            changeTime
        );
        Duration remainingTime = periodLength.minus(usedTime);

        double usedRatio = (double) usedTime.toDays() / periodLength.toDays();
        double remainingRatio = 1.0 - usedRatio;

        // Credit for unused old plan
        long credit = Math.round(oldPlan.getAmount() * remainingRatio);

        // Charge for remaining new plan
        long charge = Math.round(newPlan.getAmount() * remainingRatio);

        return new ProrationResult(credit, charge, charge - credit);
    }
}

Dunning (Failed Payment Recovery)

Retry Schedule

Dunning Schedule

Dunning Configuration

CREATE TABLE dunning_schedules (
    id                  UUID PRIMARY KEY,
    merchant_id         UUID,

    -- Retry attempts
    retry_intervals     INT[] NOT NULL,     -- [3, 2, 2, 3] = retry on day 3, 5, 7, 10
    max_attempts        INT NOT NULL,

    -- Actions
    cancel_after_failure BOOLEAN DEFAULT true,
    notify_customer     BOOLEAN DEFAULT true,

    created_at          TIMESTAMP NOT NULL
);

Smart Retry

public class SmartDunning {

    // Retry at optimal times based on historical success
    public Instant calculateNextRetryTime(Invoice invoice) {
        // Don't retry on weekends
        Instant nextRetry = invoice.getDueDate()
            .plus(dunningSchedule.getRetryInterval(invoice.getAttemptCount()));

        while (isWeekend(nextRetry)) {
            nextRetry = nextRetry.plus(Duration.ofDays(1));
        }

        // Retry at time of day with highest success rate
        // (typically morning, when daily limits reset)
        return nextRetry.atZone(customerTimezone)
            .withHour(9)
            .withMinute(0)
            .toInstant();
    }

    // Update card details prompts
    public void handleFailure(Invoice invoice, PaymentError error) {
        if (error.isCardExpired() || error.isInvalidCard()) {
            sendCardUpdateRequest(invoice.getCustomer());
        } else if (error.isInsufficientFunds()) {
            // Retry later with smaller amount option
            offerPartialPayment(invoice);
        }
    }
}

Usage-Based Billing

Event Collection

Usage Event Collection

Aggregation

-- Pre-aggregated usage per hour
CREATE TABLE usage_aggregates (
    id                  UUID PRIMARY KEY,
    subscription_id     UUID NOT NULL,
    metric              VARCHAR(50) NOT NULL,
    hour                TIMESTAMP NOT NULL,     -- Truncated to hour
    quantity            BIGINT NOT NULL,

    UNIQUE(subscription_id, metric, hour)
);

-- Query for billing period
SELECT
    metric,
    SUM(quantity) as total_quantity
FROM usage_aggregates
WHERE subscription_id = ?
AND hour >= period_start
AND hour < period_end
GROUP BY metric;

Pricing Models

Usage-Based Pricing Models


Billing Schedule

Daily Billing Job

@Scheduled(cron = "0 0 6 * * *")  // Run at 6 AM daily
public void processBillingCycle() {
    // Process in batches by timezone to bill at local midnight
    for (String timezone : timezones) {
        LocalDate billingDate = LocalDate.now(ZoneId.of(timezone));

        // Find subscriptions due for billing
        List<Subscription> dueSubscriptions = subscriptionRepository
            .findByCurrentPeriodEndBeforeAndStatus(
                billingDate.atStartOfDay(ZoneId.of(timezone)).toInstant(),
                SubscriptionStatus.ACTIVE
            );

        for (Subscription sub : dueSubscriptions) {
            try {
                processSubscription(sub);
            } catch (Exception e) {
                // Log and continue with next subscription
                alerting.notify("Billing failed for " + sub.getId(), e);
            }
        }
    }
}

private void processSubscription(Subscription subscription) {
    // 1. Generate invoice
    Invoice invoice = invoiceService.generate(subscription);

    // 2. Attempt payment
    PaymentResult result = paymentGateway.charge(
        subscription.getCustomer(),
        invoice.getAmountDue()
    );

    if (result.isSuccess()) {
        // 3a. Mark paid and advance period
        invoiceService.markPaid(invoice, result);
        subscriptionService.advancePeriod(subscription);
        webhookService.send(subscription.getCustomer(), "invoice.paid", invoice);
    } else {
        // 3b. Start dunning process
        invoiceService.markFailed(invoice, result);
        subscriptionService.markPastDue(subscription);
        dunningService.schedule(invoice);
        webhookService.send(subscription.getCustomer(), "invoice.payment_failed", invoice);
    }
}

Scalability Considerations

Partitioning Strategy

Sharding Strategy

Billing Job Distribution

Billing Job Distribution


Technology Choices

Component Technology Options
API REST, GraphQL
Primary Database PostgreSQL, MySQL
Queue Kafka, SQS, RabbitMQ
Scheduler Kubernetes CronJob, Temporal, Quartz
Cache Redis
Payment Gateway Stripe, Braintree, Adyen
Monitoring Datadog, Prometheus

Interview Discussion Points

  1. How do you handle timezone complexity?
  2. Bill at customer's local midnight, use billing_cycle_anchor

  3. How do you ensure no duplicate charges?

  4. Idempotency keys, atomic updates, deduplication in invoice generation

  5. How do you handle plan changes?

  6. Proration strategies, effective date handling

  7. What if billing job fails mid-run?

  8. Idempotent operations, checkpointing, dead letter queue

  9. How do you scale for millions of subscriptions?

  10. Distributed workers, partitioned queues, database sharding

  11. How do you handle refunds?

  12. Credit notes, prorated refunds, wallet balance