System Design ·

Designing a Subscription Billing System: Plans, Usage Metering, Proration, and Dunning

Subscription billing looks like a solved problem until you hit your first proration edge case or dunning retry storm. This guide covers the full architecture: data model for plans, subscriptions, and invoices; usage-based metering; proration logic for mid-cycle plan changes; dunning strategies for failed payments; and Stripe integration patterns in TypeScript.

Designing a Subscription Billing System: Plans, Usage Metering, Proration, and Dunning

Subscription billing touches every part of your backend: the data model that tracks what each customer owes, the metering pipeline that counts usage events in real time, the proration logic that keeps charges accurate when a customer switches plans mid-cycle, and the dunning system that retries failed payments without annoying customers into churning. Each of these is a separate design problem, and most teams underestimate at least two of them.

The failure modes are predictable. Teams skip a proper data model and store subscription state as a flat JSON blob on the user record, which breaks the moment they need to support multiple plans, trial periods, or invoices with line items. Usage metering is treated as an afterthought until a customer disputes a charge and there is no audit trail. Proration is hardcoded as a simple formula that silently produces wrong amounts for billing cycles that span month boundaries. Dunning is a single retry one day after failure, which recovers far fewer failed payments than a properly sequenced strategy.

This article covers the full architecture of a production subscription billing system. The code examples use Stripe as the payment provider, but the data model and logic apply regardless of provider.


The Data Model

The foundation of a billing system is a normalized data model. Resist the temptation to collapse plans, subscriptions, and invoices into a single table with nullable columns. Each entity has different cardinality, update frequency, and query patterns.

// Plans define what can be purchased, not what is purchased.
interface Plan {
  id: string;
  name: string;
  currency: string;
  billingInterval: "monthly" | "annual";
  // null for flat-rate plans, non-null for usage-based
  usageType: "licensed" | "metered" | null;
  baseAmountCents: number;
  // for metered plans: price per unit above the included quantity
  overageAmountCents: number | null;
  includedQuantity: number | null;
  trialDays: number;
  active: boolean;
  stripePriceId: string;
}

// Subscriptions bind a customer to a plan for a period.
interface Subscription {
  id: string;
  customerId: string;
  planId: string;
  status: SubscriptionStatus;
  currentPeriodStart: Date;
  currentPeriodEnd: Date;
  trialStart: Date | null;
  trialEnd: Date | null;
  cancelAtPeriodEnd: boolean;
  canceledAt: Date | null;
  stripeSubscriptionId: string;
  paymentMethodId: string;
  createdAt: Date;
  updatedAt: Date;
}

type SubscriptionStatus =
  | "trialing"
  | "active"
  | "past_due"
  | "canceled"
  | "unpaid"
  | "incomplete"
  | "paused";

// Invoices represent a billing event and its outcome.
interface Invoice {
  id: string;
  subscriptionId: string;
  customerId: string;
  status: InvoiceStatus;
  currency: string;
  subtotalCents: number;
  taxCents: number;
  totalCents: number;
  amountDueCents: number;
  amountPaidCents: number;
  periodStart: Date;
  periodEnd: Date;
  dueDate: Date;
  paidAt: Date | null;
  stripeInvoiceId: string;
  createdAt: Date;
}

type InvoiceStatus = "draft" | "open" | "paid" | "void" | "uncollectible";

// Line items let you explain exactly what is on an invoice.
interface InvoiceLineItem {
  id: string;
  invoiceId: string;
  description: string;
  quantity: number;
  unitAmountCents: number;
  totalCents: number;
  type: "subscription" | "proration" | "usage" | "credit" | "tax";
  periodStart: Date;
  periodEnd: Date;
}

The InvoiceLineItem.type field is where the clarity comes from. When a customer upgrades mid-cycle, the resulting invoice has both a proration credit for the unused portion of the old plan and a proration charge for the remaining days on the new plan. Modeling these as separate line items with distinct types makes it possible to explain any invoice to a customer or to dispute resolution.

One design decision that bites teams later: keeping your own invoice and subscription records, not just Stripe’s. Stripe’s data is authoritative for payment state, but your database is authoritative for product-level state. If you add a custom feature (team seats, add-on modules, partner discounts), your internal model can accommodate it without requiring a matching Stripe price object for every variation.


Usage Metering and Aggregation

Usage-based billing requires a metering pipeline that is separate from your main application database. The write path for usage events is high-throughput and append-only. The read path is aggregation at invoice time.

The architecture that holds up in production:

Application server
       |
       | emit UsageEvent (fire-and-forget, async)
       v
Message queue (SQS, Pub/Sub, Kafka)
       |
       v
Usage ingestor (worker)
       |
       v
Usage events table (insert-only, partitioned by date)
       |
       v
Aggregation job (runs at billing cycle close or on-demand)
       |
       v
Usage summary table (per subscription, per period)

The events table is append-only and partitioned by date. Never update a usage event. If a correction is needed, insert a compensating event with a negative quantity.

interface UsageEvent {
  id: string;
  subscriptionId: string;
  customerId: string;
  metric: string;         // "api_calls", "storage_gb", "active_seats"
  quantity: number;       // can be negative for corrections
  idempotencyKey: string; // deduplicate on ingest
  occurredAt: Date;
  ingestedAt: Date;
  metadata: Record<string, string>; // e.g., { endpoint: "/v1/completions" }
}

// Aggregation query (PostgreSQL example)
// Runs at invoice close time for the subscription's billing period.
async function aggregateUsage(
  subscriptionId: string,
  metric: string,
  periodStart: Date,
  periodEnd: Date
): Promise<number> {
  const result = await db.query<{ total: string }>(
    `SELECT COALESCE(SUM(quantity), 0)::text AS total
     FROM usage_events
     WHERE subscription_id = $1
       AND metric = $2
       AND occurred_at >= $3
       AND occurred_at < $4`,
    [subscriptionId, metric, periodStart, periodEnd]
  );
  return parseInt(result.rows[0].total, 10);
}

// Ingest with idempotency: skip if already seen.
async function ingestUsageEvent(event: UsageEvent): Promise<void> {
  await db.query(
    `INSERT INTO usage_events
       (id, subscription_id, customer_id, metric, quantity, idempotency_key, occurred_at, ingested_at, metadata)
     VALUES ($1,$2,$3,$4,$5,$6,$7,NOW(),$8)
     ON CONFLICT (idempotency_key) DO NOTHING`,
    [
      event.id,
      event.subscriptionId,
      event.customerId,
      event.metric,
      event.quantity,
      event.idempotencyKey,
      event.occurredAt,
      event.metadata,
    ]
  );
}

The idempotency_key unique constraint is the critical detail. Usage events are delivered at-least-once through the queue. Without deduplication at the ingest layer, any redelivery inflates your customers’ bills.

For plans with included quantities, compute overage at invoice time rather than during ingest:

async function computeOverageCharge(
  subscription: Subscription,
  plan: Plan,
  periodStart: Date,
  periodEnd: Date
): Promise<number> {
  if (!plan.includedQuantity || !plan.overageAmountCents) return 0;

  const totalUsage = await aggregateUsage(
    subscription.id,
    "api_calls",
    periodStart,
    periodEnd
  );

  const overage = Math.max(0, totalUsage - plan.includedQuantity);
  return overage * plan.overageAmountCents;
}

For high-volume systems, pre-aggregating into hourly or daily buckets reduces the cost of the final aggregation query. A background job that writes usage_daily_summaries rows from raw events gives you a fast read path without losing event-level detail for audits.


Proration Logic

Proration is the calculation that makes a mid-cycle plan change fair to both sides. The logic is simple in concept and wrong in practice unless you are careful about how days are counted.

When a customer upgrades from a $10/mo plan to a $30/mo plan on day 15 of a 31-day cycle:

  • Credit for unused days on old plan: (16 / 31) * $10 = $5.16
  • Charge for remaining days on new plan: (16 / 31) * $30 = $15.48
  • Net additional charge: $15.48 - $5.16 = $10.32

The edge cases that break naive implementations:

  1. Monthly billing where months have different lengths. Using 30 as a constant instead of the actual days in the billing period produces wrong amounts.
  2. Annual billing with mid-cycle changes. The proration period can span multiple months with different day counts.
  3. Multiple plan changes in one cycle. Each change generates a new proration pair; credits from earlier changes must be applied before computing the next charge.
  4. Free trial periods. The proration credit for days on a trial plan is zero, since the customer was not paying.
interface ProrationResult {
  creditAmountCents: number;     // credit for unused time on old plan
  chargeAmountCents: number;     // charge for remaining time on new plan
  netAmountCents: number;        // positive = customer owes more
  effectiveDate: Date;
  oldPlanId: string;
  newPlanId: string;
  daysRemaining: number;
  totalDaysInPeriod: number;
}

function calculateProration(
  oldPlan: Plan,
  newPlan: Plan,
  currentPeriodStart: Date,
  currentPeriodEnd: Date,
  changeDate: Date
): ProrationResult {
  const msPerDay = 86_400_000;
  const totalMs = currentPeriodEnd.getTime() - currentPeriodStart.getTime();
  const totalDays = Math.round(totalMs / msPerDay);

  const remainingMs = currentPeriodEnd.getTime() - changeDate.getTime();
  const daysRemaining = Math.round(remainingMs / msPerDay);

  const prorationFactor = daysRemaining / totalDays;

  // Only prorate the base amount; usage charges are computed separately.
  const creditAmountCents = Math.floor(oldPlan.baseAmountCents * prorationFactor);
  const chargeAmountCents = Math.ceil(newPlan.baseAmountCents * prorationFactor);
  const netAmountCents = chargeAmountCents - creditAmountCents;

  return {
    creditAmountCents,
    chargeAmountCents,
    netAmountCents,
    effectiveDate: changeDate,
    oldPlanId: oldPlan.id,
    newPlanId: newPlan.id,
    daysRemaining,
    totalDaysInPeriod: totalDays,
  };
}

The Math.floor / Math.ceil split matters. You want the credit to favor the customer slightly and the charge to round up. Applying Math.round to both, or Math.floor to both, produces amounts that don’t add up cleanly and creates rounding errors that accumulate across thousands of customers.

Stripe Proration Integration

When using Stripe Billing, let Stripe handle proration by setting proration_behavior on the subscription update call. Pull the preview invoice before confirming to show the customer what they will be charged:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

async function previewPlanChange(
  stripeSubscriptionId: string,
  newStripePriceId: string
): Promise<Stripe.Invoice> {
  const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId);

  // Retrieve the upcoming invoice with the proposed change applied.
  return stripe.invoices.retrieveUpcoming({
    customer: subscription.customer as string,
    subscription: stripeSubscriptionId,
    subscription_items: [
      {
        id: subscription.items.data[0].id,
        price: newStripePriceId,
      },
    ],
    subscription_proration_behavior: "create_prorations",
    subscription_proration_date: Math.floor(Date.now() / 1000),
  });
}

async function applyPlanChange(
  stripeSubscriptionId: string,
  newStripePriceId: string
): Promise<void> {
  const prorationDate = Math.floor(Date.now() / 1000);

  await stripe.subscriptions.update(stripeSubscriptionId, {
    items: [
      {
        id: (await stripe.subscriptions.retrieve(stripeSubscriptionId))
          .items.data[0].id,
        price: newStripePriceId,
      },
    ],
    proration_behavior: "create_prorations",
    proration_date: prorationDate,
    // Bill immediately for upgrades; for downgrades, wait until next cycle.
    billing_cycle_anchor: "unchanged",
  });
}

The proration_date timestamp pins the calculation to the moment you called the preview. If you delay applying the change, call the update with the same timestamp to avoid discrepancies between what the customer saw in the preview and what they are charged.


Dunning: Recovering Failed Payments

Dunning is the process of retrying failed payments and communicating with customers about their billing status. Most teams implement a single retry one or two days after failure. A properly sequenced dunning strategy recovers significantly more revenue.

The failure modes that dunning must address are distinct:

  • Insufficient funds: a temporary condition; retry in a few days.
  • Card expired: the customer must update their payment method; automated retries will all fail until they do.
  • Card stolen / fraud block: retries will not help; the customer must provide a new card.
  • Do not honor (generic decline): ambiguous; one retry is reasonable.
  • Network error on your side: retry immediately with exponential backoff; the customer’s card was never charged.

Your dunning strategy should branch on the decline code rather than applying a single schedule to all failures:

type DeclineCategory =
  | "retry_soon"        // insufficient_funds, do_not_honor
  | "update_card"       // expired_card, card_velocity_exceeded
  | "contact_bank"      // fraudulent, lost_card, stolen_card
  | "retry_immediately" // network error before reaching provider

interface DunningConfig {
  category: DeclineCategory;
  retryScheduleDays: number[];   // days after initial failure
  notifyCustomerAt: number[];    // days at which to send email
  markUncollectibleAfterDays: number;
}

const DUNNING_CONFIGS: Record<DeclineCategory, DunningConfig> = {
  retry_soon: {
    category: "retry_soon",
    retryScheduleDays: [3, 7, 14],
    notifyCustomerAt: [0, 7, 14],
    markUncollectibleAfterDays: 21,
  },
  update_card: {
    category: "update_card",
    retryScheduleDays: [],     // no point retrying with the same card
    notifyCustomerAt: [0, 3, 7, 14],
    markUncollectibleAfterDays: 21,
  },
  contact_bank: {
    category: "contact_bank",
    retryScheduleDays: [],
    notifyCustomerAt: [0],
    markUncollectibleAfterDays: 7,
  },
  retry_immediately: {
    category: "retry_immediately",
    retryScheduleDays: [0],    // retry same day
    notifyCustomerAt: [],
    markUncollectibleAfterDays: 30,
  },
};

function categorizeDunning(stripeDeclineCode: string): DeclineCategory {
  const updateCardCodes = new Set([
    "expired_card",
    "card_velocity_exceeded",
    "card_not_supported",
  ]);
  const contactBankCodes = new Set([
    "fraudulent",
    "lost_card",
    "stolen_card",
    "pickup_card",
  ]);
  const networkErrorCodes = new Set([
    "processing_error",
    "call_issuer",
  ]);

  if (updateCardCodes.has(stripeDeclineCode)) return "update_card";
  if (contactBankCodes.has(stripeDeclineCode)) return "contact_bank";
  if (networkErrorCodes.has(stripeDeclineCode)) return "retry_immediately";
  return "retry_soon";
}

Dunning State Machine

Track each invoice’s dunning state explicitly. An invoice that enters dunning transitions through states based on retry outcomes and customer actions:

type DunningStatus =
  | "none"
  | "pending_retry"
  | "awaiting_card_update"
  | "retrying"
  | "recovered"
  | "uncollectible";

interface DunningRecord {
  id: string;
  invoiceId: string;
  subscriptionId: string;
  customerId: string;
  status: DunningStatus;
  category: DeclineCategory;
  initialFailureAt: Date;
  nextRetryAt: Date | null;
  retryCount: number;
  lastDeclineCode: string;
  recoveredAt: Date | null;
  uncollectibleAt: Date | null;
}

async function scheduleNextRetry(
  dunningRecord: DunningRecord,
  config: DunningConfig
): Promise<void> {
  const nextRetryIndex = dunningRecord.retryCount;
  if (nextRetryIndex >= config.retryScheduleDays.length) {
    await markUncollectible(dunningRecord);
    return;
  }

  const daysUntilRetry = config.retryScheduleDays[nextRetryIndex];
  const nextRetryAt = new Date(
    dunningRecord.initialFailureAt.getTime() +
    daysUntilRetry * 86_400_000
  );

  await db.query(
    `UPDATE dunning_records
     SET status = 'pending_retry', next_retry_at = $1
     WHERE id = $2`,
    [nextRetryAt, dunningRecord.id]
  );

  // Enqueue a job that fires at nextRetryAt.
  await jobQueue.enqueue("retry_invoice", {
    dunningRecordId: dunningRecord.id,
    invoiceId: dunningRecord.invoiceId,
  }, { runAt: nextRetryAt });
}

Stripe Webhook Integration for Dunning

Stripe fires invoice.payment_failed when a charge attempt fails. This is the entry point for your dunning system:

async function handleInvoicePaymentFailed(
  stripeInvoice: Stripe.Invoice
): Promise<void> {
  const invoice = await db.findInvoiceByStripeId(stripeInvoice.id);
  if (!invoice) return;

  const declineCode =
    stripeInvoice.last_finalization_error?.decline_code ?? "generic_decline";

  const category = categorizeDunning(declineCode);
  const config = DUNNING_CONFIGS[category];

  // Create dunning record if this is the first failure.
  let dunning = await db.findDunningByInvoiceId(invoice.id);
  if (!dunning) {
    dunning = await db.createDunningRecord({
      invoiceId: invoice.id,
      subscriptionId: invoice.subscriptionId,
      customerId: invoice.customerId,
      status: "pending_retry",
      category,
      initialFailureAt: new Date(),
      retryCount: 0,
      lastDeclineCode: declineCode,
    });
  } else {
    // Update the decline code in case it changed on retry.
    await db.updateDunningRecord(dunning.id, {
      retryCount: dunning.retryCount + 1,
      lastDeclineCode: declineCode,
    });
    dunning = { ...dunning, retryCount: dunning.retryCount + 1 };
  }

  // Update subscription status to past_due.
  await db.updateSubscription(invoice.subscriptionId, {
    status: "past_due",
  });

  // Send the right customer notification.
  if (config.notifyCustomerAt.includes(dunning.retryCount)) {
    await notificationQueue.enqueue("dunning_notification", {
      customerId: invoice.customerId,
      category,
      retryCount: dunning.retryCount,
      nextRetryAt: null, // will be filled by scheduleNextRetry
    });
  }

  await scheduleNextRetry(dunning, config);
}

One important operational detail: when the customer updates their payment method through your portal, immediately attempt a charge rather than waiting for the next scheduled retry. A customer who just updated their card is the highest-intent moment in the dunning flow. Missing that window costs you revenue.

async function handlePaymentMethodUpdated(
  customerId: string
): Promise<void> {
  const openDunningRecords = await db.findOpenDunningByCustomer(customerId);
  for (const record of openDunningRecords) {
    // Attempt immediate retry by finalizing and paying the invoice.
    await stripe.invoices.pay(record.stripeInvoiceId, {
      forgive: false,
    });
  }
}

Tradeoffs: Build vs. Integrate

DimensionBuild internallyStripe BillingHybrid (Stripe + custom layer)
Time to launchMonthsDaysWeeks
FlexibilityFullLimited to Stripe’s modelHigh for custom logic
Usage meteringCustom designStripe Meters (limited)Custom events + Stripe prices
ProrationFull controlAutomatic, configurableStripe handles rounding
DunningFull controlSmart Retries (ML-based)Extend with custom webhooks
Compliance (PCI, SOC2)Full burdenStripe handles card dataShared, mostly Stripe
Multi-provider failoverPossibleStripe onlyRequires abstraction layer
Reporting and analyticsCustomStripe DashboardBoth
Operational costHighMonthly feesMedium

The decision framework is straightforward:

Start with Stripe Billing if you are launching a new SaaS product with standard per-seat or flat-rate pricing. The time saved is real and the operational risk is low. Stripe’s Smart Retries for dunning outperform naive retry schedules for most businesses because the ML model has visibility into bank response patterns across millions of payments.

Build a custom metering layer on top of Stripe when your usage model does not map cleanly to Stripe Meters: multi-metric pricing, complex included-quantity tiers, or custom aggregation windows. Emit events to your own pipeline, aggregate at invoice close, and inject line items into Stripe via invoice_item before the invoice finalizes.

Build a full custom billing system only when you have regulatory requirements that prohibit Stripe, need to support payment methods Stripe does not offer in your target markets, or are at the scale where Stripe’s percentage fees materially exceed the engineering cost of building and operating an in-house system. This threshold is higher than most teams assume: at $10M ARR, Stripe fees might be $200K-300K per year, but building and running a compliant billing stack costs at least that in engineering time.


Production Considerations

Idempotency at every boundary. Every invoice creation, every charge attempt, every usage ingestion must be idempotent. For Stripe calls, pass idempotencyKey on all write operations. For your own database writes, use ON CONFLICT DO NOTHING or equivalent and treat duplicates as success.

Webhook ordering. Stripe webhooks are not guaranteed to arrive in order. An invoice.paid event can arrive before invoice.created if delivery is delayed. Process each event with a guard that creates the missing record if it does not exist, rather than assuming the prior event was already processed.

Billing cycle boundary jobs. Generating invoices and closing usage periods are cron-like jobs that run at cycle boundaries. Use a distributed lock or a database-level advisory lock to prevent double-generation when the job runs on multiple instances. The invoice creation must be idempotent: if the job runs twice, the second run should detect the existing invoice and skip.

Timezone handling. Store all timestamps in UTC. Convert to the customer’s billing timezone only for display. Billing cycle boundaries calculated in local time produce the wrong day count for customers in non-UTC timezones if you use UTC timestamps naively.

Observability. Track the following metrics in production: payment failure rate by decline code, dunning recovery rate by category, proration amount distribution (large prorations are a signal of a UI problem where customers are changing plans multiple times), and usage event ingestion lag. An ingestion lag spike means usage events may not be counted in the current invoice period.

Grace periods. When a subscription enters past_due, most products continue service for a grace period (typically 7-14 days) before downgrading access. Model this as a separate field (accessRevokedAt) rather than inferring it from subscription status. The subscription can be past_due while access is still granted, and active while access is restricted during investigation of a chargeback.


Billing systems are one of the few places where getting the math wrong has immediate legal and customer-trust consequences. The complexity is in the details: the proration calculation that seems obvious until it crosses a month boundary, the dunning retry that fires on the wrong schedule, the usage event that gets counted twice because the ingest worker failed after writing to the queue but before marking the message as processed. Designing these systems with explicit state machines, normalized data models, and idempotent operations at every boundary is the work that prevents the 2 AM page when a billing cycle closes.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.