Instructions for agents working on this brick
Built from the payments module spec (erp-method-l2c modules/payments/module.yaml, version 4, signed
2026-10-23, erp-method v0.1.27; the path now erp-method/modules/payments). Version 4 is held equal to the spec: tests/test_process.py holds application/process.yaml equal to the
spec's workflows until the customer signs, then pin the signed hashes the way brick-product does. Read erp-platform's
BRICKS.md before changing anything - the rules and why each one exists. The shape is erp-brick-product's.
Where a change goes
| You are changing... | It goes in |
|---|---|
| a business rule | domain/ - and its test in tests/test_rules.py / test_stories.py / test_domain.py (one test per story, named for it) |
| how a settlement, an aging bucket or a hold is derived | domain/settlement.py, domain/credit.py - never stored, never incremented |
| who may do something, or which rows | application/use_cases.py - the permission on the declaration; never a role's name |
| a new action for people and agents | a new use case in application/use_cases.py - a route AND a tool |
| how another module is reached | infrastructure/seams.py only (ar, bp, gl); the Odoo reader and partner map are infrastructure/odoo.py |
| a setting the customer edits | domain/values.py (SETTING_DEFS, check_setting, needs_controls) - controls=True means pay:settings:controls |
| an AI step | infrastructure/ai_steps.py + domain/matching.py; the use case that calls it |
| the Odoo migration | application/migration.py (+ domain/odoo_map.py for the pure mapping) |
| statements, rules, the AI's line suggestions | application/statements.py (+ domain/bankfile.py, domain/reconcile.py) |
| settlement discount, remittance advice | application/remittances.py (+ domain/remittance.py) |
| direct debit: mandates, runs, ARUDD / ADDACS / DDICA | application/directdebit.py (+ domain/directdebit.py) |
| a column or table | infrastructure/tables.py AND schema.sql AND a new migrations/NNNNN_<name>/ (append; never drop or rename); tests/test_schema.py keeps them equal |
Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Don't write to
another brick's tables - call its seam. Don't write an upsert by hand - erp_platform.repository.upsert (facts are plain
INSERTs). Business time is work.now(); audit stamps are real time.
The rules that must not be undone
-
Settlement is DERIVED. outstanding = invoice total - SUM(allocations), as at a date. Nothing stores "paid"; nothing increments a balance. ONE allocation table settles every invoice (receipts, credit notes, write-offs). A refund draws on a CREDIT, so its allocation names the credit in
invoice_id("receipt:<payment_id>" / "credit_note:<id>"). -
'Today' is the date in
business_timezone(_today(repo), read throughSettingsRepository.timezone()- the one port that moves to the platform): an allocation, an undo, a credit limit and an override are never dated before it; a receipt, a return and a refund payment never after it. Onlypay:migration:importwrites past dates (Odoo's own, with origin). -
A correction is a reversing row or a new row. No committed
pay_row is edited; only a suggestion's status, a refund's status, a receipt's reversal (andassign_receipt_customerfor money on no customer), a reminder draft becoming sent, and the configuration rows move.reversed_on(the day WE recorded a return) - notreturned_on(the bank's) - decides whether a receipt counted on a past day, so a statement as at a past date re-computes to what it said. -
The controls on the cashier (
match_tolerance,credit_hold_*,business_timezone,statement_import_from,ai_suggest_match= auto-with-review) arepay:settings:controls;set_pay_settingrefuses them (403) naming it. -
The AI only suggests. A suggestion is a row;
decide_match(a person) allocates, citing it. The one exception is an EXACT match in the opt-in auto-with-review, recordeddecided_how = autoand listed for review. Tests never call a model. -
Never across currencies. A credit is applied only in its own currency; sums are per currency. A cross-currency receipt migrated from Odoo carries
source_amounton the allocation (in the receipt's currency) andfx_rateon the receipt. -
Money out needs a second person.
second_persongate; a refund reserves credit while requested/approved. -
No read-your-writes inside an open transaction (DataK3): a use case reads committed rows first, writes, and reports from the rows in hand (
_allocate_corereturns them). A plain INSERT of an existing key is rejected (23505 -> 409). -
Machine permissions (
receipt:ingest,receipt:match,collection:tick,allocation:from_source,migration:import) are exact grants to a service client; no wildcard reaches them. -
Input validation runs before the permission check in the doors, so the probes feed
"x": a required date or currency is a plain string (Day,Currency) parsed INSIDE the use case, or a caller without the permission would see 422, not 403. -
v4 slice A guard keys (
pay_claim, plain INSERT in the operation's own transaction, n = committed claims + 1, a loser is a 409):line(every operation on a statement line),counterpart(what a line is matched to),mandate,ddrun,ddinv(an invoice in a run),dd_report(one ARUDD/ADDACS/DDICA line),discount(one per invoice). A receipt's reversal takes the SAME source claim an allocation does. -
States never move back: a decided line suggestion, a cancelled/dormant mandate, a returned collection, a submitted run are final; a late report line is reported "already applied" / "not applied", never undone. Self-approval of a run is refused under email OR sub.
-
gl is posted in GL-30's shape for payments:
source_module: "payments",company_code(the bank account's), eventsreceipt,receipt_reversal,refund,bank_line,bank_line_reversal. tests/conftest.py checks every neighbour call against the callee's signed spec.
Before you commit
pytest -q --tb=short # rules, domain, doors, seams, migration, process, schema, scenarios + probes
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_payments/roles.yaml