dodil/erp-brick-tax

DODIL ERP brick: tax

AGENTS.md

Instructions for agents working on this brick

Built from the tax module spec (erp-method modules/tax, v2, namespace tax, prefix tax_). The customer signed the PROCESS (registration, tax_code, document_tax; 2026-10-22); tests/test_process.py fails if application/process.yaml drifts from it. A process change goes through erp-compose change propose, the customer's new signature, then the code. Read erp-platform's BRICKS.md before changing anything.

Where a change goes

You are changing…It goes in
a business ruledomain/ — and its test, named for its story (tests/test_stories.py, tests/test_documents.py)
how VAT is roundeddomain/rounding.py — tests/test_rounding.py (net + VAT = gross under every method)
the "starts after today" ruledomain/schedule.py (one place for rates, rules, registrations, treatments, settings)
who may do something, or which rows they seeapplication/use_cases.py — the permission, or _sees_*; 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 (a port with a fake in tests)
a setting the customer editsdomain/values.py (SETTING_DEFS)
an AI stepinfrastructure/ai_steps.py and the use case that calls ask()
the Odoo migrationapplication/odoo_migration.py (pure mapping) behind migrate_from_odoo; reader port in infrastructure/odoo.py
a column or tableinfrastructure/tables.py AND schema.sql (append; never drop or rename), regenerate the migration

Never import SQLAlchemy, FastAPI or MCP in domain/. Never write another brick's tables — call its port. Do not hand-write an upsert — use erp_platform.repository.

The rules that must not be undone

  • A dated row in effect is never edited or deleted. A rate, rule, registration, customer treatment or setting change starts AFTER today in business_timezone; a correction is a new row. Only a key's FIRST row may carry an earlier date (a new installation, the Odoo migration; a rule for a pair nothing covers may start today).
  • A pair no rule covers is refused, never taxed at 0%. A business not VAT registered charges no VAT (code NV) and its document is not titled "VAT invoice"; one that recorded nothing is refused until it says.
  • Tax is frozen at issue (freeze_document_tax) and never recomputed ("issued: credit it instead"). Freezing twice is a no-op.
  • net + VAT = gross and the per-rate summary sums to the total under every rounding method. Methods are code; a row chooses one.
  • Reverse charge charges 0 and records the VAT due, with the legend.
  • An override that lowers tax needs a second person unless the owner's control override_approval = self_with_reason.
  • No AI suggestion reaches a document without a person's acceptance. Tests never call a model.
  • The controls (tax_changes_need_second_person, override_approval, business_timezone) need tax:settings:controls, refused to tax:settings:write.
  • Machine permissions (tax:code:seed, tax:document_tax:compute, tax:document_tax:suggest, tax:history:import) are exact grants; no wildcard reaches them.
  • A refused transition returns its unmet gates as {gate, met, reason, fix} (in the 409 detail after — unmet gates:).
  • No read-your-writes inside an open transaction (DataK3): a use case that writes then reports works the answer out in memory.
  • The migration never recomputes history: it writes first rows, keyed in tax_migration_map; a second run writes nothing.

Before you commit

pytest -q --tb=short
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_tax/roles.yaml

Live check

BUCKET=l2ctaxlab DODIL_ORG=dodil python tests/live_check.py runs the real use cases on a scratch DataK3 bucket (route and MCP), creates the schema and drops it again. Do it once per change that touches storage, not per edit.