Double-Entry Bookkeeping for Developers
If you learn one thing from this repo, learn this page. Double-entry is the invariant the whole ERP is built to protect.
The rule
Every transaction affects at least two accounts, and total debits equal total credits.
sum(debits) - sum(credits) = 0 ← always, per document, per legal entity, per currency
That’s it. Everything else is convention.
Debits and credits are not “plus” and “minus”
The most common misunderstanding. Debit (Dr) means left side, credit (Cr) means right side. What they do depends on the account type:
| Account type | Debit | Credit | Normal balance |
|---|---|---|---|
| Asset | ↑ increase | ↓ decrease | Debit |
| Expense | ↑ increase | ↓ decrease | Debit |
| Liability | ↓ decrease | ↑ increase | Credit |
| Equity | ↓ decrease | ↑ increase | Credit |
| Revenue | ↓ decrease | ↑ increase | Credit |
Mnemonic: DEAD CLIC — Debit increases Expenses, Assets, Drawings; Credit increases Liabilities, Income, Capital.
The implementation trick
Don’t store debit and credit as two columns. Store one signed amount:
amount > 0 = debit
amount < 0 = credit
Then the invariant is trivially SUM(amount) = 0 per document, which the database can check. Display converts back to Dr/Cr columns.
This single decision eliminates an entire class of bugs (both columns populated, both zero, sign confusion in aggregation).
Worked examples
1. Sell goods for 1,000 on credit, cost 600
Accounts receivable Dr 1,000
Revenue Cr 1,000 ← the sale
Cost of goods sold Dr 600
Inventory Cr 600 ← the cost of the sale
Two paired entries. Profit = 400 emerges automatically. Note revenue and COGS are recognised together — the matching principle.
2. Customer pays
Bank Dr 1,000
Accounts receivable Cr 1,000
No profit impact. Cash and profit are different things; this is why a profitable company can go bankrupt.
3. Buy raw material for 500 on credit
Inventory Dr 500
Accounts payable Cr 500
No expense yet. Buying inventory is swapping one asset for a liability. The expense happens when it’s consumed or sold.
4. Pay salaries 10,000 with 1,500 tax withheld
Salary expense Dr 10,000
Tax payable Cr 1,500
Bank Cr 8,500
One debit, two credits. “Double entry” means balanced, not exactly two lines.
5. Depreciation of a machine
Depreciation expense Dr 2,000
Accumulated depreciation Cr 2,000
No cash moves. Accrual accounting: cost recognised in the period it’s used.
6. Accrue an expense not yet invoiced
Utilities expense Dr 3,000
Accrued liabilities Cr 3,000
…reversed next period when the real invoice arrives.
Accrual vs cash accounting
| Cash basis | Accrual basis | |
|---|---|---|
| Revenue recognised | When cash received | When earned (goods delivered/service performed) |
| Expense recognised | When cash paid | When incurred |
| Required for | Tiny businesses, some tax regimes | All companies of substance, IFRS and GAAP |
ERPs are accrual systems. The gap between accrual and cash is what accruals, prepayments, deferrals, and provisions exist to bridge. If you don’t understand why an ERP posts an expense before any cash moves, re-read this section.
The five statements built from these entries
- Trial balance — every account and its balance; debits must equal credits
- Balance sheet — assets, liabilities, equity at a point in time
- Income statement (P&L) — revenue and expenses over a period
- Cash flow statement — cash movements, categorised operating/investing/financing
- Statement of changes in equity
All five are views over journal lines. There is no separate “balance sheet table”. If you’re building an ERP: the journal line table is your only source of truth.
The journal line table
CREATE TABLE journal_header (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL,
fiscal_year INT NOT NULL,
period INT NOT NULL,
document_number VARCHAR(20) NOT NULL,
document_type VARCHAR(4) NOT NULL,
document_date DATE NOT NULL,
posting_date DATE NOT NULL,
currency_code CHAR(3) NOT NULL,
reference VARCHAR(50),
reversed_by BIGINT REFERENCES journal_header(id),
reverses BIGINT REFERENCES journal_header(id),
source_module VARCHAR(10), -- AR, AP, MM, SD, PY, GL
source_document VARCHAR(30), -- the originating business document
created_by VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, fiscal_year, document_number)
);
CREATE TABLE journal_line (
id BIGSERIAL PRIMARY KEY,
header_id BIGINT NOT NULL REFERENCES journal_header(id),
line_number INT NOT NULL,
account_id BIGINT NOT NULL REFERENCES gl_account(id),
amount NUMERIC(19,4) NOT NULL, -- signed: + debit, − credit
currency_code CHAR(3) NOT NULL,
amount_local NUMERIC(19,4) NOT NULL,
amount_group NUMERIC(19,4),
-- subledger
partner_id BIGINT, -- customer/vendor when reconciliation account
-- dimensions
cost_center_id BIGINT, profit_center_id BIGINT, project_id BIGINT,
-- tax
tax_code VARCHAR(4), tax_base_amount NUMERIC(19,4),
-- open item management
clearing_id BIGINT, cleared_at DATE,
due_date DATE,
text VARCHAR(255),
UNIQUE (header_id, line_number)
);
Note what’s absent: no debit/credit pair, no balance column, no UPDATE-able amount. Postings are inserted, never modified.
Enforcing the invariant
-- After insert of all lines, per document:
SELECT header_id
FROM journal_line
GROUP BY header_id
HAVING SUM(amount_local) <> 0; -- must return zero rows
Enforce it with a deferred constraint trigger, a check at commit, or a posting service that refuses to write an unbalanced document. Never let an unbalanced document reach the table. Once it’s in, every downstream report is wrong and finding it is miserable.
Balance must hold in local currency primarily. Transaction currency should also balance for single-currency documents; for multi-currency documents you may need a rounding line to force balance in group currency.
Reversal, not deletion
-- Original
INSERT journal_header(document_number='JV0001', ...)
INSERT journal_line(header='JV0001', account=600100, amount= 5000)
INSERT journal_line(header='JV0001', account=200100, amount=-5000)
-- Reversal
INSERT journal_header(document_number='JV0002', reverses='JV0001', ...)
INSERT journal_line(header='JV0002', account=600100, amount=-5000)
INSERT journal_line(header='JV0002', account=200100, amount= 5000)
UPDATE journal_header SET reversed_by='JV0002' WHERE document_number='JV0001'
Both documents exist forever. The net effect is zero. The audit trail shows the mistake and the fix.
Reversal rules:
- Reversal posts to an open period. If the original period is closed, the reversal goes into the current period, and there’s a config choice about whether to allow “reversal in the original period”.
- A reversed document cannot be reversed again.
- A cleared open item must be un-cleared (“reset clearing”) before its document can be reversed.
Subledgers
Not everything posts directly to the GL. Subledgers hold detail; the GL holds the summary:
AR subledger: per customer, per invoice, per due date → GL account 113000 (recon)
AP subledger: per vendor, per invoice → GL account 200100 (recon)
Asset ledger: per asset, per depreciation area → GL accounts 15xxxx/16xxxx
Inventory: per material, per plant, per batch → GL account 12xxxx
The reconciliation account balance must always equal the sum of its subledger. Every ERP has a reconciliation report for this, and a discrepancy is a serious incident.
Multi-ledger / parallel accounting
A group may need to report under IFRS and local GAAP and tax rules simultaneously, with different depreciation, different revenue recognition, different provisions.
Two approaches:
- Parallel ledgers — the same document posts to multiple ledgers; ledger-specific documents post to one. Clean, and the modern default.
- Parallel accounts — separate account ranges per GAAP within one ledger. Older, messier, still common.
Design note: add ledger_id to your journal tables from day one, defaulting to a single “leading ledger”. Retrofitting parallel ledgers is a rewrite.
Rules for developers
- Signed amount,
SUM = 0, enforced before insert. - Journal lines are append-only. No UPDATE, no DELETE, ever.
- No balance columns as truth. Derive, or maintain a rebuildable projection with a reconciliation job.
- Every posting carries: company, ledger, period, posting date, currency triple, source document.
- Every automated posting must be idempotent — keyed by the source document, so a retried interface doesn’t post twice.
- The posting service is the only writer. No module writes journal lines directly.