The opening claim
Every few months a client asks me for the same button. An Edit button, on an invoice, or on a stock movement, or on a payment. He wants it because a number is wrong and he wants to fix it. It sounds like the most reasonable request in the world.
I refuse. Not because it is hard, but because it is the single fastest way to destroy the one property that makes a financial system worth anything: the ability to answer what happened, and when, and who did it.
The moment a money row can be silently changed, your database stops being a record and becomes an opinion. It cannot settle a dispute, because it only ever shows the last thing somebody typed. Every prior truth is gone, and gone without a trace.
The mechanism 🧠
Accounting solved this centuries before we had databases. Double entry bookkeeping does not let you erase. Every movement is written twice, as a debit against one account and a credit against another, and the two must balance. When something is wrong you do not reach back and rewrite history. You write a new entry that cancels the old one, then write the correct one. The mistake stays visible forever, and so does the correction.
That is the whole idea, and it maps perfectly onto a database as an append only ledger. Rows are inserted. Rows are never updated and never deleted. A balance is not a column you maintain, it is a number you derive by summing the movements.
The correction mechanism is called a compensating transaction. A customer was charged 4,000 DZD instead of 3,000 DZD. You do not update the row to 3,000. You insert a reversal of 4,000 and then insert the correct 1,000 adjustment, or reverse and re enter the whole thing, depending on how your accounts are structured. Anyone reading the ledger later sees exactly what occurred: a charge, a mistake, a reversal, a correction, each with a timestamp and an actor.
This buys you four things that a mutable table can never provide.
You get auditability, because history is intact. You get idempotency, because a ledger keyed on an external reference can reject a duplicate write instead of double charging somebody. You get debuggability, because when a balance is wrong you can replay the movements and find the exact entry that broke it. And you get safe concurrency, because inserts do not fight each other the way read modify write updates do. Two workers updating the same balance column will silently lose one of the writes. Two workers inserting two rows lose nothing.
There is one more reason, and in practice it is the one that matters most. Mutable money is fraud with the evidence already deleted. A dishonest employee with an Edit button does not need to be clever. He edits the number, and there is nothing left to find.
Comparative Breakdown
| Property | Mutable row (the Edit button) | Append only ledger |
|---|---|---|
| Fixing a mistake | UPDATE the row, old value gone | INSERT a reversal, both visible |
| "What did this say last week?" | Unanswerable | Query by created_at |
| Who changed it | Unknown unless you bolted on triggers | Every row carries its actor |
| Duplicate submission | Charges twice | Rejected on a unique external reference |
| Two writers at once | Lost update, silent | Two rows, nothing lost |
| Balance | A column that drifts out of sync | Derived by SUM, always correct |
| Dispute with a customer | Your word against his | Timestamped sequence of events |
| Insider fraud | Edit and move on | Requires a visible reversal entry |
| Cost when wrong | Silent, discovered months later | Loud, discovered immediately |
The Algerian reality
This is not academic here. It is the difference between keeping your margin and eating it.
Almost everything is cash on delivery, which means money and product move at the doorstep, far away from your server, handled by a courier who does not work for you. The gap between "order placed" and "cash actually in hand" is days long and full of events: the customer does not answer, the parcel goes out for a second attempt, it is refused at the door, it comes back, the courier remits a batch of collections at the end of the week minus his fee.
Every one of those is a financial event. If your system models the order as a single row with a status column that you keep updating, you have thrown away the entire history of what happened to that money. When the courier's remittance does not match your expected total at the end of the week, and it will not match, you have no way to find the discrepancy. You are left arguing from memory.
Now add the second local factor: partial cash. A customer pays 5,000 DZD of a 12,000 DZD order at the door, promises the rest, pays 4,000 DZD a week later in the shop, and the last 3,000 DZD never arrives. On a mutable row, somebody types 9,000 into an amount field and the story is lost. On a ledger, you have three receipts and an outstanding balance that computes itself.
Third factor: the notebook. Most Algerian merchants track money in a paper cahier, and paper has one property developers underrate. You cannot cleanly erase it. A crossed out line with a correction next to it is an append only ledger implemented in ink. When you replace that notebook with software that has an Edit button, you have given the owner something strictly worse than what he had before, and he will feel it the first time he tries to reconstruct a bad week.
And the disputes are real. A customer insists he paid. A worker insists he handed over the cash. Without a timestamped sequence, the owner absorbs the loss, because the person who cannot prove anything always pays.
What to actually do 🛠️
Model money as events, never as state. The table below is the whole pattern. Amounts are integers in centimes, because floating point money is its own separate disaster.
create table ledger_entries (
id uuid primary key default gen_random_uuid(),
account_id uuid not null references accounts(id),
amount_cents bigint not null check (amount_cents > 0),
direction text not null check (direction in ('credit', 'debit')),
currency char(3) not null default 'DZD',
-- What this movement is about: an order, a courier remittance, a refund.
reference_type text not null,
reference_id uuid not null,
-- Idempotency: the same external event can never be written twice.
external_ref text unique,
-- A reversal points at the entry it cancels. NULL for normal movements.
reverses_id uuid references ledger_entries(id),
actor_id uuid references profiles(id),
memo text,
created_at timestamptz not null default now()
);
create index on ledger_entries (account_id, created_at desc);
create index on ledger_entries (reference_type, reference_id);
Take away UPDATE and DELETE at the database level. Do not rely on your application being well behaved. Revoke the permissions, or add a trigger that raises an exception on update or delete. A rule the database enforces is a rule. A rule your ORM enforces is a suggestion.
create or replace function ledger_is_immutable() returns trigger as $$
begin
raise exception 'ledger_entries is append only: write a compensating entry instead';
end;
$$ language plpgsql;
create trigger ledger_no_update before update or delete on ledger_entries
for each row execute function ledger_is_immutable();
Derive the balance, do not store it. A balance is a query, not a column.
select coalesce(sum(case when direction = 'credit' then amount_cents
else -amount_cents end), 0) as balance_cents
from ledger_entries
where account_id = $1;
If that sum gets slow, add a materialized snapshot with the entry id it was computed through, and sum forward from there. Cache the number, never let the cache become the truth.
Give the user the Edit button he asked for, and make it write a reversal. This is the important part. Do not lecture the client about immutability. He does not care, and he is right not to care. Label the button Correct, let him type the right number, and have it insert a reversal plus a new entry underneath. He gets his fix. You keep the history. Everybody wins and nobody had to sit through a lecture about double entry.
Show the history in the interface. A corrected invoice should visibly say so, with the original, the correction, the time and the person. That visible trail is what ends a dispute in your client's favour, and it is the feature he will thank you for later even though he never asked for it.
TL;DR 🧾
A money record is a fact about the past, and the past does not accept edits. Never UPDATE money, only INSERT the opposite. If somebody asks for an Edit button, build it, name it Correct, and let it write a reversal.
LINKEDIN VERSION
Every few months a client asks me for an Edit button on an invoice. A number is wrong, he wants to fix it. It is the most reasonable request in the world.
I always refuse.
The moment a money row can be silently changed, your database stops being a record and becomes an opinion. It only ever shows the last thing somebody typed. It cannot settle a dispute, because every earlier truth is gone without a trace.
Accounting solved this centuries ago. Double entry does not let you erase. You do not reach back and rewrite history, you write a new entry that cancels the old one. The mistake stays visible, and so does the correction.
In a database that is an append only ledger. Rows are inserted, never updated. A balance is not a column you maintain, it is a SUM you derive. Corrections are compensating entries.
This matters more in Algeria, not less. Cash on delivery means money moves at a doorstep, days later, through a courier who does not work for you: failed attempts, refusals, partial payments, weekly remittances minus a fee. Every one is a financial event. Model the order as one row with a status you keep overwriting and you have destroyed the history of your own money. When the courier's weekly total does not match yours, and it will not match, you will be arguing from memory.
So build the button. Call it Correct. Have it write a reversal.