Engineering
Money code is different
Every rule you relax elsewhere — approximate is fine, retry it, we can backfill later — becomes a support ticket when the numbers are currency.
01The float thing is real, and it is the least of it
Everyone knows not to store money in a float. Fewer people notice the second half: even with integer minor units, the moment you divide — splitting a payment, applying a percentage discount, allocating tax across line items — you have to decide where the remainder goes.
Split $10.00 three ways and you get 333, 333 and 333 cents. One cent is missing. It has to land somewhere, and "somewhere" is a business decision, not a rounding mode. Give it to the first party, the last, the largest — but decide, write it down, and test it. The bug is not the missing cent. The bug is that three different parts of the codebase each solved it differently.
// allocate a total across weights with no cent lost
function allocate(totalMinor: number, weights: number[]): number[] {
const sum = weights.reduce((a, b) => a + b, 0);
const shares = weights.map((w) => Math.floor((totalMinor * w) / sum));
let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);
// distribute the remainder one unit at a time, largest weight first
const order = weights.map((w, i) => i).sort((a, b) => weights[b] - weights[a]);
for (const i of order) {
if (remainder <= 0) break;
shares[i] += 1;
remainder -= 1;
}
return shares;
}02Retry is a dangerous default
In most of an application, a retry is free. The request failed, try again, no harm done. In payments a retry is a second charge, and the failure mode that produces it is the ugly one: the request succeeded, the response was lost, and your client has no way to tell that apart from a genuine failure.
The fix is an idempotency key generated by the caller, before the first attempt, and reused on every retry of that same logical operation. The server stores the key with the result. A second request with a seen key returns the stored result instead of doing the work again.
03Never update a row, append a fact
When a customer disputes a charge, the question is never "what is the balance now". It is "what was true on the 14th, and what changed it". A schema where the current state is a mutable row cannot answer that. A schema where the current state is derived from an append-only event log answers it trivially.
This is not event sourcing as an architecture. You do not need a framework or a bus. It is one extra table, written in the same transaction as the state change, that nothing ever updates or deletes. The cost is a few kilobytes per transaction. The benefit is that "we cannot reconstruct what happened" stops being a sentence you have to say to a customer.
04Be boring on purpose
- Integer minor units, currency stored alongside every amount. No exceptions, including in DTOs and JSON.
- A single allocation function for every split, discount and tax calculation in the codebase.
- Idempotency keys created at the boundary, persisted with their result.
- Webhooks as the source of truth for anything a payment provider owns. The redirect is a hint.
- An append-only ledger for every state change, written in the same transaction.
- Property-based tests over allocation. Total in must equal total out. Always. Fuzz it.
None of this is clever, and that is deliberate. Every clever thing I have seen in a payment path was eventually removed by someone at two in the morning.