dodil/erp-method

modules/gl/module.yaml

# Accounting (the general ledger): the module source.
#
# Version 1 was written against a package that already runs (/code/gl-suite-app, /code/gl-core), so
# `reference:` on each story says what that package implements.
#
# Version 2 (2026-10-22, DRAFT for sign-off; the brick erp-brick-gl is not built). What changed, and why:
#   - the ledger is the SOLE writer of journal entries. Invoicing (ar), payments and tax post through ONE
#     source route, POST /journal-entry/journals, idempotent by source: journal_id = derived_id(source_document,
#     event) (erp_platform.seam.derived_id, blake2b 7 bytes), so a retry or a re-post lands once. The body was
#     read from the BUILT callers (erp-brick-ar, erp-brick-payments), not guessed (seams below, with routes)
#   - the account map moves here (tax's signed seam already says so): a posting line may name a PURPOSE
#     (receivable, revenue, tax + tax_code, bank, bad_debt, deposit, fx_gain, fx_loss, rounding,
#     retained_earnings) instead of an account, resolved by the map row in force on the journal date
#   - the period lock is a guard on the write path (DataK3 has no cross-table constraint): one use case posts,
#     and it and the close both write the period row, so a close racing a posting conflicts and the loser retries
#   - FX rates by date: GET /fx-rates/{currency}?on= answers the rate in force (tax reads it, TAX-22); a foreign
#     posting carries document and functional amounts, balanced in both
#   - subledger items are retired: open items are payments' settlement (decided 2026-10-18, payments owns every
#     allocation); the receivable control account is reconciled against payments' GET /settlement, and the
#     payables control account the same way against purchasing's GET /bills/open (wave 2): no module sends gl open items
#   - fiscal years, a year-end close (P&L to retained earnings, a year-end act, not monthly), manual journals with
#     an owner-controlled second approver, journal numbers per book and year, cost centres (shared key)
#   - conventions (shared.yaml): one business_timezone, readiness {gate, met, reason, fix}, dated config rows,
#     owner-held gl:settings:controls, exact-grant machine permissions, AI steps as suggestions a person accepts
#   - migration: the Odoo slice (account.account, account.journal, posted account.move/.line, fiscal years and
#     lock dates, res.currency.rate, opening balances at cut-over), the trial balance reconciled per account and
#     period to the penny and signed by the owner (GL-34, GL-35, workflow migration_run, section `migration:`)
#
# Version 3 (2026-10-22, DRAFT for re-sign: SAP FI-GL at mid-market scope). What changed, and why:
#   - company codes (shared key company_code): several legal entities in one bucket, each with its own functional
#     currency, chart, periods, number series and fiscal year; a posting that names no company lands in the default
#     company, so every built caller (ar, payments, purchasing, inventory) keeps its body unchanged
#   - charts: an operational chart per company (companies may share one), a GROUP chart mapped account by account
#     (dated) for consolidation, and an alternative (country) account number per company for statutory exports
#   - ledgers: one leading ledger and any number of non-leading ones (IFRS vs local GAAP). A journal that names no
#     ledger is common to every ledger of its company and stored ONCE; a ledger-specific journal (a valuation
#     difference, a ledger's depreciation) counts only in its ledger. Revaluation runs per ledger, by its method
#   - the universal line (gl_split_line, the ACDOCA analogue): every journal line resolves to one or more split lines
#     carrying profit centre, segment, partner company and movement type; balances derive from it. Document
#     splitting (on per company from a fiscal year) splits receivable, payable, tax and bank lines by the base lines'
#     profit centres and adds zero-balance clearing lines, so every segment balances on every journal
#   - recurring entries, accruals/deferrals with schedules, auto-reversing accruals, rule-based allocations and
#     assessments (simulate, post, reverse, re-run), open-item FX revaluation (FAGL_FCV), intercompany journals with
#     automatic due-to/due-from and matching, financial statement versions (incl. the cash-flow statement), budgets,
#     carry-forward as derived opening balances (no closing journal), year-end audit adjustments, a closing cockpit
#     with gates and a second-person sign-off, GoBD/FEC-grade audit (a hash chain per company and an FEC / SAF-T export)
#   - inbound for fixed-assets (POST /journal-entry/journals with ledger, asset_id and movement_type) and for
#     consolidation (GET /group-reporting/balances); the migration keys are renamed (load_gl_from_odoo, ...) because
#     use-case names are unique across the method
#   - the seams with fixed-assets name it with `with:` (both branches merged, 2026-10-22)
#
# The decisions an accountant checks first:
#   - the ledger is DERIVED from journal lines with SUM, never incremented; re-posting lands once
#   - a closed period refuses operational postings; the closing entry is the one exception
#   - nothing is ever edited or deleted: a correction is a reversing journal
#   - every journal balances to the penny in its currency AND in the functional currency
#
# APQC PCF v8.0: 9.3 Perform general accounting and reporting (9.3.2 general accounting, 9.3.4 period-end close).
# The element ids are to be confirmed against the PCF file before sign-off (processes[].pcf_id is null until then).

# Version 9 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 8 stays the signed one until it is signed):
#   - seams only, no process change: one entry per caller, named in with: (what erp_platform.contract reads): GET
#     /fx-rates/{currency} for expenses; GET /cost-centres/{cost_centre} for expenses, manufacturing, payroll and
#     timesheets; GET /periods/open for expenses, manufacturing, payroll, pos and timesheets; GET
#     /companies/{company_code} for payroll and pos
#   - GL-30 manufacturing row: journal_id = derived_id(source_document, event), the schema's rule, and the prose
#     agrees: an activity journal's source_document is the confirmation ('<order>:activity:<op>:<n>[:reverse]'), a
#     settlement's is the order, so each confirmation has its own id (the prose's derived_id('mfg:<order>',
#     'activity:<op>:<n>') could not be expressed by the schema; taken literally it gave every confirmation of an
#     order one id)
#   - GL-30 pos row: a supplement's source_document is 'pos:<shop>:<date>:supplement:<n>' (the prose's
#     derived_id(..., 'supplement:<n>') could not be expressed by the schema); the purposes a cash move posts with
#     are named
#
# Version 8 (2026-10-25, DRAFT for sign-off, wave 4; seams only, no process change):
#   - GL-30 seam rows for source_module timesheets (project_labour / labour_recovery) and payroll (gross pay,
#     employer NI and pension, PAYE/NI, pension and net pay liabilities)
#
# Version 7 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
#   - purchasing reads GET /cost-centres/{cost_centre} at approve_bill (its gate cost_centre_open) and GET
#     /companies/{company_code} for our name on the VAT content check: two GL-61 seam rows
#   - GET /companies/{company_code} takes an optional on= (YYYY-MM-DD, default today): statutory_tax_rate is the row
#     in force on that date, so consolidation's deferred tax uses the rate at the period end
#   - GET /accounts/{account_id}/lines: with group_account_id the path carries the same group account id and each
#     line names its local account_id, so consolidation's drill-down call is exact
#   - GL-30 seam rows for source_module pos, expenses and manufacturing with their purposes (wave 3), and the
#     account map qualifier for an expense category
#   - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
id: gl
name: Accounting
short: Accounting
version: 9
maturity: tested           # gl-suite-app implements version 1 (17 acceptance/access tests on a bucket, pattern §14); the brick is not built; version 3 is a draft for re-sign
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"
    - "year-end"
    - "profit and loss"
    - "trial balance"
    - "what does the accountant need"
    - "the auditor asked for"
    - "we have two companies"
    - "IFRS and local GAAP"
    - "intercompany recharges"
    - "allocate overheads to departments"
    - "accruals and prepayments"
    - "segment reporting"
    - "the month-end checklist"
    - "the tax auditor wants the FEC"
  replaces: [spreadsheets, Xero, QuickBooks, Sage, NetSuite, Odoo Accounting, SAP Business One, SAP S/4HANA Finance]
  pillars: [sql, graph]
  first_module_fit: medium
  depends_on:
    - module: business-partner
      why: a receivable or payable line names the party (bp_id), so the control account can be read per customer
      required: recommended
    - module: tax
      why: the account map holds an account per tax_code; tax owns the codes
      required: recommended
  feeds: [ar, payments, tax, purchasing, inventory, consolidation]   # and fixed-assets (parallel branch)
  owns_master_data: [company, chart_of_accounts, group_chart, ledger, account_map, period, fiscal_year, fx_rate, cost_centre, profit_centre, segment, financial_statement_version]
  references_master_data: [business_partner, tax_code]

actors:
  - id: bookkeeper
    name: Bookkeeper
    does: proposes manual journals, reconciles the receivable control account, reads what the other modules posted
    sees: every account and journal
    permissions: [gl:journal:read, gl:journal:write, gl:period:read, gl:fx:read, gl:reconcile:run, gl:report:read, gl:company:read, gl:close:task]
  - id: accountant
    name: Accountant / finance manager
    does: sets up companies, ledgers, the charts and the account map, approves manual journals, recurring entries, accruals and allocation cycles, reverses mistakes, enters rates, revalues, matches intercompany, runs the closing cockpit, closes periods and years, signs off a close, runs the reports
    sees: the companies the owner gives them access to (all by default)
    permissions: [gl:journal:read, gl:journal:write, gl:journal:approve, gl:reversal:approve, gl:account:write, gl:company:read, gl:company:write, gl:fsv:write, gl:period:read, gl:period:close, gl:period:reopen, gl:adjustment:post, gl:fx:read, gl:fx:write, gl:reval:run, gl:reconcile:run, gl:report:read, gl:recurring:write, gl:recurring:approve, gl:recurring:run, gl:accrual:write, gl:accrual:approve, gl:accrual:run, gl:allocation:approve, gl:allocation:run, gl:budget:approve, gl:close:manage, gl:close:task, gl:close:signoff, gl:audit:export, gl:settings:write, gl:migration:read]
  - id: controller
    name: Controller (management accountant)
    does: keeps the allocation cycles and the statistical key figures, simulates and posts allocations, keeps the budget, reads plan against actual by cost and profit centre
    sees: the companies the owner gives them access to (all by default)
    permissions: [gl:journal:read, gl:period:read, gl:company:read, gl:report:read, gl:allocation:write, gl:allocation:simulate, gl:allocation:run, gl:keyfigure:write, gl:budget:write, gl:close:task]
    note: "a controller who wrote a cycle cannot approve it (gl:allocation:approve is the accountant's, and the approver must be another person)"
  - id: auditor
    name: Auditor
    does: reads the books and the trail behind any number, verifies the hash chain, exports the FEC or SAF-T file
    sees: everything, changes nothing
    permissions: [gl:journal:read, gl:period:read, gl:fx:read, gl:report:read, gl:company:read, gl:audit:export, gl:audit:verify, gl:migration:read]
  - id: owner
    name: The owner (or whoever the owner names)
    does: holds the controls on the people who keep the books (time zone, second approver for manual journals, recurring entries, accruals, allocation cycles and reopening, journal numbering, document splitting, who may see which company, the AI step modes) and signs the migration reconciliation
    sees: everything
    permissions: [gl:journal:read, gl:report:read, gl:company:read, gl:settings:controls, gl:migration:read, gl:migration:sign]
    note: "the controls are never held by whoever they control: a bookkeeper or accountant holding gl:settings:controls is a configuration error the access test refuses"
  - id: system
    name: The system
    does: posts what other modules send (fixed-assets included), splits and re-derives balances, derives the carry-forward, posts pending reversals when a period opens, re-splits open items by profit centre at period end, checks the closing cockpit's system tasks, materialises statements, serves consolidation its group data, checks a manual journal before approval, numbers journals, loads and reconciles a migration
    sees: everything, through its own service account
    permissions: [gl:journal:read, gl:period:read, gl:fx:read, gl:company:read, gl:journal:post, gl:balance:derive, gl:carry_forward:derive, gl:resplit:post, gl:close:check, gl:group_data:extract, gl:statement:materialise, gl:journal:check, gl:number_series:claim, gl:migration:load, gl:migration:reconcile, gl:cost_centre:read]
    note: "each is an exact grant to one service client, never reached by a wildcard (permissions.machine); gl:journal:post is held by each posting module's client (ar, payments, tax, purchasing, inventory, fixed-assets), gl:group_data:extract by consolidation's client only"

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 code, type and parent
    then: each account has a stable account_id, the customer's own code, a type (asset, liability, equity, income, expense) and a place in the tree; an account already used by a journal cannot change type or code, only its name or be deactivated (dated, after today)
    screen: Accounts
    reference: implemented (v1, without code or the used-account rule)
    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 propose a manual journal (an accrual, a correction, a payroll summary), so that the books record what no other module posts.
    given: lines totalling 1,000.00 debit and 1,000.00 credit, dated in an open period and not after today
    when: I submit it
    then: it is proposed; an unbalanced journal is refused with the difference named; where the owner asks for a second approver it waits for one, otherwise it posts at once with its journal number
    screen: Journal
    reference: "partial: v1 posts at once with no approval and no number"
  - 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, with a reason, dated today or the first day of the next open period (an accrual reversing itself)
    then: a mirror journal keyed derived_id(journal_id, "reversal") is posted referencing the original, both stay readable, the balances return to where they were, and reversing twice (or twice at once) posts one reversal
    screen: Journal
    reference: implemented (v1, without the reason or the reversal date)
  - 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 for a date range
    then: I see every line, its journal and number, its date, its source module and document, its party and memo, oldest to newest, with a running balance that ends on the account's balance
    screen: Account
    reference: "not built in v1 (no route lists the lines; lines carry no reference); v6: the route is GET /accounts/{account_id}/lines (seam GL-04), also read by consolidation's report drill-down"
  - id: GL-05
    kind: person
    actor: accountant
    story: As the accountant, I want a trial balance at a date or for a period, so that I can prove the books balance before I report anything.
    given: posted journals
    when: I run the trial balance for a company and ledger (the leading ledger by default) as at a date, or for a period
    then: total debits equal total credits exactly (no float tail) and each account shows its opening (the carry-forward, GL-23), period movement and closing balance in the company's functional currency, and in its own currency where it has one; with splitting on, the same holds per segment and per profit centre; a ledger's figures are its common journals plus its own ledger-specific ones, never another ledger's
    screen: Reports
    reference: "partial: v1 snapshots per PERIOD; no as-at-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 for a company and ledger through a published financial statement version (GL-53), optionally by segment or profit centre, optionally beside the prior year or the budget (GL-55)
    then: income less expenses for the period alone (filtered by journal date, not since the last close) gives the result; the balance sheet balances with the year's result shown as its own line (carried into retained earnings by the carry-forward, GL-23); each line opens to the accounts behind it and each account to its lines (GL-04); the report names the version and the ledger it was run on
    screen: Reports
    reference: "partial: v1 sums the all-time ledger and labels it with a period; no drill-down"
  - 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 of company UK01 whose readiness is met (the prior period closed, no manual journal waiting, the receivable and payables reconciled or their difference accepted, foreign balances and open items revalued where revaluation is on, recurring entries, accruals, allocations and depreciation posted, intercompany matched, open items re-split by profit centre where splitting is on, and the closing cockpit signed off where it is on)
    when: I close it (a period is closed per company; DE01's 2026-09 stays open until its own close)
    then: further operational postings dated in that period are refused with the period named and the fix (date it in an open period); the year-end closing entry is still allowed; reopening is a deliberate act with a reason, by someone other than the closer where the owner asks for it, and recorded
    screen: Periods
    reference: implemented (v1 lock, without readiness or the second person)
    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 invoices posted to it
    when: I reconcile as at a date
    then: I see the ledger balance, payments' outstanding for those invoices as at the same date, and the difference broken down per invoice (posted but not in payments, settled in payments but not posted, amounts that differ), each break with its fix; with purchasing installed, the payables control account the same way against purchasing's open bills, per bill
    screen: Reconcile
    reference: "partial: v1 reconciles against its own subledger_items table, which v2 retires"
  - 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 USD 10,000.00 balance on bank account 1210 (monetary, not open-item managed) of UK01, carried at 7,800.00 GBP, and a closing rate of 0.790000 on 2026-09-30
    when: I revalue balances for UK01 as at 2026-09-30, for each ledger of the company
    then: 'per ledger, by its revaluation method (closing_rate: the IAS 21 closing rate, gains and losses; losses_only: only a loss is recognised, as local GAAPs such as HGB section 256a require for items over a year): a ledger-specific journal of 100.00 to the map''s fx_gain, reversing on 2026-10-01 (posted then or held until 2026-10 opens, GL-24); the run is keyed (company, ledger, key date, balances), records its journals, and running it twice posts once; receivable and payable control accounts are not revalued here but per open item (GL-48)'
    screen: Currencies
    reference: "implemented in v1, without the reversal or the journal link"
  - 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 as at a date
    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: v1 reads a graph nothing builds, capped at five levels"
    note: "the account tree is the module's one graph use: adding an account writes its edge in the same use case"
  - id: GL-11
    kind: person
    actor: accountant
    story: As the accountant, I want manual journals approved by someone other than whoever proposed them, when the owner asks for it, so that one person cannot move money in the books alone.
    given: a proposed manual journal and the owner's manual_journal_approval set to above_threshold or always
    when: I approve or reject it
    then: approved, it posts with its number and the approver recorded; rejected, it stays readable with the reason; the proposer cannot approve it, matched on BOTH email and sub, so a second login of the same person is still the proposer
    screen: Journal
    reference: not built
  - id: GL-12
    kind: person
    actor: accountant
    story: As the accountant, I want one account map that says where invoicing, payments and tax post, so that a new tax code or bank account is set up once and every module agrees.
    given: the purposes the composed modules post (receivable, revenue, deposit, tax per tax_code, bank per bank account, bad_debt, fx_gain, fx_loss, rounding, retained_earnings, split_clearing, ic_receivable and ic_payable per partner company, accrual_balance, prepaid, deferred_revenue, assessment; and fixed-assets' asset_cost, accumulated_depreciation, depreciation_expense, asset_clearing, disposal_gain, disposal_loss per asset class)
    when: I map a purpose to an account of the chart, dated from a day after today (the map is per chart, so companies sharing a chart share it)
    then: postings dated on or after that day resolve the purpose to that account; earlier postings are untouched; a source posting naming a purpose with no row in force is refused with the purpose named, and readiness shows every unmapped purpose before the first posting
    screen: Account map
    reference: "not built (invoicing keeps its own ar_account_map today: an open customer decision)"
  - id: GL-13
    kind: person
    actor: accountant
    story: As the accountant, I want to open and close a financial year, so that the year's result moves to retained earnings once, at year end.
    given: FY2026 of UK01 whose twelve periods are closed, the audit adjustments posted (GL-56), and retained_earnings mapped
    when: I close the year
    then: 'the carry-forward (GL-23) is derived for every ledger of the company: each balance-sheet account opens FY2027 with its closing balance (per profit centre, segment and partner company), and the sum of income and expense opens FY2027 on retained earnings (per segment where splitting is on); no journal is posted (SAP balance carry-forward, not a closing entry); the year is closed to adjustments; the next year opens if it is not; closing twice derives the same rows'
    screen: Periods
    reference: "partial: v1 rolls the P&L away every month with a journal; v3 carries forward without one"
  - id: GL-14
    kind: person
    actor: owner
    story: As the owner, I want the controls on the people who keep the books held by me, so that a bookkeeper cannot switch off the second approver for their own journals.
    given: an accountant holding gl:settings:write and an owner holding gl:settings:controls
    when: the accountant tries to set manual_journal_approval to none, then the owner sets it to above_threshold from tomorrow
    then: the accountant is refused (a control); the owner's row takes effect from tomorrow, never today or earlier; the history of every control row is readable
    screen: Settings
    reference: not built
  - id: GL-15
    kind: person
    actor: accountant
    story: As the accountant, I want an AI check of a proposed manual journal before I approve it, so that a duplicate, an odd account pair or an amount out of pattern is caught first.
    given: a proposed manual journal and the check switched on (suggest)
    when: the check runs
    then: findings are listed with their evidence; I accept or dismiss each, recorded with who and when; nothing in the journal changes, and approval never waits on a finding
    screen: Journal
    reference: not built
  - id: GL-35
    kind: person
    actor: owner
    story: As the owner moving off Odoo, I want the trial balance per account and period reconciled against Odoo to the penny, and to sign it, so that I know the books came across whole before I switch.
    given: a loaded migration run
    when: the checks are computed and I sign
    then: every check is equal or explained where explaining is allowed; the trial balance per account and period must be equal and cannot be explained away; signing records who, when and the run's checksums
    screen: Migration
    reference: not built

  # ── person: SAP FI-GL at mid-market scope (version 3) ──────────────────────────────────────
  - id: GL-40
    kind: person
    actor: accountant
    story: As the accountant, I want each legal entity kept as its own company code in the same books, so that each files its own accounts while the group reads them together.
    given: UK01 (GBP, chart MAIN, year ending December) exists as the default company; DE01 is a German GmbH reporting in EUR
    when: I add company DE01 with its name, country DE, functional currency EUR, chart MAIN, financial year end month 12, its business partner (bp_id) and its ledgers
    then: >-
      DE01 has its own fiscal years, periods, number series (per company, book and year), FX revaluation and close;
      every posting carries a company_code, and one that names none lands in the default company, so a single-company
      bucket and every built caller work unchanged; company_code and functional currency are fixed once a journal uses
      them; a code that is not 1 to 4 upper-case letters or digits is refused 422; the company's bp_id is what lets a
      sale to that partner be recognised as intercompany (GL-51)
    screen: Companies
    reference: not built
  - id: GL-41
    kind: person
    actor: accountant
    story: As the accountant, I want each company's chart mapped to one group chart, and a local account number where the country asks for one, so that the group reports one way while each company files its own way.
    given: chart MAIN (operational) used by UK01 and DE01, chart GROUP (kind group), and DE01's tax adviser expecting SKR04 numbers in its exports
    when: I map account 4000 Revenue to group account G-4000 from 2026-11-01 and give account 4000 the alternative number 4400 in DE01
    then: >-
      the group extract (GL-38) reports 4000 under G-4000 from that date; every account with a posting and no group
      account is listed by the readiness gate group_mapped (consolidation cannot collect until it is met); a group
      account cannot be posted to directly (422 gate group_chart_not_postable); the FEC and SAF-T exports of DE01 (GL-59)
      use 4400; a mapping or alternative number is dated after today and its history is readable
    screen: Accounts
    reference: not built
  - id: GL-42
    kind: person
    actor: accountant
    story: As the accountant, I want a leading ledger and a local-GAAP ledger kept in parallel, so that IFRS group figures and statutory accounts come from one set of postings.
    given: ledger IFRS (leading, IFRS, revaluation closing_rate) and ledger LOCAL (non-leading, UK GAAP FRS 102, revaluation closing_rate) assigned to UK01 from FY2026; a lease that IFRS 16 capitalises and FRS 102 expenses
    when: an invoice posts with no ledger named, and I propose a manual journal naming ledger IFRS for the right-of-use asset
    then: >-
      the invoice is stored once and counts in both ledgers; the IFRS journal counts only in IFRS; every report,
      balance and carry-forward is per ledger; naming a ledger the company is not assigned to is refused 422 (gate
      ledger_assigned); the leading ledger is mandatory and cannot be removed; a ledger added later starts on the first
      day of a fiscal year, opening with the leading ledger's carry-forward (GL-23) adjusted by ledger-specific journals
      dated that day
    screen: Companies
    reference: not built
    customer_varies: which GAAPs, and which of them leads (usually IFRS for a group, local GAAP for a standalone company)
  - id: GL-43
    kind: person
    actor: accountant
    story: As the accountant, I want profit centres and segments as dimensions on every line, derived where nobody types them, so that segment results and balance sheets come out of the ledger itself.
    given: segments RETAIL and B2B; profit centres PC-LON (RETAIL) and PC-WHS (B2B); cost centre CC-SHOP1 assigned to PC-LON from 2026-01-01; account 4000 with default profit centre PC-WHS; UK01's default profit centre PC-HQ
    when: a line arrives with cost centre CC-SHOP1 and no profit centre, another on 4000 with neither
    then: >-
      the first gets PC-LON and segment RETAIL (cost centre's assignment on the journal date), the second PC-WHS and
      B2B (the account's default), any other the company default; an explicit profit centre wins; a profit centre not
      valid for the company or on the date is refused 422 (gate profit_centre_valid); an account whose field status
      requires a cost centre (or forbids one) refuses a line without (with) it (gate field_status); a profit centre's
      segment is dated, and re-assigning one never moves posted lines
    screen: Dimensions
    reference: not built
    customer_varies: whether segments are reported at all (IFRS 8 applies to listed entities; most SMEs use profit centres only)
  - id: GL-45
    kind: person
    actor: accountant
    story: As the accountant, I want rent, leases and standing charges posted every month from an approved template, so that nobody re-keys them and nobody forgets one.
    given: template RENT-LON (UK01, all ledgers, monthly 2026-01 to 2026-12, last day of the month, Dr 6100 rent 2,500.00 PC-LON / Cr 2100 accruals 2,500.00) proposed by one accountant and approved by another
    when: I run recurring entries for 2026-10, and run them again
    then: >-
      one journal per due template is posted, keyed derived_id("recurring", recurring_id, version, period_id), in book
      misc with its number; the second run posts nothing new and answers what it already posted; the run lists each
      template posted or skipped with the reason (not approved, period closed, ended); a template is approved by someone
      other than its proposer (email AND sub) when manual_journal_approval asks for it; changing a template makes a new
      version that needs approval again, and the old version stops at the change date; a closed period is refused
    screen: Recurring
    reference: not built
  - id: GL-46
    kind: person
    actor: controller
    story: As the controller, I want overheads assessed and distributed to the departments that use them by agreed rules, so that each department's result carries its real cost.
    given: >-
      cycle OVH-2026 (UK01, ledger IFRS, approved by the accountant): segment 1 assesses cost centre CC-IT (accounts
      6000-6999, actual 9,000.00 in 2026-10) to CC-SHOP1, CC-SHOP2 and CC-WHS by headcount (5, 3, 2); segment 2
      distributes rent account 6100 of CC-HQ 50/50 to PC-LON and PC-WHS
    when: I simulate the cycle for 2026-10, review it, post it, then reverse it and post it again
    then: >-
      the simulation shows senders, receivers, rule, key figure and amounts (4,500.00, 2,700.00, 1,800.00) and posts
      nothing; posting writes one journal per segment keyed derived_id("allocation", cycle_id, period_id, ledger_id,
      run_no, segment): an assessment credits the sender and debits the receivers on the assessment account, a
      distribution moves the original accounts; the amounts sum to the sender's exactly (largest remainder, to the
      penny); segments run in order, so segment 2 sees segment 1's result; a second post while a run is posted is
      refused (gate prior_run_reversed); reversing posts mirror journals and lets run_no 2 post; a posted run freezes the
      key figures it used
    screen: Allocations
    reference: not built
    customer_varies: the rules (fixed percentages, fixed amounts, statistical key figures, actual postings on a tracing account) and whether a cycle needs a second approver
  - id: GL-47
    kind: person
    actor: controller
    story: As the controller, I want headcount, floor space and similar key figures recorded per period, so that allocations follow numbers we agreed, not guesses.
    given: key figure HEADCOUNT for 2026-10
    when: I record 5 for CC-SHOP1, 3 for CC-SHOP2 and 2 for CC-WHS (by hand or from a CSV of the same columns)
    then: >-
      the values are stored per key figure, period and receiver (NUMERIC(19,4), never negative, 422 above it); a value a
      posted allocation run used is refused with the run named (reverse the run first); a receiver that is not a valid
      cost or profit centre on the period is refused 422
    screen: Allocations
    reference: not built
  - id: GL-48
    kind: person
    actor: accountant
    story: As the accountant, I want each open foreign-currency invoice and bill revalued at the period-end rate, so that receivables and payables are stated at the closing rate as IAS 21 requires.
    given: >-
      UK01 invoice INV-88 of USD 1,000.00 booked at 0.800000 (800.00 GBP) and still open in payments at USD 1,000.00;
      bill B-17 of EUR 500.00 booked at 0.860000 open in purchasing at EUR 500.00; closing rates 0.790000 USD and
      0.870000 EUR on 2026-10-31
    when: I revalue open items for UK01 as at 2026-10-31, for each ledger
    then: >-
      gl reads the open items from payments' GET /settlement and purchasing's GET /bills/open as at the key date, takes
      each item's booked rate from the journal that posted it, and posts per ledger and by its method one journal of
      lines per item on the control account (against_document = the item, so the split follows it) to the map's
      fx_gain / fx_loss: INV-88 a loss of 10.00, B-17 a loss of 5.00; the journal reverses on 2026-11-01 (GL-24), so the
      next run starts from the booked rate again; the run is keyed (company, ledger, key date, open_items) and running
      it twice posts once; an item with no closing rate blocks the run with the currency named; an item gl never posted
      is listed as a break, not revalued
    screen: Currencies
    reference: not built
  - id: GL-49
    kind: person
    actor: accountant
    story: As the accountant, I want prepayments and deferred income released over the months they cover from one registration, so that each month carries its share without a manual journal every month.
    given: an insurance premium of 12,000.00 for 2026-01 to 2026-12 already posted to prepaid (account 1400) on 2026-01-15, registered as deferral INS-2026 (UK01, all ledgers, expense 6300, straight-line by period, PC-HQ)
    when: the registration is approved and I run accruals for 2026-01, and later for each month
    then: >-
      each run posts Dr 6300 1,000.00 / Cr 1400 1,000.00 keyed derived_id("accrual", accrual_id, period_id); the last
      period takes the rounding remainder so the total released equals the registration to the penny; running a period
      twice posts once; terminating the deferral in 2026-07 posts the remaining 6,000.00 in that period and nothing
      after; daily proration is a method alternative; a registration above manual_journal_threshold needs a second
      person; deferred revenue (Dr deferred_revenue / Cr income) and expected accruals (Dr expense / Cr accrual_balance,
      cleared when the invoice arrives) follow the same schedule; the registration may name the journal it defers
    screen: Accruals
    reference: not built
  - id: GL-50
    kind: person
    actor: accountant
    story: As the accountant, I want a month-end accrual to reverse itself on the first day of the next month, so that the real invoice next month is not counted twice.
    given: a manual journal accruing 3,000.00 of October audit fees, dated 2026-10-31, flagged reverse_next_period
    when: it posts
    then: >-
      a reversal keyed derived_id(journal_id, "reversal") dated 2026-11-01 is posted at once if 2026-11 is open,
      otherwise held as pending and posted when 2026-11 opens (GL-24); the pending reversal is listed on the Periods
      screen and in the close readiness of 2026-11; the original cannot be reversed by hand while its auto-reversal is
      pending (one reversal, never two)
    screen: Accruals
    reference: not built
  - id: GL-51
    kind: person
    actor: accountant
    story: As the accountant, I want one entry that charges a cost between two of our companies to post in both with the due-to and due-from lines made for me, so that the intercompany balances always agree.
    given: UK01 pays a 1,000.00 GBP software licence that DE01 uses; ic_receivable/ic_payable mapped per partner company; both 2026-10 periods open
    when: I propose a cross-company journal (Cr UK01 bank 1,000.00; Dr DE01 6400 software 1,000.00 GBP) and it is approved
    then: >-
      gl posts one journal per company in one transaction, keyed derived_id(ic_document_id, company_code) and linked by
      ic_document_id: UK01 Dr ic_receivable(DE01) / Cr bank; DE01 Dr 6400 / Cr ic_payable(UK01), converted to EUR at
      DE01's rate on the date, each balanced in both currencies; partner_company is set on every intercompany line; a
      closed period in either company refuses both (gate ic_periods_open); the second approver applies to the whole
      document; a source posting (an ar invoice to DE01's bp_id) gets partner_company DE01 on its lines without the
      caller sending it
    screen: Intercompany
    reference: not built
  - id: GL-52
    kind: person
    actor: accountant
    story: As the accountant, I want intercompany balances and charges matched between each pair of companies before the close, so that the group eliminates what agrees and I fix what does not.
    given: UK01's ic_receivable with partner DE01 of 1,000.00 GBP and DE01's ic_payable with partner UK01 of 1,150.00 EUR (1,000.00 GBP at 0.869565); an ar invoice of UK01 to DE01 of 500.00 GBP that DE01's purchasing bill (ic_reference INV-90) carries at 498.00
    when: I match UK01 against DE01 as at 2026-10-31
    then: >-
      items are matched by ic_document_id (always equal), then by ic_reference, then the remaining balance per pair and
      transaction currency; INV-90 is a break of 2.00 with its fix (post the difference or correct the bill); breaks at
      or below ic_tolerance are listed as within tolerance; revenue and expense pairs are matched the same way for
      consolidation; I accept a difference with a reason, recorded; the result is keyed "<company>:<partner>:<as_at>"
      and read by consolidation (GL-38); the close gate ic_matched refuses, warns or is off as ic_match_required says
    screen: Intercompany
    reference: not built
  - id: GL-53
    kind: person
    actor: accountant
    story: As the accountant, I want financial statement versions that say which accounts make up each line, so that the same ledger produces our IFRS statements, the statutory format and a management view.
    given: version IFRS-BS-PL with nodes Assets > Current > Cash, Liabilities > Current > Overdraft, and bank accounts 1200-1299 assigned debit-balance to Cash and credit-balance to Overdraft
    when: I check and publish it, and run the balance sheet with it
    then: >-
      a bank account with a credit balance shows under Overdraft (debit/credit-dependent assignment); publishing is
      refused while an account with a balance is unassigned or assigned twice (gate fsv_complete, the accounts named);
      a published version is never edited: a change is version_no + 1, and a report names the version it used; several
      versions run on the same ledger; the profit-and-loss node of a version shows the year's result line
    screen: Statement versions
    reference: not built
  - id: GL-54
    kind: person
    actor: accountant
    story: As the accountant, I want a cash-flow statement from the ledger, so that the owner sees where the cash went without a spreadsheet.
    given: a cash-flow version (kind cash_flow, indirect method) assigning each balance-sheet account's movement to operating, investing or financing, depreciation added back, and accounts 1200-1299 as cash
    when: I run it for 2026-10 for UK01, ledger IFRS
    then: >-
      the statement starts from the period's result, adds back non-cash items, shows working-capital movements and the
      investing and financing lines; the net cash flow equals the movement on the cash accounts to the penny, or the
      report lists the unassigned movements that make the difference (gate cash_flow_complete); unrealised FX on cash
      is its own line (IAS 7.28)
    screen: Statement versions
    reference: not built
  - id: GL-55
    kind: person
    actor: controller
    story: As the controller, I want a budget per account, profit centre and month, approved and frozen, so that plan against actual means the same thing all year.
    given: budget B2026 (UK01, ledger IFRS, FY2026, version 1) with lines per account, profit centre, cost centre and period, imported from a CSV of those columns
    when: I submit it and the accountant approves it, then I revise it in June
    then: >-
      the approved version is frozen (lines refused 409); a revision is version 2, approved the same way, and the
      plan/actual report names the version; plan/actual shows actual, budget, variance and variance % per line of a
      statement version and per profit centre; amounts bounded NUMERIC(18,2) (422 above); the approver is not the
      submitter (email AND sub); a budget never posts to the ledger
    screen: Budgets
    reference: not built
  - id: GL-56
    kind: person
    actor: accountant
    story: As the accountant, I want the auditor's year-end adjustments posted after the last month is closed but kept apart, so that the pre-audit and the audited trial balance can both be shown.
    given: FY2026 of UK01, its December closed, the year open
    when: I post an audit adjustment dated 2026-12-31 and another accountant approves it
    then: >-
      it posts into the closed last period flagged adjustment (book adjustment, its own numbers), always with a second
      person; operational postings stay refused; the trial balance can be run before or after adjustments; the
      carry-forward to FY2027 is re-derived (GL-23); once the year is closed an adjustment is refused (gate year_open)
      with the fix (post it in the current year as a prior-period correction)
    screen: Periods
    reference: not built
  - id: GL-57
    kind: person
    actor: accountant
    story: As the accountant, I want a month-end checklist where the system ticks what it can check and people tick the rest, so that the close is the same every month and nothing is skipped.
    given: task list MONTH-UK (recurring entries run, accruals run, depreciation posted, allocations posted, balances and open items revalued, intercompany matched, receivables and payables reconciled, open items re-split, a manual task "payroll journal received" owned by the bookkeeper due on business day 2)
    when: I start the close run for UK01 2026-10
    then: >-
      each task is instantiated with its owner, due business day (in business_timezone) and dependencies; a system task
      is met when its readiness gate is met (checked on read, never ticked by hand); a manual task is done with a note
      or evidence (an object key under raw/close/), or not applicable with a reason; a task cannot complete before the
      tasks it depends on; the run shows what is late; a year-end list (MONTH-UK plus carry-forward and adjustments)
      runs for the last period
    screen: Close cockpit
    reference: not built
  - id: GL-58
    kind: person
    actor: accountant
    story: As the finance manager, I want a second person to sign off the month's close, so that one person cannot both prepare and approve the books.
    given: the close run for UK01 2026-10 with every task done or not applicable, prepared by one accountant
    when: another accountant signs it off, then the period is closed
    then: >-
      the sign-off records who, when and a digest of the trial balance; the preparer (or a task's completer) cannot sign
      (email AND sub); with close_cockpit on, close_period is refused until the run is signed (gate close_signed_off);
      a posting after the sign-off and before the close (the period still open) makes the sign-off stale and asks for it
      again
    screen: Close cockpit
    reference: not built
  - id: GL-59
    kind: person
    actor: auditor
    story: As the auditor, I want to prove nothing in the ledger was changed after it was posted, and to take the file the tax authority asks for, so that the books meet GoBD and the FEC.
    given: FY2026 of company FR01 posted and closed
    when: I verify the chain and export the FEC, and a SAF-T file for UK01
    then: >-
      every posted journal carries chain_seq, prev_hash and row_hash per company (sha-256 over the canonical journal and
      its lines); the verification answers met, or the first broken link with its journal; the FEC has the 18 columns
      of article A47 A-1 of the LPF (JournalCode ... Idevise), one row per line, entry numbers without gaps, the
      opening (a-nouveaux) lines from the carry-forward, alternative account numbers where set, named
      <SIREN>FEC<yyyymmdd>.txt; SAF-T follows the OECD SAF-T Financial 2.0 layout; each export is stored as an object
      under raw/audit/<company>/<fy>/ with its sha-256 and who produced it; master-data and settings changes are in the
      change log with before and after
    screen: Audit
    reference: not built
    customer_varies: which country file (FEC for France, SAF-T for Norway, Portugal, Poland and others, GoBD data access for Germany); retention is 10 years for each
  - id: GL-60
    kind: person
    actor: owner
    story: As the owner, I want to say which companies each person may see and post in, so that a subsidiary's bookkeeper does not see the parent's books.
    given: a bookkeeper who keeps DE01 only
    when: I give them access to DE01 from tomorrow
    then: >-
      from then on every read and write outside DE01 answers 404 (not found, not forbidden), the trial balance and
      reports list DE01 only, and a cross-company journal touching UK01 is refused; access rows are dated, held under
      gl:settings:controls and never by whoever they control; a person with no row sees every company (the default, for
      single-company books)
    screen: Settings
    reference: not built

  # ── 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 in a second committed transaction (never incremented), so concurrent postings land once each
    reference: implemented (v1)
  - id: GL-21
    kind: system
    actor: system
    trigger: after a close, and on request
    story: The system materialises the trial balance and the statements of a closed period, so that a report of a closed period is fast and stable.
    given: a closed period
    when: the statements are materialised
    then: the stored figures match a fresh computation, and re-materialising changes nothing (keyed report, period)
    reference: "partial: v1 materialises on request only"
  - id: GL-22
    kind: system
    actor: system
    trigger: inside every posting, before its commit
    story: The system resolves every journal line to the universal line with its profit centre, segment and partner company, and splits it where document splitting is on, so that every segment balances on every journal.
    given: >-
      splitting on for UK01 from FY2026; an invoice of revenue 600.00 PC-LON and 400.00 PC-WHS, VAT 200.00 and
      receivable 1,200.00; a payment Dr bank / Cr receivable against_document the invoice; a transfer Dr 6000 PC-LON
      100.00 / Cr 6000 PC-WHS 100.00
    when: each posts
    then: >-
      the invoice's receivable splits 720.00 / 480.00 and its VAT 120.00 / 80.00 in proportion to the base lines; the
      payment's lines follow the invoice's split (passive split), and without against_document they take the company's
      default profit centre (re-split at period end, GL-25); the transfer gets zero-balance lines on split_clearing of
      100.00 per profit centre; every journal nets to zero per profit centre and per segment in both currencies; the
      split amounts sum to the line exactly (largest remainder, to the penny); with splitting off each line gives one
      universal line with its derived profit centre; splitting is switched on only from a fiscal year's first day and
      never off within a year
    reference: not built
  - id: GL-23
    kind: system
    actor: system
    trigger: when a year closes, and again whenever a posting lands in a prior year (an audit adjustment, a reopened period)
    story: The system derives each year's opening balances from the previous year's closing ones, so that the balance sheet carries forward and the result lands in retained earnings without a closing entry.
    given: FY2026 closed for UK01 in ledgers IFRS and LOCAL; then an audit adjustment of 500.00 expense posts into 2026-12
    when: the carry-forward is derived, then derived again
    then: >-
      balance-sheet accounts open FY2027 with their FY2026 closing (per ledger, profit centre, segment, partner company),
      income and expense open at zero and their sum opens on retained earnings; after the adjustment retained earnings
      opens 500.00 lower; re-deriving writes the same rows (upsert with every value in VALUES); a carry-forward older
      than the last posting into its year is marked stale and the close of the new year's first period waits for it
      (gate carry_forward_current)
    reference: not built
  - id: GL-24
    kind: system
    actor: system
    trigger: when a period opens, and after every posting that asks for a reversal (GL-09, GL-48, GL-50)
    story: The system posts a reversal dated the first day of the next period as soon as that period is open, so that an accrual or a revaluation never stays in the books longer than a month.
    given: a revaluation journal of 2026-10-31 whose reversal is due 2026-11-01, and 2026-11 not yet open
    when: the accountant opens the periods of 2026-11 (or they are already open)
    then: the pending reversal posts once, keyed derived_id(journal_id, "reversal"), dated 2026-11-01; a second open or a retry posts nothing; a pending reversal is never dropped (it is a readiness reason of the next period's close)
    reference: not built
  - id: GL-25
    kind: system
    actor: system
    trigger: at period end, as a close task, where splitting is on
    story: The system re-splits receivables and payables by profit centre from their open items, so that a receipt posted before it was allocated does not leave the receivable in the wrong segment.
    given: >-
      UK01 receivable 1,200.00 posted to PC-LON 720.00 / PC-WHS 480.00 by an invoice, and a receipt of 1,200.00 posted to
      PC-HQ (no against_document) and allocated to that invoice in payments afterwards
    when: the re-split runs as at 2026-10-31
    then: >-
      gl reads the open items (payments' GET /settlement, purchasing's GET /bills/open), computes the target split per
      profit centre from each item's posted split, and posts one adjustment journal on the control accounts with
      split_clearing lines, so the receivable per profit centre is what its open items say (here zero everywhere); it
      reverses on the first day of the next period (GL-24); keyed (company, ledger, as_at, resplit); twice posts once
    reference: not built

  # ── integration ───────────────────────────────────────────────────────────────────────────
  - id: GL-30
    kind: integration
    actor: system
    trigger: when another module records something with an accounting consequence (an invoice issued or voided, a credit note or write-off approved, a receipt recorded or reversed, a refund paid, a VAT return settled)
    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 of 1,000.00 plus 200.00 VAT
    when: it calls POST /journal-entry/journals with source_module ar, source_document the invoice id, event issue, and lines naming accounts or purposes (company_code, ledger_id, profit_centre, partner_company, movement_type, against_document and asset_id are optional, so today's body is served unchanged)
    then: a balanced journal exists in the default company (or the one named) with the source named and its number; journal_id = derived_id(source_document, event) (derived_id(source_document, event, ledger_id) for a ledger-specific one), so the same call again answers the same journal (replay) and a different body for the same id is refused 409; a date in a closed period of that company is refused 409 with the period named; an unmapped purpose 422 with the purpose named; a bp_id that belongs to a group company sets partner_company on the line; the lines are split (GL-22) before the commit
    owner: gl
    reference: "implemented in v1 for account ids only (no purposes, no replay answer, no number)"
  - id: GL-31
    kind: integration
    actor: system
    trigger: when the bookkeeper reconciles the receivable control account (GL-08)
    story: The ledger asks payments what is outstanding on the invoices it has posted, so that the control account is reconciled against the module that owns the open items.
    given: invoices posted by ar to the receivable account up to a date
    when: the reconciliation runs
    then: gl calls payments' GET /settlement with those invoice ids (200 per call) as at the date, and compares per invoice; gl never reads pay_ tables
    owner: payments
    reference: not built
  - id: GL-36
    kind: integration
    actor: system
    trigger: when the bookkeeper reconciles the payables control account (GL-08), with purchasing installed
    story: The ledger asks purchasing what is still owed on the bills it has posted, so that the payables control account is reconciled against the module that owns the open bills.
    given: bills and supplier payments posted by purchasing to the payables account up to 2026-10-31; bill B-17 posted at 216.00 and 100.00 paid
    when: the reconciliation runs as at 2026-10-31
    then: "gl calls purchasing's GET /bills/open?as_of=2026-10-31 and compares per bill (the bill id is the source_document of purchasing's journals): B-17 outstanding 116.00 on both sides is no break; a bill open in purchasing but not posted, or posted at a different amount, is a break with its fix; gl never reads pur_ tables and purchasing calls no subledger route"
    owner: purchasing
    reference: not built
  - id: GL-32
    kind: integration
    actor: system
    trigger: before a module issues a document whose accounting date may fall in a closed period
    story: Another module asks whether a date is open for posting, so that it refuses before issuing rather than after.
    given: the period 2026-09 is closed
    when: invoicing calls GET /periods/open?date=2026-09-30 (company_code optional; the default company when absent)
    then: "{date, company_code, period_id: 2026-09, open: false, status: closed, reason, fix} (status none, open false, when no period covers the date)"
    owner: gl
    reference: not built
  - id: GL-33
    kind: integration
    actor: system
    trigger: when a module needs a rate for a currency on a date (tax, a foreign VAT total in sterling)
    story: Other modules read FX rates here rather than keeping their own, so that a document and its journal use the same rate.
    given: rates for EUR dated 2026-09-29 and 2026-10-01
    when: tax calls GET /fx-rates/EUR?on=2026-09-30 (type spot by default; type=average or closing, and to= another currency, for consolidation)
    then: "the rate in force: {currency: EUR, to: GBP, type: spot, on: 2026-09-30, rate_date: 2026-09-29, rate, source}; 404 with the fix when no rate of that type is dated on or before the date; an average rate is the one dated the period's last day. v6: the lookup is the NEWEST rate of that type dated ON OR BEFORE `on` (rate_date says which), never a later one; a monthly average is addressed by month, GET /fx-rates/EUR?type=average&month=2026-09 (instead of on), which answers exactly the average dated that month's last day and 404s when there is none, never the nearest"
    owner: gl
    reference: not built
  - id: GL-34
    kind: integration
    actor: system
    trigger: once at cut-over (and again after a fix, before signing)
    story: The ledger carries Odoo's chart, journals, posted entries, fiscal years, lock dates and rates, so that the books continue without re-keying and with history comparable.
    given: an Odoo database and the doors decided; business-partner and tax have mapped every partner and tax the entries use
    when: the run loads
    then: "accounts, periods, rates and posted journals written read-only, keyed on the Odoo record (migration.odoo.idempotent_on); a second run changes nothing; nothing of a signed run is ever rewritten by a later run, which only adds what Odoo posted after its cut-over"
    owner: gl
    reference: not built
  - id: GL-37
    kind: integration
    actor: system
    trigger: when fixed-assets acquires, depreciates, transfers, revalues, impairs or retires an asset
    story: Fixed assets post their entries here, per ledger where depreciation differs, so that the asset accounts and the asset register always agree.
    given: >-
      asset A-100 (class VEH, UK01, cost centre CC-WHS) acquired for 30,000.00; depreciation areas IFRS (5 years) and
      LOCAL (4 years) mapped to ledgers IFRS and LOCAL; the 2026-10 depreciation run computes 500.00 and 625.00
    when: >-
      fixed-assets calls POST /journal-entry/journals once for the acquisition (no ledger: common) and once per ledger for
      the run (source_document "dep:UK01:IFRS:2026-10:1", event depreciate, ledger_id IFRS; the same for LOCAL)
    then: >-
      the acquisition posts Dr asset_cost(VEH) / Cr asset_clearing(VEH) in every ledger; each run posts Dr
      depreciation_expense(VEH) / Cr accumulated_depreciation(VEH) in its ledger only, keyed derived_id(source_document,
      event, ledger_id), with asset_id, cost centre (so profit centre and segment are derived) and movement_type
      (addition, depreciation, disposal, transfer, revaluation, impairment) on each line; an asset_cost or
      accumulated_depreciation account is source_only, so a manual journal on it is refused 422 (gate source_only_account);
      a replay answers the journal posted; a closed period refuses 409; an unmapped asset-class purpose refuses 422
    owner: gl
    reference: not built
  - id: GL-38
    kind: integration
    actor: system
    trigger: when consolidation collects a company's reported data for a group period (and again before its sign-off)
    story: Consolidation reads each company's balances by group account here, through one exact route, so that the group figures come from the books and never from a re-keyed trial balance.
    given: UK01 2026-10 closed in ledger IFRS; every posted account mapped to a group account; intercompany matched for 2026-10
    when: consolidation calls GET /group-reporting/balances?company_code=UK01&ledger_id=IFRS&period_id=2026-10
    then: >-
      gl answers the year-to-date and period amounts in the company's functional currency per group account, partner
      company, movement type and segment, with the period's status, the ledger, the functional currency, the
      unmapped accounts (none here) and a digest of the rows; the same call twice answers the same digest while nothing
      posts; an open period answers status open so consolidation can refuse or warn; GET /intercompany/reconciliation
      answers the pair's match result (GL-52); gl never writes cons_ tables
    owner: gl
    reference: not built
  - id: GL-39
    kind: integration
    actor: system
    trigger: when the accountant reconciles the asset accounts, and when the closing cockpit checks "depreciation posted"
    story: The ledger asks fixed-assets whether depreciation is posted and what the register says per asset class, so that the asset accounts are reconciled against the module that owns the assets.
    given: UK01 asset accounts per class and ledger as at 2026-10-31, and fixed-assets' register
    when: the reconciliation runs, and the close task checks depreciation
    then: >-
      gl calls fixed-assets' GET /assets/balances (cost and accumulated depreciation per asset class and ledger as at the
      date) and compares per class and ledger, each break with its fix; it calls GET /depreciation/status for the
      company, ledger and period and shows its readiness {gate, met, reason, fix} as the close gate depreciation_posted;
      gl never reads fixed-assets' tables
    owner: fixed-assets
    reference: not built
  - id: GL-61
    kind: integration
    actor: system
    owner: gl
    trigger: when another module needs a company's functional currency, a cost centre's validity or an account's balance at a date
    story: Other modules read a company, a cost centre and an account balance here, so that the functional currency, the cost centres and the book side of a reconciliation have one source.
    given: company UK01 (functional currency GBP), cost centre CC-410 open in UK01 and CC-999 closed, bank account 1200 with 12,400.00 posted up to 2026-10-31
    when: ar and payments call GET /companies/UK01, inventory calls GET /cost-centres/CC-410 and GET /cost-centres/CC-999, payments calls GET /accounts/1200/balance?as_of=2026-10-31
    then: UK01 answers its name, country, functional currency, chart and ledgers; CC-410 answers status open with its company and CC-999 status closed (the caller's gate refuses it, gl writes nothing); 1200 answers 12,400.00 GBP as of that date, the sum of its posted lines; an unknown company, cost centre or account is 404; a company the caller's client may not see is 404, not 403

processes:
  - key: journal
    pcf_id: null                       # APQC 9.3.2 Perform general accounting (Process journal entries): id to confirm
    pcf_name: Process journal entries
    reference_names: ["Record to Report"]
    brick: gl
  - key: period
    pcf_id: null                       # APQC 9.3.4 / period-end close: id to confirm
    pcf_name: Perform period-end close
    reference_names: ["Record to Report", "Month-end close"]
    brick: gl
  - key: fiscal_year
    pcf_id: null
    pcf_name: Perform year-end close
    reference_names: ["Record to Report", "Year-end close"]
    brick: gl
  - key: allocation
    pcf_id: null                       # APQC 9.3.2 / cost accounting (allocate costs): id to confirm
    pcf_name: Perform cost allocations and charges
    reference_names: ["Record to Report", "Overhead allocation"]
    brick: gl
  - key: intercompany
    pcf_id: null                       # APQC 9.3.3 Perform intercompany accounting: id to confirm
    pcf_name: Process intercompany transactions
    reference_names: ["Record to Report", "Intercompany reconciliation"]
    brick: gl
  - key: budget
    pcf_id: null                       # APQC 9.1 Perform planning and management accounting: id to confirm
    pcf_name: Develop and maintain budgets
    reference_names: ["Plan to Perform"]
    brick: gl

workflows:
  - entity: journal
    states: [proposed, posted, rejected, reversed]
    terminal: [rejected, reversed]
    transitions:
      - { key: post_from_source, name: Post what another module sent, from: none, to: posted, by: system, gate: "balanced in document and functional currency; every purpose mapped on the journal date; the company's period open; the ledger (if named) assigned to the company; source-only accounts posted only by their module; field status met; journal_id = derived_id(source_document, event[, ledger_id])", effect: "lines written with the source named; split to universal lines (GL-22); hash chain extended; number claimed per company, book and year; balances re-derived in a second transaction; a replay answers the journal already posted" }
      - { key: propose_manual, name: Propose a manual journal, from: none, to: proposed, by: [bookkeeper, accountant], gate: "balanced; dated in an open period and not after today; the proposer has access to the company; no source-only or group account on a line", effect: "proposer recorded (email and sub); a ledger may be named (ledger-specific); reverse_next_period may be flagged (GL-50)" }
      - { key: propose_cross_company, name: Propose a cross-company journal, from: none, to: proposed, by: [bookkeeper, accountant], gate: "lines in two or more companies the proposer has access to; ic_receivable and ic_payable mapped for every pair; each company's period open", effect: "one ic_document_id; the due-to and due-from lines computed and shown before approval (GL-51)" }
      - { key: propose_audit_adjustment, name: Propose a year-end audit adjustment, from: none, to: proposed, by: accountant, gate: "dated in the last period of an open financial year; that period closed", effect: "flagged adjustment, book adjustment; always waits for a second person (GL-56)" }
      - { key: ai_check_journal, name: "AI checks the journal (suggest)", from: proposed, to: proposed, by: system, gate: "the check is on", effect: "findings listed for the approver" }
      - { key: accept_journal_ai_findings, name: Accept or dismiss each AI finding, from: proposed, to: proposed, by: accountant, gate: "the journal has findings (GL-15)", effect: "each decision recorded in gl_ai_finding" }
      - { key: post_manual, name: Post without a second approver, from: proposed, to: posted, by: system, gate: "manual_journal_approval is none, or the total is at or below the threshold; never an adjustment or a cross-company journal above the threshold", effect: "numbered; split; chained; balances re-derived; an auto-reversal posted or held (GL-24)" }
      - { key: approve_manual, name: Approve the manual journal, from: proposed, to: posted, by: accountant, approval: true, gate: "the approver is not the proposer (email AND sub); every company's period is still open (the last period closed but the year open, for an adjustment)", effect: "numbered; approver recorded; one journal per company for a cross-company document, in one transaction; balances re-derived; the carry-forward marked stale for an adjustment" }
      - { key: reject_manual, name: Reject the manual journal, from: proposed, to: rejected, by: accountant, requires: reason }
      - { key: reverse_journal, name: Reverse the journal, from: posted, to: reversed, by: accountant, requires: reason, gate: "the reversal date (today, or the first day of the next open period) is in an open period; no auto-reversal pending", effect: "a mirror journal keyed derived_id(journal_id, 'reversal'), split the same way; neither is deleted; a cross-company journal reverses with its partners" }
      - { key: post_pending_reversal, name: Post the auto-reversal, from: posted, to: reversed, by: system, gate: "the journal asked for a reversal on the next period's first day (accrual, revaluation, re-split) and that period is open", effect: "the mirror journal dated that day, once (GL-24)" }
    note: >-
      There is no edit: a journal exists or it does not, and a correction is another journal. A journal's date is an
      accounting fact dated by its document (an invoice's issue date, a receipt's received date, a manual accrual's
      month): the one kind of date allowed earlier than today, and only into an open period (or, for an audit
      adjustment, the closed last period of an open year). A migrated journal (source_module odoo) is read-only
      history: it is reversed like any other, never re-posted. Recurring entries, accrual releases, allocations and
      revaluations post through the same use case as post_from_source, with gl itself as the source.
    readiness:
      - { transition: post_from_source, gate: balanced, reason: "debits and credits differ by the amount named", fix: "the calling module's journal builder" }
      - { transition: post_from_source, gate: purposes_mapped, reason: "a purpose (or purpose and qualifier) has no account in force on the journal date in the company's chart", fix: "Account map: map it (GL-12)" }
      - { transition: post_from_source, gate: period_open, reason: "the journal date falls in a closed period of the company, or in no period", fix: "post it dated in an open period, or ask the accountant to reopen" }
      - { transition: post_from_source, gate: fx_rate, reason: "a foreign journal with no rate given and no rate in force on its date", fix: "Currencies: enter the rate for the date" }
      - { transition: post_from_source, gate: ledger_assigned, reason: "the named ledger is not assigned to the company for the journal's year", fix: "Companies: assign the ledger, or post to all ledgers" }
      - { transition: post_from_source, gate: company_known, reason: "the company_code is not a company of these books", fix: "Companies: add it, or send the default" }
      - { transition: post_from_source, gate: field_status, reason: "the account requires (or forbids) a cost centre, profit centre, partner or tax code on the line", fix: "the calling module's builder, or the account's field status" }
      - { transition: post_from_source, gate: profit_centre_valid, reason: "the profit centre is not valid for the company on the journal date", fix: "Dimensions: extend it, or send another" }
      - { transition: propose_manual, gate: source_only_account, reason: "an account on a line is kept by its module (receivable, payable, asset cost, accumulated depreciation)", fix: "post it in that module, or use a non-control account" }
      - { transition: propose_manual, gate: group_chart_not_postable, reason: "a group-chart account cannot carry a line", fix: "use the operational account mapped to it" }
      - { transition: propose_manual, gate: company_access, reason: "you have no access to a company on a line", fix: "ask the owner (GL-60)" }
      - { transition: propose_cross_company, gate: ic_accounts_mapped, reason: "no ic_receivable or ic_payable account for the pair named", fix: "Account map: map them per partner company" }
      - { transition: propose_cross_company, gate: ic_periods_open, reason: "the period is closed in one of the companies, named", fix: "date it in a period open in both" }
      - { transition: propose_audit_adjustment, gate: year_open, reason: "the financial year is closed", fix: "post a prior-period correction in the current year" }
      - { transition: approve_manual, gate: second_person, reason: "you proposed it, and the owner asks for a second person (always, for an audit adjustment)", fix: "ask another accountant" }
      - { transition: approve_manual, gate: period_open, reason: "its period closed while it waited", fix: "reject it and propose it in an open period" }
      - { transition: reverse_journal, gate: not_reversed, reason: "already reversed by the journal named", fix: "none needed" }
      - { transition: reverse_journal, gate: no_pending_reversal, reason: "an auto-reversal of this journal is pending for the next period", fix: "open the next period; the reversal posts itself" }
      - { transition: post_pending_reversal, gate: next_period_open, reason: "the period of the reversal date is not open yet", fix: "Periods: open it (the reversal posts then)" }
  - entity: period
    states: [open, closed]
    transitions:
      - { key: open_periods, name: Open the year's periods, from: none, to: open, by: accountant, gate: "the company's fiscal year is opened (GL-13)", effect: "one period per month of the year, period_id YYYY-MM, per company; pending reversals dated in them posted (GL-24)" }
      - { key: reconcile_receivable, name: Reconcile the receivable control account, from: open, to: open, by: bookkeeper, effect: "the breaks against payments' settlement recorded as at the period end (GL-08)" }
      - { key: reconcile_payable, name: Reconcile the payables control account, from: open, to: open, by: bookkeeper, gate: "purchasing is installed", effect: "the breaks against purchasing's GET /bills/open recorded as at the period end (GL-08, GL-36)" }
      - { key: reconcile_assets, name: Reconcile the asset accounts, from: open, to: open, by: accountant, gate: "fixed-assets is installed", effect: "the breaks per asset class and ledger against fixed-assets' register (GL-39)" }
      - { key: run_recurring_entries, name: Run the recurring entries, from: open, to: open, by: accountant, gate: "the period is open", effect: "one journal per due approved template; twice posts once (GL-45)" }
      - { key: run_accruals, name: Release accruals and deferrals, from: open, to: open, by: accountant, gate: "the period is open", effect: "one journal per active registration; twice posts once (GL-49)" }
      - { key: run_fx_revaluation_balances, name: Revalue foreign balances, from: open, to: open, by: accountant, gate: "revaluation is on and a closing rate is in force on the period end for every currency held", effect: "per ledger, an unrealised gain or loss journal by the ledger's method, reversing next period (GL-09)" }
      - { key: run_fx_revaluation_open_items, name: Revalue foreign open items, from: open, to: open, by: accountant, gate: "revaluation is on; a closing rate for every open item's currency", effect: "per ledger, one journal of item lines on the control accounts, reversing next period (GL-48)" }
      - { key: match_intercompany, name: Match intercompany, from: open, to: open, by: accountant, gate: "the company has a partner company with intercompany lines", effect: "the pair's match result as at the period end (GL-52)" }
      - { key: accept_ic_difference, name: Accept an intercompany difference, from: open, to: open, by: accountant, requires: reason, gate: "the break is above tolerance and the accepter is not who posted the difference", effect: "recorded beside the break; consolidation reads it" }
      - { key: resplit_open_items, name: Re-split open items by profit centre, from: open, to: open, by: system, gate: "document splitting is on for the company's year", effect: "one adjustment journal on the control accounts, reversing next period (GL-25)" }
      - { key: close_period, name: Close the month, from: open, to: closed, by: accountant, gate: "readiness met", effect: "operational postings dated in it refused for the company; statements materialised (GL-21)" }
      - { key: materialise_statements, name: Materialise the statements, from: closed, to: closed, by: system, effect: "keyed (report, company, ledger, period); a second run changes nothing" }
      - { key: reopen_period, name: Reopen the month, from: closed, to: open, by: accountant, requires: reason, approval: true, gate: "the year is not closed; the reopener is not who closed it where the owner asks for it", effect: "recorded; the materialised statements and the close sign-off marked stale" }
    readiness:
      - { transition: close_period, gate: prior_closed, reason: "the previous period is still open", fix: "close it first" }
      - { transition: close_period, gate: no_proposed, reason: "manual journals dated in the period are waiting for approval", fix: "approve or reject them (GL-11)" }
      - { transition: close_period, gate: receivable_reconciled, reason: "the receivable control account has breaks as at the period end", fix: "Reconcile: work the breaks or accept the difference with a reason" }
      - { transition: close_period, gate: payable_reconciled, reason: "the payables control account has breaks against purchasing's open bills as at the period end", fix: "Reconcile: work the breaks or accept the difference with a reason (only when purchasing is installed)" }
      - { transition: close_period, gate: revalued, reason: "foreign balances or open items are not revalued at the period end in a ledger, named", fix: "Currencies: revalue (or revaluation is off)" }
      - { transition: close_period, gate: recurring_posted, reason: "approved recurring entries due in the period are not posted", fix: "Recurring: run them (GL-45)" }
      - { transition: close_period, gate: accruals_posted, reason: "active accruals or deferrals have no release in the period", fix: "Accruals: run them (GL-49)" }
      - { transition: close_period, gate: allocations_posted, reason: "approved allocation cycles have no posted run for the period", fix: "Allocations: post them (GL-46), or retire the cycle" }
      - { transition: close_period, gate: depreciation_posted, reason: "fixed-assets has not posted the period's depreciation in a ledger (its readiness named)", fix: "run depreciation in fixed-assets (only when it is installed)" }
      - { transition: close_period, gate: ic_matched, reason: "intercompany breaks above tolerance and not accepted, per partner", fix: "Intercompany: fix or accept them (ic_match_required refuse; warn lists them)" }
      - { transition: close_period, gate: resplit_done, reason: "open items are not re-split by profit centre as at the period end", fix: "the system runs it as a close task (splitting on only)" }
      - { transition: close_period, gate: pending_reversals_posted, reason: "reversals due on the period's first day are not posted", fix: "a platform fault if the period is open: report it" }
      - { transition: close_period, gate: carry_forward_current, reason: "the opening balances of this year are older than a posting into the previous year", fix: "the system re-derives them (GL-23)" }
      - { transition: close_period, gate: close_signed_off, reason: "the close run is not signed off by a second person", fix: "Close cockpit: finish the tasks and ask another accountant to sign (close_cockpit on)" }
      - { transition: close_period, gate: trial_balance_zero, reason: "the trial balance does not net to zero, in a ledger or a segment", fix: "a platform fault: stop and report it" }
      - { transition: reopen_period, gate: year_open, reason: "the financial year is closed", fix: "post a correction in the current year" }
      - { transition: reopen_period, gate: second_person, reason: "you closed it, and the owner asks for a second person", fix: "ask another accountant" }
      - { transition: accept_ic_difference, gate: second_person, reason: "you posted the difference you are accepting", fix: "ask another accountant" }
  - entity: fiscal_year
    states: [open, closed]
    transitions:
      - { key: open_year, name: Open the financial year, from: none, to: open, by: accountant, gate: "the start month follows the company's previous year end (financial_year_end_month)", effect: "twelve periods opened for the company; the carry-forward from the previous year derived (GL-23)" }
      - { key: derive_carry_forward, name: Derive the carry-forward, from: open, to: open, by: system, gate: "the previous year has postings", effect: "opening balances per ledger, account, profit centre, segment and partner company; P&L to retained earnings; re-derivation writes the same rows" }
      - { key: close_year, name: Close the year, from: open, to: closed, by: accountant, gate: "every period of the year closed; no audit adjustment waiting; retained_earnings mapped", effect: "closed to adjustments; the carry-forward to the next year derived for every ledger; the next year opened if it is not; no closing journal" }
    readiness:
      - { transition: close_year, gate: periods_closed, reason: "periods of the year are still open: named", fix: "close them (GL-07)" }
      - { transition: close_year, gate: no_proposed_adjustment, reason: "an audit adjustment is waiting for approval", fix: "approve or reject it (GL-56)" }
      - { transition: close_year, gate: retained_earnings_mapped, reason: "no retained_earnings account in the map on the year's last day", fix: "Account map (GL-12)" }
  - entity: recurring_journal
    states: [proposed, approved, rejected, ended]
    terminal: [rejected, ended]
    transitions:
      - { key: propose_recurring, name: Propose a recurring entry, from: none, to: proposed, by: accountant, gate: "balanced; company, ledger and accounts valid; first and last period within open or future years", effect: "version 1, proposer recorded (email and sub)" }
      - { key: approve_recurring, name: Approve the recurring entry, from: proposed, to: approved, by: accountant, approval: true, gate: "the approver is not the proposer (email AND sub) where manual_journal_approval asks for it", effect: "runs from its first period" }
      - { key: reject_recurring, name: Reject the recurring entry, from: proposed, to: rejected, by: accountant, requires: reason }
      - { key: post_recurring_period, name: Post the period's entry, from: approved, to: approved, by: [accountant, system], gate: "due in the period; the period open; not posted for it", effect: "a journal keyed derived_id('recurring', recurring_id, version, period_id), through the posting use case" }
      - { key: revise_recurring, name: Revise the recurring entry, from: approved, to: ended, by: accountant, requires: reason, effect: "this version ends at the change; version + 1 is proposed and needs approval" }
      - { key: end_recurring, name: End the recurring entry, from: approved, to: ended, by: accountant, requires: reason, effect: "no period after the end posts; posted journals untouched" }
    readiness:
      - { transition: approve_recurring, gate: second_person, reason: "you proposed it, and the owner asks for a second person", fix: "ask another accountant" }
      - { transition: post_recurring_period, gate: period_open, reason: "the period is closed", fix: "post it in an open period by revising the template, or reopen" }
  - entity: accrual
    states: [proposed, active, rejected, terminated, completed]
    terminal: [rejected, terminated, completed]
    transitions:
      - { key: register_accrual, name: Register an accrual or deferral, from: none, to: proposed, by: accountant, gate: "amount above zero and within NUMERIC(18,2); start period not after end; accounts valid in the company's chart", effect: "the schedule computed per period (straight-line or daily), the last period taking the remainder" }
      - { key: approve_accrual, name: Approve the registration, from: proposed, to: active, by: accountant, approval: true, gate: "the approver is not the registrant (email AND sub) above manual_journal_threshold", effect: "released from its start period" }
      - { key: reject_accrual, name: Reject the registration, from: proposed, to: rejected, by: accountant, requires: reason }
      - { key: release_accrual_period, name: Release the period's share, from: active, to: active, by: [accountant, system], gate: "the period open and in the schedule; not released for it", effect: "a journal keyed derived_id('accrual', accrual_id, period_id)" }
      - { key: complete_accrual, name: Complete the registration, from: active, to: completed, by: system, gate: "every period of the schedule released", effect: "released total equals the amount to the penny" }
      - { key: terminate_accrual, name: Terminate early, from: active, to: terminated, by: accountant, requires: reason, gate: "the termination period open", effect: "the remainder released in the termination period; nothing after" }
    readiness:
      - { transition: approve_accrual, gate: second_person, reason: "you registered it, and it is above the threshold", fix: "ask another accountant" }
      - { transition: release_accrual_period, gate: period_open, reason: "the period is closed", fix: "release it in the next open period by terminating and re-registering, or reopen" }
  - entity: allocation_cycle
    states: [draft, approved, retired]
    terminal: [retired]
    transitions:
      - { key: define_allocation_cycle, name: Define an allocation cycle, from: none, to: draft, by: controller, gate: "every segment has senders, receivers and a rule; receivers valid; percentages total 100 where the rule is percentages", effect: "version recorded with its author (email and sub)" }
      - { key: approve_allocation_cycle, name: Approve the cycle, from: draft, to: approved, by: accountant, approval: true, gate: "the approver is not the author (email AND sub) where allocation_cycle_approval is on", effect: "runnable from its valid_from period" }
      - { key: retire_allocation_cycle, name: Retire the cycle, from: [draft, approved], to: retired, by: accountant, requires: reason, effect: "no new run; posted runs untouched; a change is a new cycle version" }
    readiness:
      - { transition: approve_allocation_cycle, gate: second_person, reason: "you wrote the cycle, and the owner asks for a second person", fix: "ask another accountant" }
  - entity: allocation_run
    states: [simulated, posted, reversed]
    terminal: [reversed]
    transitions:
      - { key: simulate_allocation, name: Simulate the cycle, from: none, to: simulated, by: controller, gate: "the cycle approved; the period open; key figures recorded for every receiver the rules need", effect: "senders, receivers and amounts computed and shown with a digest of the sender balances; nothing posted" }
      - { key: post_allocation, name: Post the allocation, from: simulated, to: posted, by: [controller, accountant], gate: "the simulation is current (sender digest unchanged); no posted run for the cycle, period and ledger; the period open", effect: "one journal per segment keyed derived_id('allocation', cycle_id, period_id, ledger_id, run_no, segment); key figures used are frozen" }
      - { key: reverse_allocation, name: Reverse the allocation run, from: posted, to: reversed, by: accountant, requires: reason, gate: "the period open", effect: "mirror journals of every segment; the key figures unfrozen; run_no + 1 may post" }
    readiness:
      - { transition: simulate_allocation, gate: key_figures_present, reason: "a receiver has no key figure value for the period, named", fix: "Allocations: record it (GL-47)" }
      - { transition: post_allocation, gate: simulation_current, reason: "postings on the senders since the simulation changed their balance", fix: "simulate again" }
      - { transition: post_allocation, gate: prior_run_reversed, reason: "a run of this cycle is posted for the period and ledger", fix: "reverse it first" }
      - { transition: reverse_allocation, gate: period_open, reason: "the period is closed", fix: "reopen it, or leave the run" }
  - entity: statement_version
    states: [draft, published, superseded]
    terminal: [superseded]
    transitions:
      - { key: draft_statement_version, name: Draft a statement version, from: none, to: draft, by: accountant, effect: "nodes and account assignments (ranges, group accounts, debit/credit-dependent); kind balance_sheet_pl or cash_flow" }
      - { key: publish_statement_version, name: Publish the version, from: draft, to: published, by: accountant, gate: "fsv_complete", effect: "reports may run on it; it is never edited again" }
      - { key: supersede_statement_version, name: Supersede the version, from: published, to: superseded, by: accountant, effect: "version_no + 1 drafted from it; reports already run name the old version" }
    readiness:
      - { transition: publish_statement_version, gate: fsv_complete, reason: "accounts with a balance unassigned or assigned twice, named", fix: "assign each exactly once" }
  - entity: budget
    states: [draft, submitted, approved, superseded]
    terminal: [superseded]
    transitions:
      - { key: draft_budget, name: Draft a budget, from: none, to: draft, by: controller, gate: "company, ledger and fiscal year exist", effect: "lines by account, profit centre, cost centre and period, typed or imported" }
      - { key: submit_budget, name: Submit the budget, from: draft, to: submitted, by: controller, gate: "every amount within NUMERIC(18,2)", effect: "submitter recorded (email and sub)" }
      - { key: approve_budget, name: Approve the budget, from: submitted, to: approved, by: accountant, approval: true, gate: "the approver is not the submitter (email AND sub)", effect: "frozen; plan/actual reads it" }
      - { key: return_budget, name: Return the budget, from: submitted, to: draft, by: accountant, requires: reason }
      - { key: revise_budget, name: Revise the budget, from: approved, to: superseded, by: controller, requires: reason, effect: "version + 1 drafted from it" }
    readiness:
      - { transition: approve_budget, gate: second_person, reason: "you submitted it", fix: "ask another accountant" }
  - entity: close_run
    states: [in_progress, ready, signed]
    transitions:
      - { key: start_close_run, name: Start the close run, from: none, to: in_progress, by: accountant, gate: "a task list exists for the company and the kind (month or year end)", effect: "tasks instantiated with owner, due business day and dependencies" }
      - { key: complete_close_task, name: Complete a manual task, from: in_progress, to: in_progress, by: [bookkeeper, controller, accountant], gate: "the tasks it depends on are done", effect: "who, when and the note or evidence key recorded" }
      - { key: skip_close_task, name: Mark a task not applicable, from: in_progress, to: in_progress, by: accountant, requires: reason }
      - { key: mark_close_ready, name: All tasks done, from: in_progress, to: ready, by: system, gate: "every task done or not applicable; every system task's gate met on read", effect: "the trial balance digest taken" }
      - { key: sign_off_close, name: Sign off the close, from: ready, to: signed, by: accountant, approval: true, gate: "the signer is not the run's starter nor a task's completer (email AND sub); the digest unchanged", effect: "who, when and the digest recorded; close_period may proceed" }
      - { key: invalidate_close_signoff, name: The sign-off goes stale, from: [ready, signed], to: in_progress, by: system, gate: "a posting landed in the period after the digest", effect: "the run shows what changed and asks for a new sign-off" }
    readiness:
      - { transition: complete_close_task, gate: dependencies_done, reason: "a task it depends on is open, named", fix: "finish that first" }
      - { transition: sign_off_close, gate: second_person, reason: "you started the run or completed a task in it", fix: "ask another accountant" }
  - entity: migration_run
    states: [loaded, reconciled, signed, discarded]
    terminal: [signed, discarded]
    transitions:
      - { key: load_gl_from_odoo, name: Load the Odoo books, from: none, to: loaded, by: system, gate: "the doors decided; business-partner and tax mapped every partner and tax the lines use", effect: "companies, accounts, account map, fiscal years, periods (closed up to Odoo's lock date), rates, recurring entries, deferrals and posted journals written read-only, keyed on the Odoo record; a re-run changes nothing" }
      - { key: reconcile_gl_migration, name: Reconcile against Odoo, from: loaded, to: reconciled, by: system, effect: "every migration.odoo.reconcile check computed on both sides, with the difference" }
      - { key: explain_gl_difference, name: Explain each difference, from: reconciled, to: reconciled, by: accountant, requires: reason, gate: "the check differs and may be explained", effect: "the explanation recorded beside the check" }
      - { key: reload_gl_migration, name: Load again after a fix, from: [loaded, reconciled], to: loaded, by: system, gate: "not signed", effect: "the same keys; explanations on unchanged checks kept" }
      - { key: sign_gl_reconciliation, name: Sign the reconciliation, from: reconciled, to: signed, by: owner, approval: true, gate: "every check equal, or explained where allowed", effect: "who, when and checksums recorded; postings from the composed modules accepted from the day after cut-over" }
      - { key: discard_gl_migration, name: Discard the run, from: [loaded, reconciled], to: discarded, by: owner, requires: reason, effect: "the run's rows removed; nothing was posted on them" }
    readiness:
      - { transition: load_gl_from_odoo, gate: doors_decided, reason: "a migration door is undecided", fix: "Migration: answer it" }
      - { transition: load_gl_from_odoo, gate: partners_mapped, reason: "Odoo partners on receivable or payable lines have no bp_id", fix: "run the business-partner migration first" }
      - { transition: load_gl_from_odoo, gate: taxes_mapped, reason: "Odoo taxes on tax lines have no tax_code", fix: "run the tax migration first" }
      - { transition: sign_gl_reconciliation, gate: zero_unexplained, reason: "checks neither equal nor explained: named", fix: "explain each, or fix and reload" }
      - { transition: sign_gl_reconciliation, gate: must_equal, reason: "a check that must be equal differs (the trial balance per company, account and period)", fix: "fix the mapping and reload; it cannot be explained away" }

entities:
  - id: company
    meaning: a legal entity whose books are kept here (SAP's company code)
    natural_key: 'company_code VARCHAR (shared.yaml keys.company_code): 1 to 4 upper-case letters or digits, the customer''s own (UK01), fixed once a journal uses it; a migrated one is named at the door from res.company'
    key_attributes: [name, country (ISO 3166), functional_currency (fixed once posted), chart_id, financial_year_end_month, bp_id (the partner that represents it, for intercompany), default_profit_centre, splitting_from_fiscal_year (null = off), is_default, active_until]
    relationships: ["-> chart", "1..n company_ledger", "1..n period", "-> business_partner (bp_id)"]
    master_data: SHARED, sole writer; consolidation, fixed-assets and every posting module read it through GET /companies
    reference_table: gl_company
    note: "exactly one company is the default; a posting that names none lands in it, which is how today's single-company callers keep their body"
  - id: chart
    meaning: a chart of accounts, operational (companies post to it) or group (consolidation reports in it)
    natural_key: chart_id VARCHAR ("MAIN", "GROUP")
    key_attributes: [name, kind (operational | group), country]
    master_data: SHARED, sole writer
    reference_table: gl_chart
  - id: account
    meaning: a bucket the business reports on (a bank account, a revenue line, a tax liability)
    natural_key: 'account_id BIGINT = derived_id("account", code) in the first operational chart (v2''s ids stand), derived_id("account", chart_id, code) in any other chart; derived_id("odoo:<db>:account.account:<id>") for a migrated one (shared.yaml keys.account_id). code is the customer''s own number (VARCHAR, unique within its chart), fixed once a journal uses it'
    key_attributes: [chart_id, code, name, type (asset | liability | equity | income | expense), subtype (receivable | payable | bank | tax | retained_earnings | asset_cost | accumulated_depreciation | ic | other), normal_balance, currency (null = functional only), parent_account_id, monetary (revalued), source_only (posted only by its module), field_status (cost_centre, profit_centre, bp_id, tax_code each required | optional | forbidden), default_profit_centre, movement_tracked, active_until]
    relationships: ["-> chart", "-> parent account (gl_account_edge)", "0..1 group_account_map", "1..n journal_line"]
    master_data: SHARED, read through gl's routes; this module is the sole writer
    reference_table: gl_account
    note: "a group chart's accounts carry no lines (gate group_chart_not_postable); every change of name, field status or deactivation is a dated row and lands in the change log (gl_master_audit)"
  - id: account_edge
    meaning: the account tree, as a graph walked to any depth
    natural_key: [parent_id, child_id]
    master_data: module-owned; written in the same use case as the account
    reference_table: gl_account_edge
  - id: group_account_map
    meaning: which group account an operational account reports under, dated
    natural_key: [account_id, valid_from]
    key_attributes: [group_account_id, set_by, set_at]
    master_data: SHARED, sole writer; read by consolidation through GET /group-reporting/balances, never directly
    reference_table: gl_group_account_map
  - id: alt_account
    meaning: a company's alternative (country) number for an account, for its statutory files
    natural_key: [company_code, account_id, valid_from]
    key_attributes: [alt_code, alt_name]
    master_data: module-owned config
    reference_table: gl_alt_account
  - id: ledger
    meaning: a set of books in one accounting principle; one leads, others run in parallel
    natural_key: ledger_id VARCHAR ("IFRS", "LOCAL"; customer's own, 2 to 8 upper-case letters or digits)
    key_attributes: [name, kind (leading | non_leading), accounting_principle (IFRS | FRS102 | HGB | PCG | US_GAAP | other), reval_method (closing_rate | losses_only)]
    master_data: SHARED, sole writer; fixed-assets maps its depreciation areas to ledger_id; consolidation names the ledger it collects
    reference_table: gl_ledger
    note: "exactly one leading ledger per bucket; its id is fixed once a journal posts"
  - id: company_ledger
    meaning: which ledgers a company keeps, from which fiscal year
    natural_key: [company_code, ledger_id]
    key_attributes: [from_fiscal_year, until_fiscal_year]
    master_data: module-owned config
    reference_table: gl_company_ledger
  - id: account_map
    meaning: where a purpose posts, dated, per chart
    natural_key: [chart_id, purpose, qualifier, valid_from]
    key_attributes: [account_id, set_by, set_at]
    master_data: module-owned config; read by every posting
    reference_table: gl_account_map
    note: 'qualifier is the tax_code for purpose tax, the bank_account_id for purpose bank, the partner company for ic_receivable / ic_payable, the asset class for fixed-assets'' purposes, else "". A purpose+qualifier with no row falls back to the purpose with qualifier "" (as invoicing''s builder does today)'
  - id: journal
    meaning: one balanced accounting entry of one company
    natural_key: 'journal_id BIGINT = derived_id(source_document, event) for a source posting common to all ledgers, derived_id(source_document, event, ledger_id) for a ledger-specific one (the caller may send it; gl checks it), derived_id("manual", uuid) for a manual one, derived_id(ic_document_id, company_code) for each company of a cross-company document, derived_id(journal_id, "reversal") for a reversal, and the run keys of recurring, accrual, allocation, revaluation and re-split journals'
    key_attributes: [company_code, ledger_id (null = every ledger of the company), status, journal_date, period_id, book (sales | purchases | cash | misc | fx | accrual | allocation | adjustment | opening | migration), entry_no, source_module, source_document, event, currency, fx_rate, memo, adjustment, reverse_next_period, reversal_status (none | pending | posted), ic_document_id, recurring_id, accrual_id, allocation_run_id, reval_run_id, proposed_by, proposed_sub, approved_by, approved_sub, posted_at, reverses_journal_id, body_digest, chain_seq, prev_hash, row_hash, migration_run_id]
    relationships: ["1..n journal_l