General Ledger (GL)
The GL is the book of record. Every other module is a feeder into it.
What lives in the GL
| Object | Purpose |
|---|---|
| Journal entry / document | A balanced set of lines |
| GL account balance | Derived: per account, period, dimension |
| Open items | Unmatched lines on open-item-managed accounts |
| Trial balance | All accounts, opening + movements + closing |
| Ledger | A complete set of books under one accounting standard |
Ways entries reach the GL
Automatic (95%+) Manual (<5%)
┌──────────────────────┐ ┌─────────────────────┐
│ AR invoices │ │ Journal vouchers │
│ AP invoices │ │ Accruals │
│ Goods movements │───▶ GL ◀──────│ Reclassifications │
│ Production settlement│ │ Corrections │
│ Payroll posting │ │ Audit adjustments │
│ Depreciation run │ │ Provisions │
│ Bank statements │ └─────────────────────┘
│ Tax calculation │
└──────────────────────┘
A healthy ERP has very few manual journals. A high manual-journal count is an audit red flag — it means processes aren’t integrated and controls are weak. Auditors specifically test manual journals.
Journal entry lifecycle
Draft / Parked ──▶ Submitted ──▶ Approved ──▶ POSTED ──▶ (Reversed)
│ │
editable immutable
deletable append-only
Parked/held documents let a clerk enter incomplete data and someone else complete or approve it. They have a document number but no ledger effect and don’t appear in balances. This supports the four-eyes principle: preparer ≠ poster.
Recurring entries are templates that post on a schedule (monthly rent, amortisation) — defined once, executed by a batch job with a run schedule and a validity range.
Posting keys and entry types
Classic ERPs encode the “what kind of line is this” into a posting key:
40 GL account, debit
50 GL account, credit
01 Customer invoice (debit)
15 Customer payment (credit)
21 Vendor credit memo (debit)
31 Vendor invoice (credit)
70 Asset debit
75 Asset credit
The posting key determines: debit/credit, which account type is allowed, and which fields are required. If you’re building your own, the equivalent is a line_type enum that drives validation.
Open item management and clearing
Some accounts need to track which specific item is still outstanding, not just a net balance:
- All AR and AP subledger items
- Clearing accounts (GR/IR, bank clearing, payroll clearing)
GR/IR account 231000
Item 1: Goods receipt PO 4500001 Cr 5,000 OPEN
Item 2: Invoice PO 4500001 Dr 5,000 ← clears item 1
After clearing: both marked cleared, clearing document created, net 0
Clearing rules:
- Items clear when they net to zero (or within a tolerance)
- Partial clearing creates a residual item (new open item for the remainder) or a partial payment (both stay open, linked)
- Clearing generates its own document with a clearing date
- Reset clearing un-does it, needed before a reversal
- An aged, uncleared balance means something is broken: goods received but never invoiced, payments never matched, duplicate postings
Aged GR/IR analysis and unreconciled bank clearing are two of the first things any new controller looks at.
Balances: derived, not stored
The right query:
SELECT
account_id,
SUM(amount_local) FILTER (WHERE posting_date < :period_start) AS opening,
SUM(amount_local) FILTER (WHERE amount_local > 0
AND posting_date BETWEEN :period_start AND :period_end) AS debits,
SUM(-amount_local) FILTER (WHERE amount_local < 0
AND posting_date BETWEEN :period_start AND :period_end) AS credits,
SUM(amount_local) FILTER (WHERE posting_date <= :period_end) AS closing
FROM journal_line jl JOIN journal_header jh ON jh.id = jl.header_id
WHERE jh.company_id = :company AND jh.ledger_id = :ledger
GROUP BY account_id;
At scale this is expensive. Options:
- In-memory / columnar DB (HANA, ClickHouse, DuckDB-style) — just aggregate; this is what S/4HANA does and why it deleted dozens of totals tables.
- Materialised balance table, updated in the same transaction as the journal, with a nightly reconciliation job that rebuilds and compares. Classic and safe.
- Monthly snapshot table — store closing balance per account/period/dimension at close, query current period from journals only. Best cost/benefit for most builds.
Never let the balance table be the only truth. Always be able to rebuild from journal lines, and run a job that proves they agree.
The trial balance
The fundamental report:
Account Name Opening Debit Credit Closing
100000 Cash 250,000 80,000 45,000 285,000
113000 Trade AR 410,000 190,000 160,000 440,000
120000 Inventory 600,000 220,000 180,000 640,000
200100 Trade AP (320,000) 90,000 140,000 (370,000)
300000 Share capital (500,000) 0 0 (500,000)
320000 Retained earnings (330,000) 0 0 (330,000)
400000 Revenue 0 0 190,000 (190,000)
500000 COGS 0 130,000 0 130,000
600000 Operating expenses 0 25,000 0 25,000
─────────────────────────────────────────
TOTAL 0 735,000 735,000 0
Total closing must be zero. If it isn’t, something is deeply wrong — unbalanced document, corrupted balance table, or a rounding line that never got posted. Build an automated check for this; run it nightly.
Line item vs summary posting
A design tension:
- Line item detail in the GL — full traceability, huge tables
- Summarised GL, detail in subledger — smaller GL, but you must drill to the subledger for detail
Classic ERPs summarised aggressively (one GL line per invoice, per tax code) to keep the GL small. In-memory systems post full detail because storage and aggregation stopped being the constraint. Know which mode your system is in — it changes what “drill down” is possible.
The GL API contract
If you build a posting service, this is the shape:
POST /gl/documents
{
"companyId": 1000,
"ledgerId": "0L",
"documentType": "SA",
"documentDate": "2026-03-15",
"postingDate": "2026-03-31",
"currency": "BDT",
"reference": "ACCR-2026-03-UTIL",
"sourceModule": "GL",
"idempotencyKey": "accrual-utilities-2026-03", ← required
"lines": [
{ "account": "600300", "amount": 3000.00, "costCenter": "ADMIN", "text": "Utilities accrual" },
{ "account": "210100", "amount": -3000.00, "text": "Accrued liabilities" }
]
}
Validation, in order:
- Company/ledger exists and user is authorised
- Period open for this account type and this user’s group
- All accounts exist, not blocked, valid on the posting date
- Field status: required dimensions present, forbidden dimensions absent
- Currency valid; exchange rate available for posting date
- Validation/substitution rules pass
- Sum of local amounts = 0
- Number range assignment (last, right before commit)
- Insert header + lines atomically
- Return document number
Idempotency is mandatory for anything called by an interface. Same key → return the original document, do not post again. See Concurrency, Locking and Idempotency.
Common GL problems you’ll be asked to fix
| Symptom | Usual cause |
|---|---|
| Subledger ≠ reconciliation account | Direct manual posting to a recon account, or a failed interface |
| Trial balance doesn’t total zero | Unbalanced document from a custom posting, or corrupt totals table |
| Posting date in a closed period | Batch job with a hardcoded date |
| Duplicate journals | Interface without idempotency, retried after a timeout |
| GR/IR growing forever | Missing invoices, wrong quantity receipts, no clearing job scheduled |
| Balances change for closed months | Someone reopened a period; or balances computed at read time with a changing rate table |
| Slow trial balance | Missing index on (company, ledger, posting_date, account), or scanning all history |