Instructions for agents working on this brick
Built from the expenses module spec (erp-method modules/expenses/module.yaml, version 1, signed 2026-10-25; BPMN in
modules/expenses/signoff/expenses/). Read erp-platform's BRICKS.md before changing anything - the rules and why each one exists. The
shape is erp-brick-payments'. Slices A and B are built (v0.2.0): the expense report workflow, hr's leaver intake, the gates, delegates, company cards, foreign currency and VAT through tax, the receipt store, the payout, mileage and per diem, rates/categories/settings as dated rows, the receipt reader and its consent, reminders, re-billing and the Odoo slice; see "Spec gaps and decisions" and "What is left".
Where a change goes
| You are changing... | It goes in |
|---|---|
| a business rule (state machine, the approval chain, the journal's shape, the leaver window) | domain/report.py - and its test in tests/test_rules.py / test_stories.py |
| a rule of one concern | domain/ beside it: policy.py (limits and bans), cards.py (feed, match, netting), vat.py (fx and tax's answer), rates.py (mileage bands, per diem days), receipts.py (the image), ai.py (the reader's contract), reminders.py, rebill.py, odoo.py (the mapping) |
| who may do something, or which rows | application/use_cases.py - the permission on the declaration (or _need), _can_see / _own; never a role's name |
| a new action for people and agents | a new use case in application/use_cases.py (the Odoo slice: application/migration.py) - a route AND a tool; add its name to tests/test_doors.py |
| how another module is reached | infrastructure/seams.py only (hr, gl, payments, tax, ar, the mail capability); the Odoo reader is infrastructure/odoo.py, the receipt store infrastructure/objects.py, the receipt reader infrastructure/readers.py |
| a setting the customer edits | domain/values.py (SETTING_DEFS, SETTING_KINDS; CONTROL_SETTINGS are the owner's) |
| a column or table | infrastructure/tables.py AND schema.sql (regenerate, keep equal) AND a new migrations/NNNNN_<name>/ (append; never drop or rename) AND MIGRATIONS in tests/test_schema.py |
| a default category or statutory rate | a new migration row + DEFAULT_CATEGORIES / DEFAULT_RATES in domain/values.py (a correction is a row with a later valid_from) |
| the process | never here first: erp-compose change propose, a new signature, then application/process.yaml; tests/test_process.py pins the signed BPMN byte for byte |
Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Don't write another brick's tables - call its
seam (seams.py). Don't write an upsert by hand - erp_platform.repository.upsert (facts are plain INSERTs). Business time is work.now()
counted in business_timezone (_today(repo)); audit stamps are real time.
The rules that must not be undone (each has a test; slice A's in tests/test_invariants.py / test_stories.py)
- One claim key per money or person operation (
exp_claim, plain INSERT in the operation's own transaction):series, one-shotsubmit,approval(report:rev:level),complete,close,paid,leaver,payout_cancel,cardline,migration_sign; and the card rowsexp_card_claim(txn, operation: match | personal | recover - netting AND repayment sharerecover, so one wins). A loser is a 409, never a second effect. The journal, the payout, a personal recovery and a re-bill are different keys (derived_id,exp:<r>:r<n>:pay[n],exp:<r>:<line>), so none answers as another's replay. - Side effects only after the local commit, from pending rows (
exp_outbox: gl, payments, ar, notify): written in the transaction that makes the state change, sent by_drainafter it, resent byPOST /outbox/drain; a rolled-back approval sends nothing. Reminders are rows keyed (kind, ref, business date). - States never move back.
domain/report.py MOVES,domain/cards.py MOVES(a card match is final), the migration run (loaded > reconciled > signed | discarded). A returned claim continues as revision n+1. - Nobody approves their own claim, under either login, as a delegate either (
Identity.same_asis email OR sub); nobody decides two levels; a delegate covers the manager level only, between two dates, whiledelegation_allowed. - The chain is fixed at submission; lines freeze. A stale line (a rule, a rate, an earlier mileage approval changed) refuses submission AND approval until recomputed; an approved or posted line is never recomputed; a submitted claim's fx rate is frozen.
- Business date = the date in
business_timezone, never the UTC date (approval, reminders, rates, consents). - Money is bounded and exact: a line below 10 trillion, two places, > 0; a foreign line rounded ONCE half up at gl's rate; rates NUMERIC(19,4), miles
NUMERIC(19,4) up to 100,000; every setting bounded (
read_setting); a bad input is a 422{gate: bounds}, never a 500. - Tax answers, expenses stores: VAT is reclaimed only where tax says reclaimable AND its content check passes; reclaimable <= the receipt's VAT; cost + VAT = the line's amount; no tax module means nothing reclaimed. Tax is asked again only when its inputs changed (a digest).
- The journal balances to the penny: cost (less reclaimable VAT) + input VAT = employee_payable (own money) + card_liability per card (card lines).
- Personal card spend is recovered once: netted from the payout (oldest first, only what fits) or repaid; both take the
recoverclaim row. - Dates on rows: a row finance or the owner adds starts today (the first of its key) or after today; a change never rewrites what a claim was written under;
weakening a block rule is
exp:settings:controls(the owner's), never finance alone. - Reader and consent: no image reaches the model without the employee's recorded, current consent and the step on; every suggested field stays marked until
the person confirms or corrects it; the audit row (
exp_ai_finding) keeps what the model read and what the person did. - Statements stay flat (
tests/test_statement_budget.py): a claim loads in three queries, lines are written in one statement, lists are one query, the gates/policy/cards/tax/fx/delegation add a constant number - tested with 2 vs 20 lines and 2 vs 50 unrelated rows. HTTP calls to tax grow per receipt (one document each), the engine's statements do not. - A neighbour answer is a reply, never an exception: not installed 409, failed 502, silent 503.
tests/conftest.pychecks every call against the CALLEE's signed spec (checked_seams). - Machine permissions (
exp:report:mark_paid- payments' client alone,exp:leaver:intake- hr's,exp:journal:post_auto- the worker's tick: drain and reminders,exp:migration:load/reconcile- the system) are exact grants; no wildcard reaches them. Seeroles.yaml.
Spec gaps and decisions (recorded 2026-10-25/26, slice B; each says what was built instead)
Contract checks. erp_platform.contract reads only seams: and with:. In tests/conftest.py a shim reads gl's GET /fx-rates/{currency} as listing expenses
(its entry names tax, consolidation and fixed-assets: GL-33), and tax's POST /documents/compute / /check-contents (TAX-53, TAX-50, listed with with: expenses
under external_services:, not seams:) as seams. hr's with: expenses, pos is split by the platform since 0.9.13. Fix: the owners list expenses under seams:.
Platform. No object-store helper exists (capabilities.yaml is an outside-world seam; brick-gl wrote its own SigV4 client): infrastructure/objects.py is a port
with an S3 implementation and a memory one. The platform refuses request bodies over 5 MiB, so a receipt is at most 3 MiB (base64 on the wire); a direct upload
(presigned PUT) is the way past it. No AI capability takes an image (ai.suggest takes JSON scalars) and no vision model is reachable: infrastructure/readers.py is a
port with no implementation; with none set the step's own fallback applies (the employee types the fields). Near-duplicate detection by vector similarity is not built
(no embedding model, no VECTOR column): the same-digest check is. receipt_key on add_line is trusted by its shape (migrated and hand-uploaded objects); add_receipt
is the path that stores and records it, and get_receipt says plainly when a key was not stored through it.
card_txn. The signed process has no unmatch: a match is final, so a matched line cannot be removed or re-amounted and its draft cannot be cancelled. "Card
transactions of the period" is undefined: built as the claim's first receipt date .. the submission day. Netting has no clearing journal (gl events are approve |
personal | reverse): employee_payable and employee_receivable of one person net in payments' settlement. A claim wholly netted has nothing to pay, so it is settled
(paid, payment_id netted:...) at posting - the spec says paid only on payments' confirmation. The repayment of personal spend has no route from payments: finance
records it (recover_personal).
Payout. No workflow transition reverses a posted claim (posted changes only by a reversal journal, event reverse): cancel_payout / reissue_payout cancel and
re-ask the PAYOUT (until exported; then the payment stands and the message says to recover from the employee). Claim reversal and its journal are not built: they need a
spec change.
Tax. tax's answer currency is unspecified: read as the receipt's currency and converted with the line's frozen rate. The tax category of a client meal is
entertainment (a correction row, migration 00005); supply is goods for gl purpose office_supplies, else services. A receipt's particulars (supplier, address, VAT
number, invoice number) are typed (or read) per line - without them nothing is reclaimed (gate receipt_particulars).
Rows and dates. A policy or rate row that starts today judges lines dated on or after it (first rows start today), so a rule adopted today does not police past
lines. UK benchmark scale rates (EIM05231, hours thresholds) are not modelled: a UK company claims actual cost or adds its own rate rows. A category's gl purpose is
checked for shape (never an account number); gl refuses an unmapped purpose at posting.
EXP-20. No neighbour tells expenses a rate changed: marking happens in the request that changes a policy/rate row or approves mileage; a draft's fx re-reads gl on
recompute; a submitted claim's rate is frozen.
hr. No hr route returns an email or maps Odoo employees: a card's holder_email is typed on put_card; a delegate is named by email (and optional sub); the Odoo
load takes employee_map as a door. business-partner lists no expenses seam: a re-billed customer is validated by ar. No route says another brick's migration is
signed: upstream_signed is attested at the door. The tax map is not used (history is never re-priced; VAT is not carried).
Odoo. XmlRpcReader is not verified against a live Odoo (the mapping is, by fake rows). approve-state sheets become approved and are posted by the normal path
(Odoo booked nothing); post-state become posted; done become paid; the unpaid ones are paid here.
Not built / carried: company scoping of exp:report:read_all (finance sees every company's claims); explain_policy_warning AI step; the exp:report:derive,
exp:payout:request, exp:number_series:claim, exp:ai:read_receipt machine permissions are listed in the spec but no route needs them yet.
What is left
Every story has a use case and a test. What a later slice or a spec change must do: claim reversal (a reverse transition and journal), unmatch of a card
transaction, a real receipt reader and vector duplicate search, presigned receipt upload, company scoping for finance, the explain_policy_warning step, and the
gaps above. Scenarios SC-EXP-01..05 run in tests/test_scenarios.py, test_rates.py and test_ai.py.
Backlog (decided 2026-10-26, pending the spec PR)
seemo decided these on 2026-10-26; they are written into erp-method branch spec/gaps-wave-4. Build them once that PR is
signed (the module's processes need a re-sign first), not before; until then the spec on main stands.
- Receipts: direct-to-store upload when the platform offers it. A presigned PUT straight to the object store (past the 5 MiB body limit); until then compress photos client-side (the web app) so a receipt stays under 3 MiB on the wire.
- Adopt the hr seam (hr v4). A card holder and a delegate are person ids; read the email from
GET /persons/{person_id}/assignment(HR-63) instead ofholder_email/ a delegate email typed in; the Odoo load matches employees by work email throughGET /roster(HR-64) instead of theemployee_mapdoor; company-scopeexp:report:read_all. - Build unmatch.
unmatch_card_txn(matched -> imported while the claim is a draft, EXP-25): the line's typed amount comes back, the match stays in the history; finance (exp:card_txn:resolve) may do it for anyone. - Build the claim reversal.
reverse_report(posted | paid -> reversed, finance,exp:report:reverse, EXP-26): a mirror journalderived_id("exp:<report_id>:r<rev>", "reverse"), gl event reverse; cancel the payout if not exported, else leave an employee receivable netted or repaid as in EXP-14.cancel_payout/reissue_payoutstay for the payout alone. - Migration stages. load and reconcile write staging rows only; sign writes the claims; discard deletes staging rows only.
exp:leaver:intakeis now held by the system actor in the spec (no code change).
Before you commit
pytest -q --tb=short # rules, stories, doors, seams, invariants, budget, process, schema, composition, cards, fx/vat, receipts, rates, ai, reminders, rebill, migration
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_expenses/roles.yaml