# 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