Fakturly
An invoicing and payments system, built to the standards real financial software is held to.
The problem
Why it exists
Most invoicing tutorials get three things wrong. They store money as floats, and floats lose money. They let you edit an invoice after it's sent, which rewrites history. And their "not yours" check answers 403, which confirms the invoice exists. Walk the id range and you've counted the customers.
Fakturly gets those right and writes down why. It has two portals over one ledger, a nightly job that adds late interest under räntelagen, Stripe payments, credit notes and SIE export. Every decision is documented next to the alternative I rejected.
How it works
A Stripe payment, end to end
Step 1 of 6 · Client
Client pays
The client opens the Stripe payment link on their invoice and pays.
Decisions
What I chose, and what I didn't
Money is an integer count of öre
In floating point, 0.1 + 0.2 isn't 0.3. Over thousands of invoices those errors turn into money that doesn't reconcile. Every amount is an Int named so the unit is obvious, like amountOre or lateFeeOre, and it only becomes kronor at the display edge.
Rejected
Float or Decimal columns. Postgres NUMERIC is exact, but JavaScript has no decimal type, so the value would still pass through Number somewhere and lose precision.
Financial history is append-only
An auditor must be able to reconstruct what the system believed at any point in time. In Sweden that's a legal requirement for accounting records. Transactions and audit log rows are never edited, and a sent invoice is corrected with a credit note.
Rejected
Updating rows in place with an updatedAt timestamp. Simpler, but you can see that something changed, never what it was before.
Ownership checks answer 404, never 403
403 means "this exists, and it isn't yours". Invoice numbers are sequential by law, so confirming that 2026-0007 exists tells anyone how many invoices the business has sent this year. Login failures follow the same rule: same message, same status, same timing.
Rejected
Helpful, specific errors. They're easier to debug, and they list your customers for anyone patient enough to try ids.
Three layers of webhook idempotency
The event id is claimed before the work. The paid-status guard is part of the update itself. Unhandled event types return 200, so Stripe stops retrying them. The order matters: recording the event afterwards would let a crash in between turn the retry into a double payment.
Rejected
One "already processed?" check in the service. Two simultaneous retries both pass it.
By the numbers
What it adds up to
- backend integration tests against real Postgres and Redis
- 410
- architecture decisions, each with the alternative rejected
- 55
- concurrent deliveries of one event, exactly one payment row
- 5 → 1
- portals over one ledger
- 2
What I learned
The small rules were the hard ones. Getting a credit note to cancel its invoice to the öre, or making a failed login take the same time whether the email exists or not, took more thought than any feature. Writing each decision down next to what I rejected showed me which ones I couldn't defend.