dodil/erp-brick-expenses

DODIL ERP brick: expenses

AGENTS.md

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 concerndomain/ 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 rowsapplication/use_cases.py - the permission on the declaration (or _need), _can_see / _own; never a role's name
a new action for people and agentsa 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 reachedinfrastructure/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 editsdomain/values.py (SETTING_DEFS, SETTING_KINDS; CONTROL_SETTINGS are the owner's)
a column or tableinfrastructure/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 ratea new migration row + DEFAULT_CATEGORIES / DEFAULT_RATES in domain/values.py (a correction is a row with a later valid_from)
the processnever 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-shot submit, approval (report:rev:level), complete, close, paid, leaver, payout_cancel, cardline, migration_sign; and the card rows exp_card_claim (txn, operation: match | personal | recover - netting AND repayment share recover, 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 _drain after it, resent by POST /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_as is email OR sub); nobody decides two levels; a delegate covers the manager level only, between two dates, while delegation_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 recover claim 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.py checks 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. See roles.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 of holder_email / a delegate email typed in; the Odoo load matches employees by work email through GET /roster (HR-64) instead of the employee_map door; company-scope exp: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 journal derived_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_payout stay for the payout alone.
  • Migration stages. load and reconcile write staging rows only; sign writes the claims; discard deletes staging rows only. exp:leaver:intake is 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