← All work
CompletedSolo project · live demo

Fakturly

An invoicing and payments system, built to the standards real financial software is held to.

BunFastifyPrismaPostgreSQLRedisStripeReact

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.