DEV Community

Building FoxyInvoice - Chapter 5: The domain - invoices, tax, and money math

This series is written in the open, from a real production system. This chapter tours the domain logic: the invoice lifecycle, the tax waterfall, and the exports accountants actually want. [All chapters and diagrams live in the public repo.]

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

The invoice lifecycle, rendered live: Draft β†’ Sent β†’ Paid/Partial/Overdue β†’ Void, and how a quote becomes a real invoice on accept. 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.

Reading this and want to send a real invoice through this machine? Create a free workspace at foxyinvoice.com, then redeem founding code U8B4Z8S87X on the Upgrade page - 6 months of Pro, free, no card. If anything breaks, there's a feedback button in the app. I read every one.

Next: Chapter 6 - Accounts, hosting, DNS, email: from zero to a domain.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.