dodil/erp-brick-gl

DODIL ERP brick: general ledger

AGENTS.md

Instructions for agents working on this brick

Built from the gl module spec (erp-method modules/gl/module.yaml, version 3, signed 2026-10-22). Read erp-platform's BRICKS.md before changing anything: the rules and why each one exists. The brick is the SOLE writer of journals; every module that posts money (ar, payments, tax, purchasing, inventory, fixed-assets) posts through POST /journal-entry/journals.

Where a change goes

You are changing...It goes in
what makes a journal valid (balance, purposes, dimensions, field status)domain/journal.py and its test in tests/test_posting.py
how a line is split by profit centre and segmentdomain/splitting.py (tests/test_dimensions.py)
how a share is cutdomain/money.py largest_remainder (never a float)
the one write path (number, chain, lines, split lines, period guard)application/ledgerops.py write_posted — nothing else writes a journal
the carry-forwardapplication/carry.py (derived, never posted)
the close gatesapplication/gates.py (the cockpit and get_period_readiness read the same list)
who may, and which rowsthe permission on the declaration + brick.company_row / can_see (a company outside the caller's access is 404)
another module's routeinfrastructure/seams.py only, shaped from THEIR spec route; tests use tests/fakes.py
the Odoo readerinfrastructure/odoo.py (a port); tests/fake_odoo.py computes Odoo's OWN aggregates from its rows
a column or tableinfrastructure/tables.py, then python scripts/gen_schema.py (schema.sql + migration 00001), and a NEW migration for a later release (additive only)

Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Never write another brick's tables. A use case is named as its workflow transition in the spec (tests/test_process.py fails if one is missing).

The rules that must not be undone

  • A journal is never edited or deleted. A correction is a reversing journal keyed derived_id(journal_id, "reversal"); its id is the claim, so reversing twice (or twice at once) posts one. The original reads reversed; neither is deleted.
  • Source postings are idempotent by source. journal_id = derived_id(source_document, event[, ledger_id]); the same body again answers the journal (replay), a different body is 409 journal_conflict, two callers racing for one id land one (a plain INSERT of the key).
  • Claims are plain INSERTs (gl_number_series, gl_chain_claim, the journal id, gl_recurring_posting, gl_fx_reval_run): the loser's whole transaction is rolled back and brick.claims() runs it again against the committed state. Never an upsert on a claim.
  • No read-your-writes inside a transaction. Lines are written, committed, THEN balances are re-derived in a second transaction (derive_balances, a SUM). A use case that posts two journals of ONE company in one transaction must not claim a number twice.
  • The period lock is a guard on the write path: assert_period_still_open updates the period row WHERE status='open' in the posting's own transaction; a close that won the race leaves no row to update and the posting is refused. Do not read the period and trust it.
  • Balances, trial balances and the carry-forward are derived from split lines with SUM and never typed or incremented. A common journal (ledger_id NULL) counts in every ledger of its company; a ledger-specific one only in its own.
  • A dated row starts after today (account map, controls, group mapping, alternative numbers, company access, segment assignments); the FIRST row of a key may carry any date (the day the thing began). An FX rate is dated today or earlier and never changed.
  • Second person = email AND sub (brick.same_person): approve_manual, approve_recurring, approve_accrual, approve_allocation_cycle, approve_budget, sign_off_close, reopen_period, accept_ic_difference.
  • A control is the owner's (set_gl_control, gl:settings:controls); set_gl_setting refuses one. Nobody holds a control they control.
  • 'Today' is the business date in business_timezone (brick.today, read through one function), never date.today() or UTC.
  • Shape is checked after the permission. Doors check size only; brick._checked validates codes, periods, years and dates inside the use case, so an unauthorised caller is 403, never told what is wrong with their body (the generated probes depend on it).
  • History is read-only. A migrated journal equals Odoo's move to the penny, is written without the period guard, keeps Odoo's entry number, and is never rewritten; a signed run is frozen; a later run only adds what Odoo posted after it.
  • AI steps only suggest (ai.py): findings are listed with evidence, a person accepts or dismisses each; nothing changes a journal.

Before you commit

pytest -q --tb=short            # rules, both doors, access, concurrency, probes, process, schema, composition, migration
ruff check src tests
python scripts/gen_schema.py --check
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_gl/roles.yaml