An employee applies for two days' leave. A manager approves it. The system records two days used. Simple, until one afternoon the ledger shows four.

Nobody had approved anything twice on purpose. The approve action had run twice — a slow connection, a second tap, a retried request — and each run did exactly what it was built to do. Two identical transactions, both valid, both wrong together.

This is a short account of how that was fixed in the HR system at Rafiq Jamil Textile Industries, and why the fix is not where most people would put it.

Where the balance actually lives

The first thing to understand is that the system never stores a leave balance. There is no column that says “this employee has 11 days left.” There is a baseline for each employee and a table of transactions — a grant, a use, an adjustment — and the balance is the sum of that table at the moment you ask.

That design is deliberate. A stored balance is a claim; a ledger is evidence. When a dispute arrives — and in payroll, disputes always arrive — you can show every line that produced the number. But it has a cost. If a bad row gets into the ledger, it does not corrupt one field. It corrupts every balance calculated from that day forward.

So the question was never “how do we stop the button being pressed twice.” It was “how do we make it impossible for the ledger to hold two rows for the same approval.”

The fix that was tried first

The instinct is to guard the interface. Disable the button after the first click. Show a spinner. Debounce the handler.

Every one of those is worth doing, and none of them is a guarantee. A disabled button does nothing about a request that was already in flight. It does nothing about a retry the browser makes on its own. It does nothing about a second tab, a second manager, or a script calling the same endpoint next year when nobody remembers the button existed.

A UI guard protects against the one path you imagined. The ledger needs protection against every path.

What was actually built

Three layers, added over four commits as each one turned out to be necessary.

The constraint. Every leave transaction that comes from an approval carries the request it came from. A unique index on that reference — only where it is present — means the database will refuse a second transaction for the same request no matter who sends it or how. This is the layer that matters. It is not a convention the code is expected to follow; it is a rule the code cannot break.

One honest note: that index was created directly in the database when the duplicates were found, and it exists in the schema snapshot rather than in a numbered migration file. The guarantee is real. The paperwork is less tidy than I would like.

The check before the write. The application also looks for an existing transaction before inserting one. This is not for safety — the constraint already provides that. It is for behaviour: a duplicate can be recognised and skipped quietly instead of arriving as a database error.

The cross-request check. Six weeks later a different duplicate turned up. Not the same request approved twice, but two separate requests for the same days. The unique index cannot see that, because the references are different. So a third layer checks for overlapping approved requests and refuses with a clear error naming the one that already exists.

That layer was a lesson in itself. The first two were built for the failure that had happened. The third was built for the failure the first two structurally could not catch — and it took real duplicates to reveal it.

Why the order matters

If you only take one thing from this: put the guarantee in the layer that cannot be bypassed, then make the layers above it polite.

The constraint is the guard. The pre-check is manners. The button state is decoration. Build them in that order and you can lose the top two without losing correctness. Build them in the opposite order — which is what most systems do, because the button is where the problem is visible — and the first unexpected path through the system writes a row you will be explaining to someone in six months.