# Invoice and Billing System Low-Level Design

Source: https://www.techinterview.org/post/3233467355/lld-invoice-system/
Updated: 2026-04-17 · techinterview.org

## Requirements

  - Generate invoices for subscriptions and one-time purchases

  - Support multiple billing cycles: monthly, annual, usage-based

  - Apply discounts, taxes, and proration for mid-cycle changes

  - Collect payments via card, ACH, wire transfer

  - Handle payment failures: retry logic, dunning (escalating notifications)

  - 100M customers, 10M invoices/month

## Data Model


```
Customer(customer_id, name, email, billing_address, tax_id, currency, payment_method_id)

Subscription(subscription_id, customer_id, plan_id, status ENUM(ACTIVE,PAST_DUE,CANCELLED),
             current_period_start, current_period_end, billing_cycle ENUM(MONTHLY,ANNUAL),
             quantity, discount_id)

Plan(plan_id, name, price_per_unit, currency, billing_interval, usage_based BOOL)

Invoice(invoice_id, customer_id, subscription_id, status ENUM(DRAFT,OPEN,PAID,VOID,UNCOLLECTIBLE),
        currency, subtotal, discount_amount, tax_amount, total, due_date, paid_at,
        attempt_count, next_retry_at)

InvoiceLineItem(line_item_id, invoice_id, description, quantity, unit_price, amount,
                period_start, period_end, type ENUM(SUBSCRIPTION,PRORATION,ONE_TIME,TAX))

Payment(payment_id, invoice_id, amount, currency, method ENUM(CARD,ACH,WIRE),
        status ENUM(PENDING,SUCCEEDED,FAILED), processor_id, created_at)
```


## Invoice Generation

Triggered by a scheduled job at billing cycle end. Steps:

  - Collect line items: subscription fee for the upcoming period, usage charges for the past period, any proration credits/charges from plan changes

  - Apply discounts (PERCENT or FIXED) from the Subscription.discount_id

  - Calculate tax: look up tax rate by customer billing address and product type (TaxJar/Avalara API). Tax = (subtotal - discount) * tax_rate

  - Create Invoice with status=DRAFT; line items attached

  - Finalize to status=OPEN: lock line items, send invoice email to customer

  - Attempt payment immediately (for auto-pay) or wait for due date

## Proration

When a customer upgrades mid-cycle (e.g., day 15 of a 30-day month): credit unused days of old plan, charge remaining days of new plan.


```
days_remaining = (period_end - upgrade_date).days   # e.g., 15
days_in_period = (period_end - period_start).days   # e.g., 30

credit = old_plan_price * (days_remaining / days_in_period)   # credit for unused old plan
charge = new_plan_price * (days_remaining / days_in_period)   # charge for remaining on new plan
proration_adjustment = charge - credit
```

Add proration as InvoiceLineItems: one credit line item (negative amount) and one charge line item, both with type=PRORATION.

## Payment Collection and Retry (Dunning)

On first payment attempt failure: update Invoice status=OPEN (not paid), set next_retry_at = NOW() + 3 days. Retry schedule: +3 days, +5 days, +7 days (total 4 attempts over 15 days). On each retry, charge the stored payment method. If all retries fail: mark Invoice status=UNCOLLECTIBLE, Subscription status=PAST_DUE. Send escalating notification emails at each retry: "Payment failed — we'll retry in 3 days" → "Action required" → "Final notice — subscription will be cancelled." After dunning period: cancel Subscription, provision access revocation, send final notice.


```
-- Dunning scheduler query (runs every hour):
SELECT invoice_id FROM Invoice
WHERE status = 'OPEN'
  AND attempt_count < 4
  AND next_retry_at <= NOW()
ORDER BY next_retry_at
LIMIT 1000
```


## Idempotency for Payment Processing

Never charge a customer twice for the same invoice. Use idempotency keys when calling the payment processor: key = invoice_id + attempt_count. If the payment API call times out, retry with the same idempotency key — the processor returns the result of the original attempt without creating a duplicate charge. Store processor's payment_intent_id in the Payment record; check if it exists before creating a new one.

## Tax Calculation

Tax rates vary by customer location, product type, and jurisdiction. Use a tax API (TaxJar, Avalara) rather than maintaining a rate table. Call the API at invoice finalization, passing customer address and line items. Cache the tax rate for (country, state, product_type) for 24h — rates rarely change daily. Store the calculated tax as a line item for auditability. For EU VAT: validate VAT IDs for B2B customers (zero-rate if valid EU VAT ID). For US sales tax: nexus rules determine which states require collection.

## Key Design Decisions

  - Invoice status machine: DRAFT → OPEN → PAID/VOID/UNCOLLECTIBLE — never mutate a finalized invoice, append corrections as credit notes

  - Idempotency keys on payment processor calls — prevents double-charges on retry

  - Proration as explicit line items — audit trail, customer transparency

  - Dunning via scheduled job with next_retry_at — decoupled from payment processor, retryable

  - Tax API integration with caching — correct rates without maintaining jurisdiction tables

Stripe system design is the canonical billing and invoice interview topic. See common questions for [Stripe interview: billing and invoice system design](https://www.techinterview.org/post/3233460268/stripe-interview-guide-2026-process-bug-bash-round-and-payment-systems/).

Shopify system design covers subscription billing and invoicing. Review patterns for [Shopify interview: subscription billing system design](https://www.techinterview.org/post/3233459924/shopify-interview/).

Coinbase system design covers payment processing and billing. See design patterns for [Coinbase interview: payment and billing system design](https://www.techinterview.org/post/3233459920/coinbase-interview/).
