# Accounting — module spec (generated from modules/gl/module.yaml)
# A starting vocabulary for phase 2: correct it with the customer, then write ERP_GL.md.
schema: dodil.module-spec/v1
id: gl
name: Accounting
short: Accounting
version: 1
maturity: tested
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
  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:
  - 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
  - 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
  - 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
    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
    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
    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
    - 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
  - 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
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: https://blog.dodil.io/code/gl-suite-app/
    - id: gl-core
      kind: reference
      what: chart of accounts, journals and the derived ledger only
      url: https://blog.dodil.io/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
lifecycle: https://blog.dodil.io/modules/lifecycle.md
module_pattern: https://blog.dodil.io/modules/pattern.md
design_template: https://blog.dodil.io/modules/template.md
