ARTICLE
Projects

Instalment schedules, and the guard that stops a cancelled invoice turning paid

29 October 2026Majed Alandajani

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

Have a project in mind?
Tell me what you want to build. Your first 15 minutes of consulting are free.
Book a consultation