# Accounting (the general ledger) — the module source.
#
# Written against a package that already runs (/code/gl-suite-app), so `reference:` on each story
# says what is actually implemented rather than what would be nice.
#
# The three decisions an accountant checks first, and the platform reasons behind them:
# - the ledger is DERIVED from journal lines, never incremented — re-posting lands once
# - a closed period is an application control on the sole write path, not a table constraint;
# this platform has no enforced cross-table constraint, so the guard IS the lock
# - nothing is ever edited or deleted: a correction is a reversing journal
id: gl
name: Accounting
short: Accounting
version: 1
maturity: tested # 17 acceptance/access tests pass on a bucket — pattern §14
summary: >-
The books: every transaction as a balanced entry, account balances you can trust, month-end
close, and the reports your accountant asks for — fed by the rest of the business rather than
re-keyed from it.
facets:
domain: finance
industries: []
customer_words:
- "our books"
- "the accounts"
- "month-end"
- "profit and loss"
- "what does the accountant need"
- "the auditor asked for"
replaces: [spreadsheets, Xero, QuickBooks, Sage, NetSuite]
pillars: [sql, graph]
first_module_fit: medium
depends_on:
- module: business-partner
why: subledger open items and party reporting need one customer/supplier record
required: recommended
feeds: [ar, purchasing]
owns_master_data: [chart_of_accounts, period, fx_rate] # NOT tax codes — the package has none; invoicing keeps them
references_master_data: [business_partner]
actors:
- id: bookkeeper
name: Bookkeeper
does: posts journals, records what the other modules send, reconciles subledgers
sees: every account and journal
permissions: [gl:journal:read, gl:journal:post, gl:subledger:write, gl:report:read]
- id: accountant
name: Accountant / finance manager
does: sets up the chart of accounts, closes periods, reverses mistakes, runs the reports
sees: everything
permissions: [gl:journal:read, gl:journal:post, gl:reversal:approve, gl:account:write, gl:period:close, gl:fx:write, gl:report:read]
- id: auditor
name: Auditor / owner
does: reads the books and the trail behind any number
sees: everything, changes nothing
permissions: [gl:journal:read, gl:report:read]
- id: system
name: The system
does: posts what other modules send, re-derives balances, materialises reports
sees: everything, through its own service account
permissions: []
stories:
# ── person ────────────────────────────────────────────────────────────────────────────────
- id: GL-01
kind: person
actor: accountant
story: As the accountant, I want a chart of accounts that matches how we report, so that every entry lands somewhere meaningful.
given: a new set of books
when: I add accounts with their type and their parent
then: each account has a stable id, a type (asset, liability, equity, income, expense) and a place in the tree; an account already used by a journal cannot change type
screen: Accounts
reference: implemented
customer_varies: the chart itself — every country, accountant and trade has its own; the shape is ours, the contents are theirs
- id: GL-02
kind: person
actor: bookkeeper
story: As the bookkeeper, I want to post a balanced journal, so that the books record what happened.
given: lines totalling 1,000 debit and 1,000 credit, in an open period
when: I post it
then: it is accepted, each line lands against its account, the affected balances are re-derived, and an unbalanced journal is refused with the difference named
screen: Journal
reference: implemented
- id: GL-03
kind: person
actor: accountant
story: As the accountant, I want to correct a mistake with a reversing entry rather than an edit, so that the trail survives an audit.
given: a posted journal
when: I reverse it
then: a mirror journal is posted referencing the original, both stay readable, the balances return to where they were, and reversing twice does not double the correction
screen: Journal
reference: implemented
- id: GL-04
kind: person
actor: auditor
story: As the auditor, I want to see every entry behind an account balance, so that I can trace a number to its source.
given: an account with a balance
when: I open it
then: I see every line, its journal, its date, its source module and its reference, oldest to newest, with a running balance
screen: Account
reference: "not built — the package returns an account's BALANCE but has no route listing the lines behind it, and journal lines carry no reference column. This is the first thing to add."
- id: GL-05
kind: person
actor: accountant
story: As the accountant, I want a trial balance at a date, so that I can prove the books balance before I report anything.
given: posted journals
when: I run the trial balance
then: total debits equal total credits exactly — no float tail — and each account shows its balance in the reporting currency
screen: Reports
reference: "partial — the package snapshots a trial balance per PERIOD; there is no as-of-date version"
- id: GL-06
kind: person
actor: accountant
story: As the accountant, I want profit and loss and a balance sheet for a period, so that I can tell the owner how the business did.
given: a closed or open period
when: I run the reports
then: income less expenses gives the result for the period, the balance sheet balances, and each line can be opened to the accounts behind it
screen: Reports
reference: >-
partial — the package sums the all-time ledger and LABELS the result with a period; it does
not filter by period, and its statements carry no drill-down. What it reports is therefore
"since the last close", which is only the period because the close rolls the P&L away every
month (see known_gaps).
- id: GL-07
kind: person
actor: accountant
story: As the accountant, I want to close a month, so that nobody can change history after we have reported it.
given: a period with everything posted
when: I close it
then: further operational postings into that period are refused with the period named, the closing entry itself is allowed, and reopening is a deliberate act that is recorded
screen: Periods
reference: implemented
customer_varies: who may close and reopen, and whether a soft close (warn) comes before the hard one
- id: GL-08
kind: person
actor: bookkeeper
story: As the bookkeeper, I want to reconcile what the ledger says against the open invoices behind it, so that the receivables figure is not a guess.
given: a receivables control account and the open items behind it
when: I reconcile
then: I see the ledger balance, the sum of open items, and any difference broken down as breaks I can work through
screen: Reconcile
reference: implemented
- id: GL-09
kind: person
actor: accountant
story: As the accountant, I want balances in a second currency restated at the rate of the day, so that foreign balances are not carried at last year's rate.
given: a foreign-currency balance and a rate for the date
when: I revalue
then: the difference posts as a gain or loss journal, the run is recorded, and running it twice for the same date changes nothing
screen: Currencies
reference: implemented
- id: GL-10
kind: person
actor: accountant
story: As the accountant, I want to see a group of accounts rolled up, so that a report line means the same thing as the accounts under it.
given: a parent account with children
when: I open the rollup
then: the parent shows the total of everything beneath it, however deep, and the tree can be walked from any node
screen: Accounts
reference: >-
partial — the rollup reads a graph that nothing in the package builds (adding an account
writes parent_account_id but never an edge, and no CREATE GRAPH runs), and the walk is
capped at five levels.
note: "the account tree is the module's one graph use — a rollup is a walk, not a recursive query maintained by hand"
# ── system ────────────────────────────────────────────────────────────────────────────────
- id: GL-20
kind: system
actor: system
trigger: after every posting
story: The system re-derives the balances of the accounts a journal touched, so that a balance is always the sum of its lines and never drifts.
given: three postings landing on one account at the same time
when: they commit
then: the balance equals the signed sum of every posted line — computed with SUM, never incremented — so concurrent postings land once each
reference: implemented
- id: GL-21
kind: system
actor: system
trigger: on request, and after a close
story: The system materialises the trial balance and the statements, so that a report of a closed period is fast and stable rather than recomputed differently each time.
given: a closed period
when: the statements are materialised
then: the stored figures match a fresh computation, and re-materialising changes nothing
reference: "partial — materialising works on request; closing a period does NOT trigger it"
# ── integration ───────────────────────────────────────────────────────────────────────────
- id: GL-30
kind: integration
actor: system
trigger: when another module records something with an accounting consequence
story: Other modules post their entries here rather than keeping their own books, so that the accounts are complete without anyone re-keying.
given: invoicing issuing an invoice, or purchasing receiving a bill
when: it posts
then: a balanced journal exists with the source module and document named, its id derived from that document so re-posting changes nothing, and posting into a closed period is refused
owner: gl
reference: implemented (the posting route; the callers are the other modules' work)
- id: GL-31
kind: integration
actor: system
trigger: when an invoice or bill is issued, and when it settles
story: Modules that owe or are owed money keep their open items here, so that the control account can be reconciled against the detail.
given: an issued invoice
when: invoicing records the open item and later clears it
then: the subledger shows it open then cleared, tied to the party, and GL-08 can reconcile the control account against it
owner: gl
reference: implemented
workflows:
- entity: journal
states: [posted, reversed]
transitions:
- { from: none, to: posted, by: [bookkeeper, accountant, system], gate: "balanced, and the period is open (or this is the closing entry)", effect: "lines written, balances re-derived, audit row appended" }
- { from: posted, to: reversed, by: accountant, effect: "a mirror journal referencing the original; neither is deleted" }
note: "there is no draft and no edit: a journal exists or it does not, and a correction is another journal"
- entity: period
states: [open, closed]
transitions:
- { from: open, to: closed, by: accountant, gate: "the close journal has posted", effect: "operational postings into it are refused" }
- { from: closed, to: open, by: accountant, requires: reason, effect: "recorded — reopening a reported period is a decision somebody owns" }
entities:
- id: account
meaning: a bucket the business reports on — a bank account, a revenue line, a tax liability
natural_key: account_id (a number you choose; there are no sequences here)
key_attributes: [name, type, normal_balance, currency, parent_account_id, active]
relationships: ["-> parent account (the tree)", "1..n journal_line"]
master_data: SHARED — this module is the sole writer
reference_table: gl_accounts
- id: account_edge
meaning: the account tree, as a graph you can walk to any depth
natural_key: [parent_id, child_id]
master_data: module-owned
reference_table: gl_account_edges
- id: journal
meaning: one balanced accounting entry
natural_key: 'journal_id — a BIGINT the CALLER supplies. Derive it: int.from_bytes(blake2b(<document id>, digest_size=7)), so a module re-posting the same invoice writes the same journal. The package does not derive it for you.'
key_attributes: [status, period, source, reference, posted_at, posted_by, reverses_journal_id]
relationships: ["1..n journal_line", "0..1 reversal"]
master_data: module-owned
reference_table: journals
- id: journal_line
meaning: one side of an entry against one account
natural_key: [journal_id, line_no]
key_attributes: [account_id, debit, credit, line_memo] # no currency, fx_rate or party column — a known gap
master_data: module-owned
reference_table: journal_lines
- id: ledger
meaning: the balance of each account — DERIVED, never typed and never incremented
natural_key: account_id
key_attributes: [balance, currency, as_of]
master_data: module-owned (derived)
reference_table: ledger
note: "re-derived as SUM(debit) - SUM(credit) in a second committed transaction after the lines land"
- id: period
meaning: a month, and whether it is still open to postings
natural_key: period_id ("2026-09")
key_attributes: [status, opened_at, closed_at, locked_by]
master_data: SHARED — sole writer; every module's postings are gated by it
reference_table: periods
- id: journal_audit
meaning: what happened to a journal and when
natural_key: audit_id
key_attributes: [journal_id, event, event_at]
master_data: module-owned (append only)
reference_table: journal_audit
- id: subledger_item
meaning: one open item behind a control account — an unpaid invoice, an unpaid bill
natural_key: item_id
key_attributes: [subledger, control_account_id, party, amount, status, doc_date] # no due_date: aging is not possible from this table today
master_data: module-owned; written by the module that owns the document, through this module's route
reference_table: subledger_items
note: "carries a free-text party today; joining it to the shared partner record needs a bp_id column — a known gap"
- id: reconciliation
meaning: a comparison of a control account against the open items behind it, and what did not match
natural_key: recon_id
key_attributes: [control_account_id, gl_balance, subledger_sum, variance, reconciled, as_of]
master_data: module-owned
reference_table: reconciliation
- id: fx_rate
meaning: the rate used to restate a currency on a date
natural_key: [from_ccy, to_ccy, rate_date]
key_attributes: [rate, source]
master_data: SHARED — sole writer; invoicing and purchasing read it
reference_table: fx_rates
- id: fx_reval
meaning: a revaluation run and the gain or loss it posted
natural_key: reval_id
key_attributes: [account_id, ccy, orig_balance, revalued_balance, gain_loss, rate, period] # does NOT record the journal it posted
master_data: module-owned
reference_table: fx_reval
- id: statement
meaning: a materialised trial balance, profit and loss or balance sheet for a period
natural_key: "[period, account_id] for the trial balance; [period, section] for the two statements"
key_attributes: [lines, produced_at]
master_data: module-owned (derived)
reference_table: [trial_balance, income_statement, balance_sheet]
master_data:
owns: [gl_accounts, periods, fx_rates]
references:
- entity: business_partner
owner: business-partner
how: "subledger items name a party; today as free text, which is why the reconciliation cannot yet group by partner id"
if_absent: "reconciliation still works per control account, but not per customer or supplier"
warning: >-
The chart of accounts, the period lock and the FX rates are shared: every module that posts
money reads them and none of them writes them. A module keeping its own copy of any of the
three is how two reports of the same month stop agreeing.
jobs:
- story: GL-20
trigger: after every posting
runs: re-derive the touched accounts' balances
where_options: [in the same request, in a second committed transaction after the lines commit]
idempotent: "required — SUM over lines, never an increment"
- story: GL-21
trigger: on request, and after a close
runs: materialise the trial balance and statements
where_options: [in the request, always-on worker for a large chart]
idempotent: "keyed (report, period)"
seams:
- story: GL-30
with: ar
owner: gl
rule: >-
invoicing calls POST /journal-entry/journals (the routers are mounted under prefixes, and
four other routers expose a same-named route with a different body — this is the one to
use) with a flat body of journal_id, period, source, reference and lines. journal_id is a
BIGINT the caller derives from its own document id:
int.from_bytes(blake2b(document_id, digest_size=7)) — the same derivation the shared partner
record uses — so re-posting the same document writes the same journal. Invoicing never
writes journal tables.
- story: GL-31
with: ar
owner: gl
rule: >-
invoicing calls POST /subledger-reconciliation/subledger-items for its open items, with
item_id derived the same way, and clears them through the same route
- story: GL-30
with: purchasing
owner: gl
rule: same routes, same derivation, different source
screens:
- { id: accounts, name: Accounts, stories: [GL-01, GL-10], actors: [accountant] }
- { id: account, name: Account, stories: [GL-04], actors: [accountant, auditor, bookkeeper] }
- { id: journal, name: Journal, stories: [GL-02, GL-03], actors: [bookkeeper, accountant] }
- { id: reports, name: Reports, stories: [GL-05, GL-06], actors: [accountant, auditor] }
- { id: periods, name: Periods, stories: [GL-07], actors: [accountant] }
- { id: reconcile, name: Reconcile, stories: [GL-08], actors: [bookkeeper, accountant] }
- { id: currencies, name: Currencies, stories: [GL-09], actors: [accountant] }
permissions:
namespace: gl
catalog:
- gl:journal:read
- gl:journal:post
- gl:reversal:approve # the name the package uses; reversing a posted journal
- gl:account:write
- gl:period:close
- gl:subledger:write
- gl:fx:write
- gl:report:read
row_visibility:
rule: "anyone who can see the books sees all of them; the division is by verb, not by row"
enforced: >-
every WRITE carries its own permission — posting, reversing, closing and reopening a
period, changing the chart, writing FX rates, writing and reconciling subledger items.
Reads are open to any signed-in user, which is the intent: in a ledger the division is by
verb, not by row. (gl:report:read is catalogued and not enforced for that reason.)
not_found_not_forbidden: true
config_rows:
- { table: gl_accounts, changes: "the chart of accounts — the accounts, their names, their tree" }
# periods is NOT here on purpose: the lock moves only through the close and reopen routes.
# A period anyone can edit on a settings screen is not a lock.
- { table: fx_rates, changes: "the rates used to restate foreign balances" }
in_code_not_rows:
- "that a journal must balance"
- "that a closed period refuses operational postings, and that the closing entry is the exception"
- "that balances are derived with SUM and never incremented"
- "that a correction is a reversing journal, never an edit or a delete"
- "that a journal id derived from its source document makes re-posting a no-op"
tests:
acceptance: "one per story id above, named for it"
access:
- "a bookkeeper cannot close a period, reverse a journal or change the chart"
- "an auditor cannot post anything"
- "a request with no gateway identity is rejected"
invariants:
- "the trial balance nets to exactly zero — no float tail"
- "an unbalanced journal is refused"
- "a balance equals the signed sum of its lines after any number of concurrent postings"
- "an operational posting into a closed period is refused with the period named"
- "re-posting the same source document changes the ledger once"
- "a reversal returns the balance to its prior value, and reversing twice does not double it"
scale:
reads: >-
the tables reader serves 16 concurrent reads. NOT IN THE PACKAGE: it retries only SQLSTATE
40001 and holds no semaphore. Add a client-side semaphore below the cap (DB_CONCURRENCY=10)
and widen the retry to 08006/58030/XX000 plus the message markers before any wide workload.
hot_paths: ["the trial balance", "an account's line history", "the rollup walk over the account tree"]
workers: "none required; materialise statements for a large chart rather than recomputing on every read"
volumes: >-
a 60-wide posting storm was taken from lossy to clean with the semaphore plus a
serializable retry loop on one pinned-warm replica — that is the shape to copy for any
high-volume posting path.
platform_constraints:
- constraint: "There is no enforced cross-table constraint, so the period lock is an application control."
today: "it holds only because one function is the sole writer of journals — route every posting through it, including the other modules' postings"
- constraint: "No read-your-writes inside an open transaction."
today: "post the lines, commit, then re-derive balances in a second transaction with SUM()"
- constraint: "A zero DECIMAL renders as JSON 0, not \"0.00\"."
today: "assert that the trial balance nets to zero exactly, not that it prints a particular string"
# What differs between customers — with something for them to react to, and where one exists,
# something they can look up.
#
# A business user can rarely recall a policy on demand and can always react to one. "Most firms
# like yours chase at 7, 14 and 30 days — does that match?" gets a truer answer, faster, than
# "when do you chase?". Read it out, let them correct it, write down what they said.
#
# Two kinds of answer, and they must never be confused:
#
# standard: a NAMED external authority the customer can look up and hold us to — a
# directive, an accounting standard, a statute. Adopt it, or decide deliberately
# to differ and know why. If a line here cannot be cited, it does not belong.
# typical: arrangements we have SEEN. No authority, no survey behind them, no implication
# that a customer doing otherwise is doing it wrong. Conversation, not evidence.
#
# Calling convention a standard is how an implementation ends up defending a choice nobody
# ever made.
customer_varies:
- question: Do you keep your books here, or in Xero/QuickBooks with this feeding them?
changes: whether this module is in scope at all, or only the subledger and a hand-off
- question: Who is allowed to close a month, and who can reopen one?
changes: the accountant role and GL-07
- question: What does your chart of accounts look like today?
changes: gl_accounts — bring theirs across rather than imposing one
typical:
most_smes: their accountant's standard template, lightly edited — bring it across as-is
warning: a chart redesigned during implementation makes every prior-year comparison wrong
- question: Do you hold balances in more than one currency?
changes: GL-09 and the FX rate source
- question: Does your accountant want the trial balance and statements in a particular layout?
changes: the report definitions; the numbers underneath do not move
- question: What else should post automatically — invoices, bills, payroll, stock movements?
changes: which modules use GL-30, and the account map each of them needs
- question: Do you report by department, site or project as well as by account?
changes: dimensions on a journal line — decide before the first posting, because adding one later is a migration
typical:
single_site_services: account only
multi_site_or_project_work: one dimension — site, or project — and rarely more than one
warning: adding a dimension after the first posting means restating history
- question: Is your month-end a real close, or just a cut-off you report from?
changes: whether the close rolls the P&L away monthly (the package's default, and usually wrong) or only at year end
standard: >-
IAS 1 governs what a set of financial statements must contain; it does not require a monthly close. Monthly is an operating choice — the obligation is the reporting period itself.
typical:
audited_or_investor_reporting: a real monthly close with a lock
owner_managed: a cut-off they report from, closed properly only at year end
note: the package's default rolls the P&L away monthly, which is usually more than they want
- question: Which accounts do retained earnings, FX gains and losses post to?
changes: values the package currently hard-codes — put them in the account map
standard: >-
IAS 21 — the effects of changes in foreign exchange rates: which differences go to profit or loss and which to other comprehensive income.
reference:
packages:
- { id: gl-suite-app, kind: app, what: "the whole module in one app: chart, journals, ledger, periods, reports, subledger reconciliation, FX", url: /code/gl-suite-app/ }
- { id: gl-core, kind: reference, what: "chart of accounts, journals and the derived ledger only", url: /code/gl-core/ }
contracts: []
build_guide: null
known_gaps:
- "no build guide post yet — the package is the reference"
- "the suite covers GL-01/02/03/05/07/20 plus access; GL-04 is not built, and GL-06/08/09/10 have routes but no tests — tests/README.md lists them"
- "closing a month LOCKS it and no longer sweeps the P&L — that is a year-end act, decided by close_rolls_pl (year_end default | monthly) and financial_year_end_month in gl_settings. The old behaviour destroyed year-to-date and month-over-month reporting silently, because the books balance perfectly either way"
- "no dimensions on a journal line (cost centre, department, project), so 'profit by site' cannot be answered and adding one later is a migration"
- "no aging on subledger items (no due date), so GL-08 gives a variance rather than 30/60/90"
- "no bank reconciliation — no statement import, no cleared flag — which for a small business is the most-used screen in a ledger"
- "no opening-balance import, which is step one of every migration off another system"
- "no cash-flow statement, comparatives, budgets, recurring or accrual journals, or attachments on a journal"
- "subledger items carry a free-text party, not a partner id, so reconciliation cannot group by customer"
- "no payroll, fixed assets or depreciation"
- "no country tax returns (VAT/GST filing) — the tax codes are here, the filing is not"
- "permission names in the package predate the namespaced convention"
- "tables are unprefixed (journals, ledger, periods) — prefix the module-owned ones when generating into a shared bucket"