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

  1. Trial balance — every account and its balance; debits must equal credits
  2. Balance sheet — assets, liabilities, equity at a point in time
  3. Income statement (P&L) — revenue and expenses over a period
  4. Cash flow statement — cash movements, categorised operating/investing/financing
  5. 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

  1. Signed amount, SUM = 0, enforced before insert.
  2. Journal lines are append-only. No UPDATE, no DELETE, ever.
  3. No balance columns as truth. Derive, or maintain a rebuildable projection with a reconciliation job.
  4. Every posting carries: company, ledger, period, posting date, currency triple, source document.
  5. Every automated posting must be idempotent — keyed by the source document, so a retried interface doesn’t post twice.
  6. The posting service is the only writer. No module writes journal lines directly.

Next