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¶
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¶
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¶
2. Billing Cycle Processing¶
3. Plan Change (Proration)¶
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 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¶
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¶
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¶
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¶
- How do you handle timezone complexity?
-
Bill at customer's local midnight, use billing_cycle_anchor
-
How do you ensure no duplicate charges?
-
Idempotency keys, atomic updates, deduplication in invoice generation
-
How do you handle plan changes?
-
Proration strategies, effective date handling
-
What if billing job fails mid-run?
-
Idempotent operations, checkpointing, dead letter queue
-
How do you scale for millions of subscriptions?
-
Distributed workers, partitioned queues, database sharding
-
How do you handle refunds?
- Credit notes, prorated refunds, wallet balance