Invoice and Billing System Low-Level Design

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:

  1. Collect line items: subscription fee for the upcoming period, usage charges for the past period, any proration credits/charges from plan changes
  2. Apply discounts (PERCENT or FIXED) from the Subscription.discount_id
  3. Calculate tax: look up tax rate by customer billing address and product type (TaxJar/Avalara API). Tax = (subtotal – discount) * tax_rate
  4. Create Invoice with status=DRAFT; line items attached
  5. Finalize to status=OPEN: lock line items, send invoice email to customer
  6. 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


{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you generate invoices for subscription billing at scale?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Invoice generation is triggered by a scheduled job at billing cycle boundaries (e.g., monthly on the anniversary date). Steps: (1) Query all subscriptions with current_period_end = today (or use a job queue — enqueue a generate_invoice job when the subscription is created, scheduled for end_time). (2) Collect line items: subscription fee for the next period, usage charges for the completed period, any pending proration adjustments. (3) Apply discounts and calculate tax via a tax API. (4) Create Invoice record with status=DRAFT and InvoiceLineItems. (5) Finalize to OPEN: lock line items, send invoice email. (6) Attempt payment. At 10M invoices/month (~4 per minute on average), this is manageable with a job queue. Peak days (1st of month for monthly subs): batch all 1st-day subscriptions across 24 hours using invoice time randomization to flatten the spike.”}},{“@type”:”Question”,”name”:”How does proration work when a customer changes plans mid-billing cycle?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Proration ensures customers only pay for what they use. When upgrading on day 15 of a 30-day cycle: credit the unused 15 days of the old plan, charge 15 days of the new plan. Formula: credit = old_price * (days_remaining / days_in_period); charge = new_price * (days_remaining / days_in_period); net_proration = charge – credit. These are added as InvoiceLineItems with type=PRORATION on the customer's next invoice. For same-day changes, include the proration in the immediately generated invoice. For downgrades: some billing systems apply the proration credit to the next invoice rather than refunding (simpler). For annual subscriptions: proration amounts can be large — always show the breakdown clearly to the customer before confirming the plan change.”}},{“@type”:”Question”,”name”:”How does dunning (retry logic for failed payments) work?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Dunning is the process of retrying failed payments and notifying customers with escalating urgency. Standard retry schedule: immediate attempt → retry after 3 days → retry after 5 days → final retry after 7 days (4 total attempts over 15 days). Implementation: on payment failure, set Invoice.next_retry_at = NOW() + retry_interval and increment attempt_count. A scheduled job runs every hour: SELECT invoices WHERE status=OPEN AND attempt_count < 4 AND next_retry_at <= NOW(). For each, attempt payment. If succeeded: status=PAID. If failed and attempt_count < 4: schedule next retry. If attempt_count = 4: status=UNCOLLECTIBLE, subscription=PAST_DUE, send final cancellation notice. Email notifications at each stage: "Payment failed — we will retry in 3 days", "Second attempt failed — update your payment method", "Final notice — service will be suspended." SMS for final notice increases recovery rate.”}},{“@type”:”Question”,”name”:”How do you prevent double-charging on payment retries?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use idempotency keys when calling the payment processor. The idempotency key is a unique string that identifies this specific payment attempt: key = "{invoice_id}-{attempt_count}". If the HTTP call to the payment processor times out and you retry the API call, pass the same idempotency key — the processor returns the result of the original attempt (succeeded or failed) without creating a second charge. Store the processor's payment intent ID (e.g., Stripe's pi_xxx) in your Payment record before initiating the charge. On retry: check if a Payment record with this invoice_id and attempt_count already exists (idempotency in your DB). If the payment intent exists but the status is pending, poll for the result rather than creating a new charge. Never create a new payment intent for a retry without incrementing attempt_count and generating a new idempotency key.”}},{“@type”:”Question”,”name”:”How do you calculate and apply taxes in an invoice system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Tax calculation is complex: rates depend on customer jurisdiction (country, state, county, city), product type (software, physical goods, services differ), and customer type (B2B may be exempt). Use a tax API rather than maintaining rate tables: TaxJar and Avalara handle jurisdiction lookups, rate updates, and provide a calculation audit trail. Call the tax API at invoice finalization: pass customer billing address and line items. Cache the calculated rate for (country, state, product_type) for 24 hours — rates rarely change. Store the tax as a separate InvoiceLineItem (type=TAX) for auditability and reporting. For EU VAT: validate the customer's VAT ID via the VIES service; if valid, apply reverse charge (zero-rate the invoice for B2B EU cross-border transactions). For US sales tax: determine nexus (physical presence or economic threshold) before collecting — consult a tax service for nexus tracking across states.”}}]}

Stripe system design is the canonical billing and invoice interview topic. See common questions for Stripe interview: billing and invoice system design.

Shopify system design covers subscription billing and invoicing. Review patterns for Shopify interview: subscription billing system design.

Coinbase system design covers payment processing and billing. See design patterns for Coinbase interview: payment and billing system design.

Scroll to Top