4 Oct 2026
Garment Manufacturing ERP
Making every money movement post itself
Why the garment ERP puts a double-entry ledger at the centre instead of bolting accounting on as a report, and the handful of rules that keep its books balanced.
The temptation in an ERP is to treat accounting as a reporting layer: read the invoices table, sum it, call it revenue. That falls apart the first time someone voids an invoice or issues a credit note, because the report and the documents now disagree and nobody can say which is right. In the garment ERP the ledger sits at the centre instead. Every document that moves money posts a balanced journal entry, and every financial report reads only from the ledger.
One function, one invariant
There is exactly one way to write to the ledger. It refuses an entry with fewer than two lines, a negative amount, or a line that is both a debit and a credit, and it refuses any entry whose debits and credits do not sum to the same figure. All of that is checked before a single row is written.
@transaction.atomic
def post_journal_entry(*, date, memo="", reference="", lines):
"""Post a balanced double-entry journal. The one invariant the whole ledger
rests on: Σ debit == Σ credit. Anything else is rejected before a row is written."""
if len(lines) < 2:
raise ValidationError("A journal entry needs at least two lines.")
total_debit = total_credit = ZERO
for line in lines:
debit, credit = _q2(line.get("debit")), _q2(line.get("credit"))
if debit < 0 or credit < 0:
raise ValidationError("Debit and credit cannot be negative.")
if (debit > 0) == (credit > 0): # both zero, or both non-zero
raise ValidationError("Each line must be either a debit or a credit, not both.")
total_debit += debit
total_credit += credit
if total_debit != total_credit:
raise ValidationError(f"Unbalanced entry: debits {total_debit} != credits {total_credit}.")
...The debit-or-credit rule is also a database constraint on the journal line table, so a line written by any other path, a shell session or a future bug, still cannot be both.
models.CheckConstraint(
check=(Q(debit__gt=0) & Q(credit=0)) | (Q(debit=0) & Q(credit__gt=0)),
name="journal_line_debit_xor_credit",
)Documents post themselves
An invoice is recognised the moment it leaves draft: debit accounts receivable for the gross, credit sales for the net, credit VAT payable for the tax, and no VAT leg at all when the rate is zero. It happens exactly once, because the invoice’s link to its journal entry is the guard. Purchases, payroll runs and stock movements follow the same pattern, which is why the trial balance in the screenshots matches to the paisa.
def post_invoice_to_ledger(invoice):
"""Dr AR (gross) / Cr Sales (net) / Cr VAT Payable."""
lines = [
{"account": account_by_code(AR_CODE), "debit": invoice.total_amount},
{"account": account_by_code(SALES_CODE), "credit": invoice.subtotal},
]
if invoice.vat_amount > 0:
lines.append({"account": account_by_code(VAT_CODE), "credit": invoice.vat_amount})
return post_journal_entry(date=invoice.issue_date, reference=invoice.invoice_no, lines=lines, ...)Reversals, never edits
A posted entry is never changed. Undoing one posts a new entry, on the date of the correction, with every line’s debit and credit swapped, referencing the original. Both stay on the books and the accounts net back to where they were, which is what lets an auditor reconstruct what the books said on any given day.
def reverse_journal_entry(entry, *, date, memo=""):
return post_journal_entry(
date=date,
memo=memo or f"Reversal of {entry.entry_no}",
reference=entry.entry_no or "",
lines=[
{"account": l.account, "debit": l.credit, "credit": l.debit, "memo": l.memo}
for l in entry.lines.all()
],
)Gapless invoice numbers
Nepal’s tax office expects tax invoices numbered per fiscal year with no gaps. The counter is a row per fiscal year, locked with select_for_update inside the caller’s transaction. Concurrent invoices queue on that lock, and if the invoice insert rolls back, the counter rolls back with it, so a failed request never burns a number.
Anything that leaves the system waits for the commit. The sync to the tax authority’s billing system is queued with transaction.on_commit, so an invoice that rolls back is never reported to the government.
Reports read the ledger, not the documents
The trial balance, profit and loss, balance sheet and cash book are all sums over journal lines. The trial balance carries an is_balanced flag that is simply total debits compared with total credits; with every write going through the function above, it stays true. The report stops being a reconciliation and becomes a proof.