← Blog
How it works

A fix does not reach the invoices already written

“An invoice still shows the wrong amount after the bug that caused it was fixed, and the PDF the customer downloads shows the same wrong figure.”

On 1 September a developer deployed a fix for how their app stored credits on invoices. The next morning they filed a follow-up issue against their own repository: the owner still saw a wrong invoice total, "because the fix changed generation only." (The repository has since stopped being public, so the issue is no longer linked here. The quotations are from the issue as read in full on 1 October 2026.)

The issue lists why the old figure survives, and every reason is a write that will never happen again. Invoice rows are inserted with ON CONFLICT DO NOTHING by an idempotent job, "so an existing row is never rewritten." A new exchange-rate column was added and "deliberately does not backfill it." And the PDF route "redirects to the stored object and never re-renders, so the wrong PDF is served forever too." The affected invoice held a credit count where a currency amount belonged, which the issue puts at "about 100,000 times the true taka amount", and its PDF said the same.

None of this is a mistake in the fix. It is what a stored figure is. A total that is computed when you look at it is corrected by correcting the code. A total that was written into a row is corrected only by something that writes that row again, and the PDF rendered from it is one more stored copy with the same property. The rest of this post is the same thing, read in the source of two open-source invoicing systems, where the stored figure is not written once but adjusted over time.

What the column actually holds

Invoice Ninja stores an invoice's outstanding amount in a balance column. The service that changes it is a 36-line file whose run() method makes exactly one write. At tag v5.13.20, app/Services/Invoice/UpdateBalance.php line 28:

$this->invoice->increment('balance', floatval($this->balance_adjustment));

increment is the database-level operation: add this much to what is there. The caller passes a delta, and the column takes it. Nothing in that file reads the invoice's credits or its payments, and nothing adds them up.

So balance is not the invoice's balance in the arithmetic sense. It is the accumulated result of every adjustment that happened to run against that row, in the order it ran. If the right adjustment never ran, or ran twice, the column is wrong, and nothing in this path compares it with anything.

The credit side is built the same way. app/Models/Credit.php line 440, inside updateBalance:

$this->balance = $this->balance + $balance_adjustment;

One credit, several stored totals

Applying a credit to an invoice is a single user action, and app/Services/Credit/ApplyPayment.php turns it into a fan-out of adjustments. run() decrements the credit's balance and increments its paid_to_date (lines 72 and 73), then addPaymentToLedger() adjusts three figures on the client, two on the invoice, and appends a credit ledger row:

$this->invoice
         ->service()
         ->updateBalance($this->amount_applied * -1)
         ->updatePaidToDate($this->amount_applied)
         ->updateStatus()
         ->save();

Note also line 48. run() takes a local copy, $invoice_balance = $this->invoice->balance;, and decrements that local as it decides how much to apply. The local is what the branch at line 64 tests. The stored column is moved separately, later, by the service call above. Two pieces of arithmetic about the same quantity, in the same method, that are not the same variable.

The bug class is visible in the file

Lines 124 to 126 of that same file are commented out, and the comment says why:

// $this->payment
//          ->ledger()
//          ->updatePaymentBalance($this->amount_applied * -1, "ApplyPaymentCredit"); // this duplicated the company ledger paid to date amount.

One event was reaching a stored total through two code paths, and the repair was to comment one of them out.

That is the natural repair when a total is the sum of whatever writes reached it: find every writer and reason about how many times each one runs. It fixes the next credit. It does not, by itself, fix the totals that were already counted twice. That needs a separate pass that works the right figure out from something else, which is the same position the issue at the top of this post was in.

The recomputing version exists, and nothing calls it

The same class contains a method that does it the other way. private function applyPaymentToCredit() at line 88 appends a negative line item to the credit and then recomputes the credit from its line items:

$this->credit = $this->credit->calc()->getCredit();

calc() builds an InvoiceSum over the document and returns a total derived from its parts. That is the version that cannot drift. The string applyPaymentToCredit occurs exactly once in the file, as its own definition, and the method is private, so nothing outside the class can reach it either. It is dead code sitting next to the live incremental path.

ERPNext takes the other design

ERPNext treats the stored figure as a cache rather than the truth. The truth is a table of Payment Ledger Entry rows, and an invoice's outstanding_amount is refreshed from them. At tag v16.20.0, erpnext/accounts/utils.py line 2146, update_voucher_outstanding queries the ledger for a voucher and writes the answer back:

frappe.db.set_value(
	voucher_type,
	voucher_no,
	"outstanding_amount",
	outstanding_amount,
)

This is a recomputation, which means drift here is repairable: run the function again and the column agrees with the ledger. It applies to three document types only, per OUTSTANDING_DOCTYPES at line 58, a frozen set of Sales Invoice, Purchase Invoice and Fees.

A cache still has to be told when to refresh, and that is where ERPNext's assumption sits. create_payment_ledger_entry at line 2123 takes an update_outstanding parameter that defaults to "Yes", and the reconciliation path at line 567 passes the other value:

create_payment_ledger_entry(gl_map, update_outstanding="No", cancel=0, adv_adj=1)

Two lines later:

# Only update outstanding for newly linked vouchers

On that path, lines 560 to 567 delete the payment's ledger rows and rebuild them from the payment document. Only the invoices being linked now get their outstanding amount re-read. Invoices the payment was already linked to get their rows rebuilt and keep the figure they had. That is the right figure as long as the rebuild reproduces their rows exactly, which is the expected case and the reason this is an optimisation rather than a bug. It is still an assumption, and a stored figure is only as right as the assumptions about when it gets rewritten.

A rebuild tool is a statement about the design

ERPNext also ships a document type for rewriting the figures in bulk. repost_payment_ledger.py defines VOUCHER_TYPES as Sales Invoice, Purchase Invoice, Payment Entry and Journal Entry. Its get_vouchers (line 92) selects every submitted voucher for a company with a posting date at or after a date the user picks, and repost_ple_for_voucher (line 16) deletes that voucher's payment ledger entries and builds them again from the document:

def repost_ple_for_voucher(voucher_type, voucher_no, gle_map=None):
	if voucher_type and voucher_no and gle_map:
		_delete_pl_entries(voucher_type, voucher_no)
		_delete_adv_pl_entries(voucher_type, voucher_no)
		create_payment_ledger_entry(gle_map, cancel=0)

That last call leaves update_outstanding at its default of "Yes", and the ledger entry's own hook (payment_ledger_entry.py lines 168 to 176) calls update_voucher_outstanding when that flag is "Yes". So a repost rebuilds the ledger and then re-reads every affected invoice's outstanding amount from it. It runs as a background job and carries a status of Queued, Failed or Completed (line 73).

A per-invoice refresh already exists, so this is not the only repair available. What a company-wide, date-ranged rebuild with a queue and an error log does say is that the vendor expects stored figures to fall out of step often enough, and in ways hard enough to find one at a time, that rebuilding everything from a date is the practical answer. That is the repair the issue at the top asked for too: a one-shot job over every row the fix could not reach.

What to ask when a figure will not come right

Four questions, in this order, and the answers are in the source rather than the interface.

Is the figure stored or derived? In both systems here, and in the issue, it is stored. A derived figure cannot be stale, so if correcting an input does not move the output, it is stored. A PDF that was rendered and saved counts as stored.

If it is stored, is it written once, written as a delta, or recomputed? Look for an insert that ignores conflicts, for increment, for +=, for = x + y on the column. Written once means a fix never reaches existing rows. A delta means an error stays until something else overwrites it.

If it is a delta, how many places write it? That is what the Invoice Ninja comment is a record of someone checking.

If it is recomputed, what triggers the refresh and what is its scope? In ERPNext the trigger exists, and on reconciliation it is limited to the vouchers being linked, on the assumption that the others came out of the rebuild unchanged.

What this post does not establish

It was written by reading one public bug report and the source of two systems at pinned tags, invoiceninja/invoiceninja at v5.13.20 and frappe/erpnext at v16.20.0. Every code quotation above was taken from the file at that tag, not from a branch tip, because a branch tip moves and a quotation should not.

Neither system was installed, run, or made to produce a wrong balance. The claims are about what the code says it does, which is a weaker thing than a reproduction, and they are specific to those tags: both projects move quickly and any of these lines may read differently in a later release. The issue is described as its author wrote it; its repository's code was not read.

It also makes no claim about how common this is. One report is one report, so there is no count of affected projects here and no assertion that one design is more widely used than the other. Neither design is wrong, either. An increment is cheap and a recomputation is a query, and which one a system can afford depends on how much it is holding.