Instalment schedules, and the guard that stops a cancelled invoice turning paid
Instalment invoicing is a common requirement: 40% up front, 30% on delivery, 30% on acceptance.
The arithmetic: its one trap
Split the amount by the percentages, add the pieces back up, and the total will not equal the original. A unit goes missing in the rounding.
The fix is to compute every instalment except the last, then let the last one be whatever remains:
last instalment = total - sum of the previous ones
The sum then matches the original for any set of percentages.
Work in the smallest unit: halalas, not riyals. Decimal arithmetic on money accumulates error with every operation.
One test guards all of this: the stored instalments must sum to the invoice total, whatever the split.
The arithmetic itself
// Compute in the smallest unit, and make the last one the remainder,
// so the sum always equals the original
function plan(totalHalalas, percents) {
const parts = [];
let used = 0;
for (const p of percents.slice(0, -1)) {
const amount = Math.round(totalHalalas * p / 100);
parts.push(amount);
used += amount;
}
parts.push(totalHalalas - used); // the last absorbs the rounding
return parts;
}
plan(2000000, [40, 30, 30]); // 20,000 SAR
The real danger
The arithmetic turns out to be the easy part. An invoice moves through states: draft, sent, partially paid, paid, cancelled. Several paths in the system can close one: a payment recorded by hand, a notification arriving from the payment gateway, or the scheduled job that reconciles pending items. Any of them can receive an event for an invoice that was cancelled after it went out.
What happens without a guard
An invoice gets cancelled, and a late payment notification for it arrives anyway. The path sees money coming in and closes the invoice, flipping the cancelled invoice to paid. Your reports now show revenue with nothing behind it.
The guard
Every update that closes an invoice carries the required state inside the statement itself:
UPDATE invoices SET status = 'paid'
WHERE id = ? AND status IN ('sent','partial')
A cancelled invoice fails the condition, so the update touches nothing. And there is no separate check for whoever writes the fourth path to forget.
The rule
Put the state condition inside the update statement. A check on a separate line leaves a race window, because the state can change between the read and the write. When the condition lives in the statement, the database evaluates it and applies the write as one step, and the window is gone.
Related reading
Tell me what you want to build. Your first 15 minutes of consulting are free.
Book a consultation