Chapter 05 β The domain: invoices, tax, and money math
The "domain" is the part of the software that would be true even if you ran the business on paper: what an invoice is, when tax applies, how a quote becomes a bill. Get this layer right and every interface on top β web, public link, export file β is just a window onto the same truth. This chapter tours FoxyInvoice's domain logic, which is where most of the genuine engineering lives.
The invoice state machine
stateDiagram-v2
[*] --> Draft: create
Draft --> Sent: send (email / share link)
Sent --> Paid: payment recorded
Sent --> Partial: partial payment
Partial --> Paid: remainder recorded
Sent --> Overdue: due date passes
Overdue --> Paid: payment recorded
Draft --> Void: discard
Sent --> Void: cancel
note right of Sent
Quotes: same machine, type=Quote.
Accept β converts to a real
Invoice (clone + void quote)
end note
Small on purpose. Every extra state is a branch in fifty UIs and a
question for every customer. Overdue isn't stored so much as
derived: a daily worker flags Sent invoices past due (which also
triggers reminders β +3 and +14 days, plus a pre-due nudge three days
before). Credit notes exist as a third type for refunds/adjustments.
Two invariants protect this machine:
- Totals are server property. The SPA shows a live preview explicitly labeled "server-confirmed on save" β but no number the browser sends is ever trusted. Every mutation (edit a line, change a discount) triggers full recomputation from the line items up through the tax engine. The client lying about a total simply has no effect.
- Edit windows. Invoices lock
InvoiceEditWindowHours(default 24h) after certain transitions β accounting systems don't like history being quietly rewritten.
The tax engine: nexus, jurisdictions, and a waterfall
US sales tax is the reason this product has a "tax engine" and not a
tax_rate column. The rules in one paragraph: whether you collect
sales tax for a sale depends on where your business has nexus (a
presence triggering tax duties), what jurisdiction the client is in,
and what kind of thing you sold β service vs good, taxable or
exempt, sometimes with a rate override.
The engine models exactly that, evaluated per line item:
- Is the client tax-exempt? β zero tax, keep the line total.
- Do you have nexus in the client's jurisdiction? β no: don't collect.
- Is a rate known for that jurisdiction? β no: can't collect.
- Is there a taxability rule for this product type in this jurisdiction? β use it (including rate overrides); otherwise fall back to the product's default taxability.
- Apply:
lineTotal Γ rate, rounded to cents. Never floats.
Jurisdictions carry component breakdowns (state/county/city) for reporting; a tax liability report sums what you owe where. The whole calculator is a pure function β no I/O β which makes it the most tested code in the repo.
Quotes that convert
A quote is an invoice with type=Quote and the same math. Conversion
clones header + lines into a real Draft invoice (new number from the
tenant's sequence, atomically reserved with SELECT β¦ FOR UPDATE so
two concurrent creates can't collide), then voids the quote so it can't
convert twice. The client-facing version (Chapter 12): a share link
with Accept / Decline buttons β accept runs this exact conversion
server-side and emails the owner. One code path for button and menu.
Getting paid: two directions
- Online: per-invoice Stripe payment links; the checkout webhook auto-records the payment and moves the state machine. No card data ever touches our servers (Chapter 04).
- Offline: record payments manually, or email a payment notice to a dedicated inbound address β SES receives it, S3 stores it, a worker parses it, and a human confirms before it books. Note the trust ladder: machines suggest, humans confirm for anything money-shaped.
Recurring invoices: templates with a schedule; a worker generates Draft invoices on cadence, runs them through the same tax engine, and can auto-send. Because it reuses the create path, every invariant applies for free.
Exports: the accountant is the audience
An invoicing product that can't hand off to the accountant is a toy.
Three formats ship today β CSV (spreadsheets), QuickBooks IIF
(balanced TRNS/SPL entries β debits and credits must sum to zero or
QuickBooks rejects the file), and Tally XML for India (voucher
envelopes where every voucher must balance). An export profile
per workspace holds country, tax registration number, its
locale-aware label (EIN, GSTIN, VAT Noβ¦), and fiscal-year start β so a
PDF invoice in India shows GSTIN: 27ABCDE⦠without anyone configuring
"labels." Export code is where you learn accountants are a format
problem more than a math problem.
Money, one more time
Every amount is (decimal, currency); cross-currency arithmetic
throws; totals are aggregates of per-line decimals rounded once at the
edges (subtotβtaxβtotal each rounded to cents, in a fixed order β
rounding order is an API contract with your accountant).
Recap. A small state machine guarded by server-owned totals, a pure per-line tax waterfall driven by nexus and jurisdiction, conversion instead of duplication, machines-suggest-humans-confirm for inbound money, and exports designed for the person who'll actually read them.
Next: Chapter 06 β Accounts, hosting, DNS, email: from zero to a domain β buying the pieces, wiring them, and the mail-routing storm that taught us DNS respect.