dodil/erp-method

node_modules/.cache/erp-method/v0.1.15/modules/gl/module.yaml

# 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"