Invoice & Billing
Line items, tax rules, discounts, invoices. The model every SaaS eventually needs and most interview candidates model badly — floats, mutable invoices, missing audit.
Key Abstractions
Immutable once finalized. Customer, line items, totals, tax, status.
Description, quantity, unit price, discount, tax rate
Strategy — US sales tax, VAT, compound tax, zero-tax
Strategy — percent off, fixed off, BOGO, tiered
One settlement against an invoice. An invoice may have many (partial payments).
State machine: DRAFT → ISSUED → PARTIALLY_PAID → PAID, or ISSUED → VOIDED
Class Diagram
The Key Insight
The mistake every candidate makes in billing design is using floats for money. Computers can't represent 0.1 exactly in binary; a running total drifts cent-by-cent until the books don't balance. Integer cents is the entire fix: store 1999 for $19.99, divide by 100 only at display time. Decimal libraries work too but are slower; cents are universal.
Immutability after issuance is the second load-bearing choice. A customer's issued invoice is a legal document — once it's out the door, the numbers on it can't change. Corrections and refunds are new invoices with negative amounts (credit memos) that offset the original. This mirrors accounting reality: the original transaction stays in the ledger, the correction is its own entry.
Tax and discount as strategies matches the real world. US sales tax, EU VAT, Canadian compound GST+PST, reverse-charge B2B — none of them fit in a single formula. A strategy interface lets new rules drop in without disturbing existing invoices. Same story for discounts: percent off, fixed off, buy-one-get-one, tiered volume pricing.
Requirements
Functional
- Create a draft invoice, add line items with quantity, unit price, tax, discount
- Finalize to freeze totals and mark ISSUED
- Record payments — partial or full
- Void or issue credit memos for corrections
- Compute subtotal, tax, and total
- Track amount paid and amount due
Non-Functional
- All monetary math in integer cents
- Issued invoices are immutable
- Totals computed once at finalize, stored on the invoice
- Thread-safe add-item and payment application
Design Decisions
Why integer cents?
Float math loses precision; decimal libraries are slower and don't always round consistently across languages. Cents (or mils for currencies with 3 decimals) are exact, fit in a long, and sum without drift. Display layer converts.
Why freeze totals at finalize?
Recomputing totals on every render means a tax-rate change in 2027 silently rewrites invoices from 2026. Finalizing once captures the historical truth — "this is what the invoice looked like the day it was issued." Reports and audits stay stable.
Why strategies for tax and discount?
No two jurisdictions agree on tax rules. No two promotions agree on discount rules. Hardcoding either means forking the codebase per country or per campaign. Strategy interfaces let each rule be its own small, testable class.
Why credit memos instead of edits?
Editing an issued invoice erases the paper trail. Credit memos preserve it — "invoice 1001 was $500; credit memo 1002 refunded $100 of it; net receivable is $400." Accountants, auditors, and regulators all expect this model.
Why InvoiceStatus as a state machine?
Transitions aren't arbitrary. You can't apply a payment to a voided invoice. You can't void a fully paid invoice. Encoding the allowed transitions in one place (the apply_payment and void methods) keeps the rules discoverable and prevents illegal states.
Interview Follow-ups
- "How would you handle multiple currencies on one invoice?" Don't. An invoice is single-currency; cross-currency becomes two invoices. If forced, store line items with their native currency and convert at finalize with a stored FX snapshot.
- "What about recurring subscriptions?" A separate
Subscriptionentity generates a freshInvoiceper billing cycle. Subscription holds the template (items, frequency); each cycle instantiates a new immutable invoice. - "How does proration work when a plan changes mid-cycle?" On plan change, generate a credit memo for unused days on old plan + a new invoice for new plan prorated to cycle end. Both land in the customer's ledger.
- "How does dunning (late-payment follow-up) fit?" Background sweep marks overdue invoices (past due date, unpaid). Dunning service reads OVERDUE invoices and sends escalating reminders per policy.
- "How would you reconcile with a payment gateway?"
PaymentstoresgatewayRefId. Nightly job pulls gateway settlement report, matches by ref, flags mismatches. Orphaned payments (gateway has it, we don't) are investigated manually.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify scope and the money model
GoalLock the money representation, the immutability rule, and what counts as a correction. Then ask the interviewer whether they want a class diagram or code-first so the next 40 minutes are budgeted.
Do & Say- ASK·1Open with the money question: I am storing money as integer cents with a currency tag, never floats. Are we single-currency per invoice or do we need cross-currency line items? Default to single-currency, park multi-currency as v2.
- SAY·2Lock the immutability rule out loud: Once an invoice is ISSUED, the line items, totals, and tax cannot change. Corrections happen as credit memos, which are new invoices with negative line items. This is what auditors and accountants expect.
- SAY·3Pin the status set: DRAFT, ISSUED, PARTIALLY_PAID, PAID, VOIDED, OVERDUE. Void is for never-paid mistakes, credit memo is for refunds after payment. They are different paths, do not collapse them.
- SAY·4Confirm what is in scope for v1: line-item discounts, line-item tax with compound tax for places like Quebec QST on GST, partial payments, overdue sweep. Park subscriptions, proration, dunning workflows, FX, and tax filing as v2.
- ASK·5Ask the process question: Do you want me to sketch the class diagram first, or go straight to code with Money and Invoice and let the relationships fall out as I type? Either way I will keep the state transitions and the strategies visible.
Interviewer is grading: You name integer cents as the first constraint, not as an afterthought. You distinguish void from credit memo explicitly. You ask the diagram-vs-code question instead of just diving in.
- 25-10 min
Sketch the model and pricing flow
GoalLock the class shapes and the order of math on a LineItem before any code. Either draw the diagram or verbalize the relationships and the per-line formula.
Do & Say- SAY·1Name the abstractions: Money value object, LineItem with TaxRule and DiscountRule, Invoice owning a list of LineItems and a list of Payments plus an InvoiceStatus, BillingService as the facade. Two strategy interfaces: TaxRule and DiscountRule.
- WRITE·2WRITE per-line formula: gross = unit_price * quantity. net = gross - discount.apply(gross). tax = tax_rule.tax_for(net). line_total = net + tax. Discount on net base, tax on post-discount net. Gross-tax jurisdictions are a different TaxRule, not a flag.
- WRITE·3WRITE finalize: sums net into subtotal, sums tax into tax_total, sets total. Transitions DRAFT -> ISSUED, snapshots issued_at and due_at. add_item raises after. Totals stored once. A 2027 tax-rate change must not silently rewrite a 2026 invoice.
- SAY·4List the InvoiceStatus transitions explicitly: DRAFT to ISSUED on finalize. ISSUED or OVERDUE plus apply_payment to PARTIALLY_PAID or PAID. ISSUED to VOIDED, never PAID to VOIDED, that is a credit memo path. ISSUED or PARTIALLY_PAID past due_at to OVERDUE via the sweep.
- SAY·5If a diagram was requested, draw Invoice in the center with arrows out to LineItem, Payment, InvoiceStatus. LineItem points to Money, TaxRule, DiscountRule. Three TaxRule subclasses (NoTax, SalesTax, CompoundTax) and three DiscountRule subclasses (NoDiscount, Percent, Fixed). BillingService at the top as facade.
Interviewer is grading: The per-line formula is on the board in one line. You name post-discount net as the tax base and acknowledge gross-tax jurisdictions as a different rule, not a config flag. You volunteer the issued-then-immutable rule unprompted.
- 325 min
Code in this sequence (bottom-up)
GoalType the actual code in the order Money to Status enums to strategies to LineItem to Payment to Invoice to BillingService. Talk while you type.
Do & Say- SAY·1Start with Money as a frozen dataclass: cents int, currency str. Methods add, subtract, multiply(factor), is_zero. Say: Add and subtract raise on currency mismatch. multiply rounds half-up; bankers rounding would be ideal but half-up is a fine default and easier to explain. (~3 min)
- SAY·2Next the two enums: InvoiceStatus with DRAFT, ISSUED, PARTIALLY_PAID, PAID, VOIDED, OVERDUE. PaymentMethod with CREDIT_CARD, BANK_TRANSFER, CHECK, CREDIT_NOTE. Say: CREDIT_NOTE as a payment method is how credit memos settle against existing invoices. (~1 min)
- SAY·3TaxRule strategy plus three concretes. NoTaxRule returns Money(0). SalesTaxRule takes a rate, returns amount.multiply(rate). CompoundTaxRule stacks rules on a running base. Quebec needs this: QST applies on price+GST, not original price. (~5 min)
- SAY·4Code the DiscountRule strategy and three concretes. NoDiscount returns zero. PercentDiscount takes a percent in 0 to 100, calls amount.multiply(percent / 100). FixedDiscount clamps so the discount never exceeds the base. Say: Clamping is what stops a fifty-dollar discount from turning a thirty-dollar item into a negative line. (~4 min)
- SAY·5LineItem dataclass: description, quantity, unit_price, tax_rule, discount. Methods: gross, discount_amount, net, tax, total. Validate quantity >= 1. unit_price can be negative for credit memos. Each LineItem owns its own rule and discount, so one invoice mixes taxable and zero-rated lines. (~3 min)
- SAY·6Code Payment as a small dataclass: id, invoice_id, amount, method, received_at. (~1 min)
- SAY·7Invoice. Fields: id, customer_id, currency, _items, _status=DRAFT, _issued_at, _due_at, _subtotal, _tax_total, _total, _payments, _lock. add_item (DRAFT+currency match). finalize (sum totals, transition DRAFT->ISSUED, set timestamps). apply_payment (status/currency/positive checks, reject overpay, transition to PARTIALLY_PAID/PAID). void (refuse PAID). check_overdue. Overpayment is a separate workflow. (~6 min)
- SAY·8BillingService. _invoices dict. create_invoice returns draft. issue/record_payment/void delegate. credit_memo creates a new invoice with negative-priced line item, finalizes, never touches the original. The $50K original stays ISSUED, the $10K memo lives next to it, customer ledger nets them out. (~2 min)
- SAY·9Walk through one cycle as a self-check before stopping. Create invoice, add two line items, issue. Partial payment moves to PARTIALLY_PAID, second payment hits PAID. Attempt to add a line raises. sweep_overdue on an unrelated invoice past due_at flips it to OVERDUE. (~1 min)
Interviewer is grading: Money is integer cents from the first line, never floats. Tax sits on a strategy interface, not a switch inside LineItem. void and credit_memo are different methods with different semantics, you do not conflate them. You volunteer that totals are frozen at finalize so historical reports stay stable.
- 45 min
Trade-offs and extensions
GoalName two trade-offs and defend them. Volunteer one extension. Close in one sentence.
Do & Say- SAY·1Trade-off one, freeze-at-finalize over recompute-on-read: Recomputing totals on every render means a 2027 tax-rate change silently rewrites 2026 invoices. Freezing at finalize captures the historical truth. The cost is three more fields on Invoice and the rule that they cannot be edited afterward.
- SAY·2Trade-off two, credit memo over invoice edit: An edit erases the audit trail. The compensating-memo pattern preserves both numbers in the ledger. The cost is two rows for one logical refund, which accountants actually prefer.
- SAY·3Volunteer subscriptions with proration: Subscription entity holds the template. Each cycle instantiates a fresh immutable Invoice. Mid-cycle plan changes generate a credit memo for unused days plus a new prorated invoice. Invoice model unchanged; subscriptions produce invoices.
- SAY·4Multi-currency: Single-currency per invoice. Cross-currency becomes two native-currency invoices. If forced into one, store FX snapshot at finalize. Dunning: sweep_overdue marks past-due; separate Dunning service reads OVERDUE and sends reminders. Keep dunning out of Invoice.
- SAY·5Close in one sentence: Money in integer cents. Invoice immutable after finalize. Strategy for tax and discount per line. State machine on status. Credit memos for corrections. Five pieces, no float math anywhere.
Interviewer is grading: You defend two specific trade-offs with their cost. You volunteer subscriptions with proration as a natural extension. You can summarize the design in one breath.
Code Implementation
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from threading import RLock
import uuid
@dataclass(frozen=True)
class Money:
"""Integer cents. Currency tag prevents accidental USD+EUR addition."""
cents: int
currency: str = "USD"
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"currency mismatch: {self.currency} vs {other.currency}")
return Money(self.cents + other.cents, self.currency)
def subtract(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("currency mismatch")
return Money(self.cents - other.cents, self.currency)
def multiply(self, factor: float) -> "Money":
# Banker's rounding would be ideal; half-up is a decent default.
return Money(int(round(self.cents * factor)), self.currency)
def is_zero(self) -> bool:
return self.cents == 0
def __str__(self) -> str:
return f"{self.cents / 100:.2f} {self.currency}"
ZERO_USD = Money(0, "USD")
class InvoiceStatus(Enum):
DRAFT = "draft"
ISSUED = "issued"
PARTIALLY_PAID = "partially_paid"
PAID = "paid"
VOIDED = "voided"
OVERDUE = "overdue"
class PaymentMethod(Enum):
CREDIT_CARD = "credit_card"
BANK_TRANSFER = "bank_transfer"
CHECK = "check"
CREDIT_NOTE = "credit_note"
# ---- Strategies ----
class TaxRule(ABC):
@abstractmethod
def tax_for(self, taxable_amount: Money) -> Money: ...
class NoTaxRule(TaxRule):
def tax_for(self, amount: Money) -> Money:
return Money(0, amount.currency)
class SalesTaxRule(TaxRule):
def __init__(self, rate: float):
if rate < 0:
raise ValueError("rate must be non-negative")
self._rate = rate
def tax_for(self, amount: Money) -> Money:
return amount.multiply(self._rate)
class CompoundTaxRule(TaxRule):
"""Multiple taxes applied sequentially on the previous base+tax (e.g., Quebec QST on GST)."""
def __init__(self, rules: list[TaxRule]):
self._rules = rules
def tax_for(self, amount: Money) -> Money:
total_tax = Money(0, amount.currency)
running = amount
for rule in self._rules:
tax = rule.tax_for(running)
total_tax = total_tax.add(tax)
running = running.add(tax)
return total_tax
class DiscountRule(ABC):
@abstractmethod
def apply(self, amount: Money) -> Money: ...
class NoDiscount(DiscountRule):
def apply(self, amount: Money) -> Money:
return Money(0, amount.currency)
class PercentDiscount(DiscountRule):
def __init__(self, percent: float):
if not 0 <= percent <= 100:
raise ValueError("percent must be 0..100")
self._percent = percent
def apply(self, amount: Money) -> Money:
return amount.multiply(self._percent / 100.0)
class FixedDiscount(DiscountRule):
def __init__(self, amount: Money):
if amount.cents < 0:
raise ValueError("discount must be non-negative")
self._amount = amount
def apply(self, base: Money) -> Money:
# Clamp: never discount more than the base.
return self._amount if self._amount.cents <= base.cents else base
# ---- Line item + invoice ----
@dataclass
class LineItem:
description: str
quantity: int
unit_price: Money
tax_rule: TaxRule = field(default_factory=NoTaxRule)
discount: DiscountRule = field(default_factory=NoDiscount)
def __post_init__(self):
if self.quantity < 1:
raise ValueError("quantity must be >= 1")
# Note: unit_price can be negative — that's how credit memos and refunds model reversals.
def gross(self) -> Money:
return self.unit_price.multiply(self.quantity)
def discount_amount(self) -> Money:
return self.discount.apply(self.gross())
def net(self) -> Money:
return self.gross().subtract(self.discount_amount())
def tax(self) -> Money:
return self.tax_rule.tax_for(self.net())
def total(self) -> Money:
return self.net().add(self.tax())
@dataclass
class Payment:
id: str
invoice_id: str
amount: Money
method: PaymentMethod
received_at: datetime
class Invoice:
def __init__(self, invoice_id: str, customer_id: str, currency: str = "USD",
due_in: timedelta = timedelta(days=30)):
self.id = invoice_id
self.customer_id = customer_id
self.currency = currency
self._items: list[LineItem] = []
self._status = InvoiceStatus.DRAFT
self._issued_at: datetime | None = None
self._due_at: datetime | None = None
self._due_in = due_in
self._subtotal = Money(0, currency)
self._tax_total = Money(0, currency)
self._total = Money(0, currency)
self._payments: list[Payment] = []
self._lock = RLock()
def add_item(self, item: LineItem) -> None:
with self._lock:
if self._status != InvoiceStatus.DRAFT:
raise RuntimeError("cannot modify an issued invoice")
if item.unit_price.currency != self.currency:
raise ValueError("line item currency must match invoice")
self._items.append(item)
def finalize(self) -> None:
"""Freeze the invoice — compute totals once and mark ISSUED."""
with self._lock:
if self._status != InvoiceStatus.DRAFT:
raise RuntimeError("already finalized")
if not self._items:
raise RuntimeError("invoice has no line items")
subtotal = Money(0, self.currency)
tax_total = Money(0, self.currency)
for item in self._items:
subtotal = subtotal.add(item.net())
tax_total = tax_total.add(item.tax())
self._subtotal = subtotal
self._tax_total = tax_total
self._total = subtotal.add(tax_total)
self._status = InvoiceStatus.ISSUED
self._issued_at = datetime.utcnow()
self._due_at = self._issued_at + self._due_in
def apply_payment(self, amount: Money, method: PaymentMethod) -> Payment:
with self._lock:
if self._status not in (InvoiceStatus.ISSUED, InvoiceStatus.PARTIALLY_PAID, InvoiceStatus.OVERDUE):
raise RuntimeError(f"cannot apply payment to a {self._status.value} invoice")
if amount.currency != self.currency:
raise ValueError("payment currency must match invoice")
if amount.cents <= 0:
raise ValueError("payment must be positive")
remaining = self.amount_due()
if amount.cents > remaining.cents:
raise ValueError(f"payment exceeds amount due ({remaining})")
payment = Payment(
id=str(uuid.uuid4())[:8],
invoice_id=self.id,
amount=amount, method=method,
received_at=datetime.utcnow(),
)
self._payments.append(payment)
if self.amount_paid().cents >= self._total.cents:
self._status = InvoiceStatus.PAID
else:
self._status = InvoiceStatus.PARTIALLY_PAID
return payment
def void(self) -> None:
with self._lock:
if self._status == InvoiceStatus.PAID:
raise RuntimeError("cannot void a paid invoice — issue a credit memo instead")
if self._status == InvoiceStatus.VOIDED:
return
self._status = InvoiceStatus.VOIDED
def check_overdue(self, now: datetime | None = None) -> bool:
"""Transition to OVERDUE if past due and still unpaid. Returns True if changed."""
with self._lock:
if self._status not in (InvoiceStatus.ISSUED, InvoiceStatus.PARTIALLY_PAID):
return False
if self._due_at is None:
return False
check_at = now or datetime.utcnow()
if check_at >= self._due_at:
self._status = InvoiceStatus.OVERDUE
return True
return False
def amount_paid(self) -> Money:
total = Money(0, self.currency)
for p in self._payments:
total = total.add(p.amount)
return total
def amount_due(self) -> Money:
return self._total.subtract(self.amount_paid())
@property
def status(self) -> InvoiceStatus: return self._status
@property
def subtotal(self) -> Money: return self._subtotal
@property
def tax_total(self) -> Money: return self._tax_total
@property
def total(self) -> Money: return self._total
class BillingService:
def __init__(self):
self._invoices: dict[str, Invoice] = {}
def create_invoice(self, customer_id: str, currency: str = "USD") -> Invoice:
inv = Invoice(invoice_id=str(uuid.uuid4())[:8], customer_id=customer_id, currency=currency)
self._invoices[inv.id] = inv
return inv
def issue(self, invoice_id: str) -> None:
self._invoices[invoice_id].finalize()
def record_payment(self, invoice_id: str, amount: Money, method: PaymentMethod) -> Payment:
return self._invoices[invoice_id].apply_payment(amount, method)
def void(self, invoice_id: str) -> None:
self._invoices[invoice_id].void()
def credit_memo(self, original_invoice_id: str, amount: Money) -> Invoice:
"""Compensating invoice for refunds or corrections. Never mutates the original."""
original = self._invoices[original_invoice_id]
memo = self.create_invoice(original.customer_id, original.currency)
memo.add_item(LineItem(
description=f"Credit for invoice {original_invoice_id}",
quantity=1,
unit_price=Money(-amount.cents, original.currency), # negative line
))
memo.finalize()
return memo
def sweep_overdue(self, now: datetime | None = None) -> int:
"""Mark every issued/partially-paid invoice past its due date as OVERDUE.
Run this on a daily cadence; dunning reads OVERDUE invoices downstream."""
count = 0
for inv in self._invoices.values():
if inv.check_overdue(now):
count += 1
return count
if __name__ == "__main__":
svc = BillingService()
inv = svc.create_invoice("cust-001", currency="USD")
# Two items, 10% discount on the first, 8.25% sales tax on both.
tax = SalesTaxRule(0.0825)
inv.add_item(LineItem(
description="Enterprise license",
quantity=1,
unit_price=Money(50_000),
discount=PercentDiscount(10),
tax_rule=tax,
))
inv.add_item(LineItem(
description="Support add-on",
quantity=2,
unit_price=Money(5_000),
tax_rule=tax,
))
svc.issue(inv.id)
print(f"Invoice {inv.id} — status {inv.status.value}")
print(f" Subtotal: {inv.subtotal}")
print(f" Tax: {inv.tax_total}")
print(f" Total: {inv.total}")
# Partial payment.
p1 = svc.record_payment(inv.id, Money(30_000), PaymentMethod.CREDIT_CARD)
print(f"\nAfter payment of {p1.amount}: status {inv.status.value}, due {inv.amount_due()}")
# Pay the rest.
p2 = svc.record_payment(inv.id, inv.amount_due(), PaymentMethod.BANK_TRANSFER)
print(f"After final payment: status {inv.status.value}, due {inv.amount_due()}")
assert inv.status == InvoiceStatus.PAID
# Cannot modify an issued invoice.
try:
inv.add_item(LineItem("late addition", 1, Money(100)))
except RuntimeError as e:
print(f"Post-issue modification blocked: {e}")
# Credit memo for a refund.
memo = svc.credit_memo(inv.id, Money(10_000))
print(f"\nCredit memo {memo.id}: total {memo.total}")
# Overdue sweep: issue a second invoice with a short due window and jump time forward.
late = svc.create_invoice("cust-002", currency="USD")
late.add_item(LineItem("Late thing", 1, Money(10_000), tax_rule=NoTaxRule()))
svc.issue(late.id)
assert late.status == InvoiceStatus.ISSUED
future = datetime.utcnow() + timedelta(days=60)
swept = svc.sweep_overdue(now=future)
print(f"\nSwept {swept} overdue invoice(s); late invoice status: {late.status.value}")
assert late.status == InvoiceStatus.OVERDUE
print("All operations passed.")Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Stores money correctly and writes Invoice, LineItem, and a flat pricing flow, but mixes void with credit memo and treats status as a string.
- Names integer cents as the storage model and rejects floats.
- Writes LineItem with quantity, unit_price, and a total method.
- Implements add_item and finalize on Invoice and sets a total.
- Recognizes that issued invoices should not be edited when prompted.
- Adds a DiscountRule or TaxRule strategy when asked, even if only one concrete class.
- Calls void on a paid invoice to model a refund instead of issuing a credit memo.
- Stores InvoiceStatus as a string and branches on string comparisons instead of an enum or state machine.
- Recomputes total on every read instead of freezing at finalize.
- Applies tax on gross instead of post-discount net, or hardcodes one tax rate inside LineItem.
- Lets apply_payment accept overpayments silently.
Mid-Level Engineer (L4)
Drives the design end-to-end with frozen totals, strategy-based tax and discount, and a clean separation between void and credit memo.
- Writes Money first and propagates the currency check through add, subtract, and apply_payment.
- Implements TaxRule and DiscountRule as strategy interfaces with two or more concretes each, including CompoundTaxRule for stacked taxes.
- Freezes subtotal, tax_total, and total at finalize and refuses add_item afterward.
- Distinguishes void (never paid, no payments) from credit_memo (issued or paid, compensating invoice) and refuses to void a PAID invoice.
- Implements partial payments with a remaining-amount check and transitions to PARTIALLY_PAID or PAID correctly.
- Adds an overdue sweep keyed by due_at and explains why it runs on a cadence rather than per-request.
- Does not volunteer subscriptions with proration or dunning as natural extensions until asked.
- Treats FX as a single conversion at finalize without persisting the FX snapshot for audit.
- Misses idempotency on record_payment, so a retried gateway call double-counts.
Senior Engineer (L5+)
Volunteers extensions before being asked, names auditability and currency-correctness invariants, and frames each pattern around the failure it prevents.
- Volunteers subscription-driven invoice generation, dunning as a downstream consumer of OVERDUE, and gateway-reconciliation jobs unprompted.
- Names integer cents and currency-tagged Money as the invariant that makes the books balance across thousands of operations.
- Frames each pattern around its failure mode: Strategy for tax and discount so a new jurisdiction never edits LineItem, State machine on status so apply_payment cannot run on a VOIDED invoice, freeze-at-finalize so a 2027 tax-rate change does not rewrite 2026 history, credit memos so the audit trail survives every refund.
- Proposes idempotency keys on record_payment so retried gateway calls do not double-charge, and stores gateway_ref on Payment for nightly reconciliation.
- Suggests an FX snapshot stored on Invoice at finalize so converted totals are reproducible after rates move.
- Names reverse-charge B2B and zero-rated exports as concrete TaxRule subclasses, not flags.
- Closes with a one-sentence summary that names integer cents, freeze-at-finalize, the two strategy interfaces, the state machine, and credit memos in under 20 seconds.
Common Mistakes
- ✗Storing money as float/double. Catastrophic rounding errors at scale.
- ✗Mutating issued invoices. Disconnects audit trail from customer-visible state.
- ✗Calculating total at every render. Small perf issue, bigger is 'did you change the tax rate and invalidate historical reports?'
- ✗Tax inside LineItem hardcoded. EU B2B, reverse-charge, and zero-rated exports all need pluggable rules.
Key Points
- ✓Money in integer cents. Floats turn $0.10 + $0.20 into $0.30000000000000004 — audit nightmare.
- ✓Invoice is immutable once issued. Corrections produce a credit memo; the original is preserved.
- ✓Tax and discount are strategies. Every jurisdiction has its own rules; every promotion is its own logic.
- ✓Totals computed once at finalize time. Avoids drift between stored total and recomputed sum.