dodil/erp-brick-payments

DODIL ERP brick: payments

AGENTS.md

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 ruledomain/ - 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 deriveddomain/settlement.py, domain/credit.py - never stored, never incremented
who may do something, or which rowsapplication/use_cases.py - the permission on the declaration; never a role's name
a new action for people and agentsa new use case in application/use_cases.py - a route AND a tool
how another module is reachedinfrastructure/seams.py only (ar, bp, gl); the Odoo reader and partner map are infrastructure/odoo.py
a setting the customer editsdomain/values.py (SETTING_DEFS, check_setting, needs_controls) - controls=True means pay:settings:controls
an AI stepinfrastructure/ai_steps.py + domain/matching.py; the use case that calls it
the Odoo migrationapplication/migration.py (+ domain/odoo_map.py for the pure mapping)
statements, rules, the AI's line suggestionsapplication/statements.py (+ domain/bankfile.py, domain/reconcile.py)
settlement discount, remittance adviceapplication/remittances.py (+ domain/remittance.py)
direct debit: mandates, runs, ARUDD / ADDACS / DDICAapplication/directdebit.py (+ domain/directdebit.py)
a column or tableinfrastructure/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 through SettingsRepository.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. Only pay:migration:import writes 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 (and assign_receipt_customer for money on no customer), a reminder draft becoming sent, and the configuration rows move. reversed_on (the day WE recorded a return) - not returned_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) are pay:settings:controls; set_pay_setting refuses 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, recorded decided_how = auto and 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_amount on the allocation (in the receipt's currency) and fx_rate on the receipt.

  • Money out needs a second person. second_person gate; 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_core returns 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), events receipt, 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