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:

  1. In-memory / columnar DB (HANA, ClickHouse, DuckDB-style) — just aggregate; this is what S/4HANA does and why it deleted dozens of totals tables.
  2. Materialised balance table, updated in the same transaction as the journal, with a nightly reconciliation job that rebuilds and compares. Classic and safe.
  3. 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:

  1. Company/ledger exists and user is authorised
  2. Period open for this account type and this user’s group
  3. All accounts exist, not blocked, valid on the posting date
  4. Field status: required dimensions present, forbidden dimensions absent
  5. Currency valid; exchange rate available for posting date
  6. Validation/substitution rules pass
  7. Sum of local amounts = 0
  8. Number range assignment (last, right before commit)
  9. Insert header + lines atomically
  10. 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

Next