dodil/erp-brick-pos

DODIL ERP brick: point of sale

AGENTS.md

Instructions for agents working on this brick

Built from the pos module spec (erp-method modules/pos/module.yaml, version 1, signed 2026-10-25 with its BPMN in modules/pos/signoff/pos/). tests/test_process.py holds application/process.yaml equal to the spec's workflows and each entity's drawing equal to the signed sha256, so a process change goes through erp-compose change propose and a new signature first. Read erp-platform's BRICKS.md before changing anything. The shape is erp-brick-payments'.

What is built

Slice A (0.1.0): the session - open the till with a counted float (POS-03), cash moves (POS-04), the blind count (POS-05), a second person for a difference (POS-06) - and the sale: price from product, VAT from tax (eat-in or takeaway), tenders with tips, finalise (POS-08, the tender and tip of POS-09), the stock row (POS-16/27), the controls (POS-35).

Slice B (0.2.0, migration 00002_slice_b, tests test_closing/refunds/vouchers/recipes/snapshot/setup.py):

  1. Closing the day: force_close_session (POS-07: the claimed gap of receipt numbers), post_session (POS-20/29: the session's takings as ONE balanced gl journal, tips as tips_payable, voucher liability both ways, the drawer's difference, paid-outs and safe drops; a pending outbox row after the commit, the session is posted once gl holds it), requeue_outbox_row (a failed row, e.g. a closed gl period, after a person fixed the cause), seal_cash_bag / list_cash_bags (POS-19: the safe's balance is derived, a bag is sealed once, payments gets POST /deposit-batches/expected from a pending row).
  2. Void and refund: void_receipt (an open receipt kept as a voided record, its number never reused; a voucher it spent goes back), refund_receipt (POS-11: each original line's quantity is claimed refund:<original>:<line>:<n>, the ORIGINAL's tax code and rate, the money back the way it came, a cash refund above cash_refund_limit needs another shift lead, restocked lines to inventory POST /receipts (INV-59) from pending rows), set_category_rule.
  3. Vouchers and the card terminal: voucher_sale lines in ring_up(vouchers=), redemption as a voucher payment (claims voucher:<code>:<n>, balance derived, never stored), get_voucher; a card that timed out is pending until resolve_card_payment (POS-09, POS-13).
  4. Recipes: keep_recipe / get_recipe / list_recipes (POS-15: dated versions with modifiers, exact unit conversion, stocked components); each receipt line freezes its recipe version and its components (pos_line_component); inventory is told the components (POS-16); recipe_source pos | manufacturing (POS-31: GET /boms/{product_id}/explode, checked against manufacturing's signed spec).
  5. The register snapshot (POS-30): open_session reads the whole price list, the tax categories and tax's rates ONCE, keeps a dated, digested, versioned copy (pos_register_snapshot, GET /sessions/{id}/snapshot) and ring_up sells from it: a sale reads no neighbour.
  6. POS-01 setup validation: keep_shop checks (only what changed) the company in gl, the warehouse and location in inventory.

Not built (each a strict xfail in tests/test_remaining.py, pytest -q -rx): POS-02 (the layout), 10 (discounts), 12 (offline sync, quarantine), 14, 16 (tax and loyalty outbox rows), 17, 18 (Z report, readiness, tax_agrees), 20 (the shop-day journal; built per session), 21, 24, 25, 26 (card settlement), 28, 29 (the shop day's supplement), 32, 33, 34. Inside built stories: the recount of POS-06 and the approval of a float difference (neither is a transition in the signed workflow), match_bag (payments reporting the bag matched), refund of a voucher tender back to the voucher, single-purpose vouchers, settings other than those in domain/values.py:SETTING_DEFS.

Where a change goes

You are changing...It goes in
a business ruledomain/ - and its test in tests/test_domain.py / test_stories.py / test_invariants.py
price, VAT, roundingdomain/pricing.py (the answers are product's and tax's; pos types neither)
who may do something, or which rowsapplication/use_cases.py - the permission on the declaration; _scope() for rows; 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 (product, tax, inventory, gl, payments, manufacturing)
a setting the customer editsdomain/values.py SETTING_DEFS (controls=True means pos:settings:controls, set through set_pos_control)
a column or tableinfrastructure/tables.py (list it in ADDED / NEW_TABLES for its release), then python scripts/gen_schema.py writes schema.sql and the migration; tests/test_schema.py keeps them equal. 00002_slice_b was regenerated all through slice B and is unreleased: from the next release on, append 00003_<name> and never edit 00002

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).

The rules that must not be undone

  • One claim key per money operation, a plain INSERT in the operation's own transaction (pos_claim): pay:<receipt_id>:<pay_no>, a refund's quantity refund:<original>:<line>:<n> (with its pos_refund_claim row), a voucher's issue, redemption and give-back voucher:<code>:<n> (with its pos_voucher_claim row, the operation is part of what is claimed), a cash bag bag:<shop>:<bag_no> and safe:<shop>:<n>; they never share one. The card terminal's idempotency key is the payment LINE (<receipt_id>:<pay_no>), never the receipt.
  • A neighbour is written only from a pending outbox row, after the commit that wrote it. finalise_receipt (stock), refund_receipt (restock), post_session (gl) and seal_cash_bag (payments) write the row in the same transaction; _dispatch (SENDERS, _after_sent) sends it afterwards (after the response, or by dispatch_outbox, the system client's job). A transient failure leaves the row pending; a 4xx from the neighbour makes it failed (never retried by itself); a sent row never goes back. A till sale is NEVER refused for stock: inventory being down is a pending row, not a refused sale.
  • States never move back: a counted session is not open again (a correction is a later session), a closed one is not counted again, a finalised receipt is never edited (a refund is a new receipt naming it), an outbox row sent is not resent.
  • The business date is the date in business_timezone with the shop's day cutoff (04:00), never the UTC date (domain/businessday.py, SettingsRepository.timezone() is the one port). A session fixes it when it opens; its receipts take the session's date.
  • Money is bounded: an amount arrives as a string (the input type is Any on purpose: pydantic would turn true into 1), is parsed in the use case after the permission check, and anything the books cannot hold is 422 {gate: bounds}, never a 500 (domain/money.py).
  • Statement count is flat in lines, receipts and unrelated rows (tests/test_statement_budget.py and the budget test of each slice-B file): the session's takings, moves, sales and journal, a safe's balance, a voucher's balance, a refund's claims and a recipe are one statement each, derived from their own rows; the expected cash, a balance and a safe are never a running total. Several inserts of one kind are ONE multi-row statement (claim_many, add_many, add_claims).
  • No person approves their own difference, paid-out, override, force-close, cash refund above the limit or post: matched on email OR sub (session.same_person).
  • The blind close: while a session is open and blind_close is on, get_session hides the cash it should hold.
  • Machine permissions (pos:outbox:dispatch and six more) are exact grants to the system's service client; no wildcard reaches them.
  • A sale is rung from the session's snapshot: no neighbour is read while selling (POS-30); a session opened before snapshots reads live.
  • A recipe's version and components are frozen on the line when it is rung; a later recipe never changes a past sale.
  • No read-your-writes inside an open transaction (DataK3): a use case reads committed rows first, writes, and reports from the rows in hand.
  • Input validation runs before the permission check in the doors, so money is Any and parsed inside (a caller without the permission sees 403, not 422).

Spec gaps found while building slice A (for erp-method; this brick is not blocked by them)

  1. product's spec lists GET /products/{product_id}?on= as a seam for inventory only, not for pos (tests/conftest.py allows it by name).
  2. (FIXED in the specs by erp-method v0.1.39: the unquoted on is quoted; the allowance is removed.) Was: { field: on, ... } unquoted YAML 1.1 in product PRD-40 and tax TAX-52.
  3. tax's v6 entries for pos (TAX-52 GET /rates, TAX-53 POST /documents/compute) sit under external_services: in tax's module.yaml, not under seams:, so contract.check cannot find them (conftest checks them against that entry). tests/test_seams.py fails the day any of the three is fixed, so the allowance goes with it.
  4. pos's own spec: pos:shop:read_all is not in the permission catalog (added to roles.yaml for retail_ops, accountant, owner: row_visibility needs a permission, not a role); the session has no currency, closing_float, counted_by/counted_sub or float_difference (added: the count must name its counter and the next float is compared with what this one left); a shop has no list of the people who work there (added: pos_shop_user, POST /shops/{id}/staff); business_timezone is read from a pos_settings control until the platform owns the bucket's one zone.
  5. The signed workflow has no transition for approving a float difference (POS-03) or the shift lead's recount (POS-06), and receipt.finalise claims the receipt number while ring_up needs it for the receipt_id: here ring_up takes the next number of the session (a plain INSERT of the receipt's own key).
  6. A recipe-less sale consumes the product itself from inventory (component 1); a latte is a 'make' product, so until POS-15 lands inventory is told of the finished drink, not its beans and milk.

Spec gaps found while building slice B (for erp-method; this brick is not blocked by them)

  1. gl's pos seam keys ONE journal per shop and business day (derived_id("pos:<shop>:<date>", "day_close" | "supplement")) and the workflow's post_session is by system once the shop day posted. With no shop_day built, post_session posts one journal per SESSION: source_document pos:<session_id>, event day_close, and is run by pos:day:post (accountant; a worker's client may hold it). The shop-day journal (sessions merged, supplement) is what is left of POS-20.
  2. gl lists no purpose for a paid-in or a float top-up: a paid-in credits shop_supplies, a top-up moves pos_cash_in_safe to pos_cash_in_till (pos_cash_reason is not built).
  3. POS-16's example sums components across lines (BEANS-HOUSE 0.036) but gives one move_ref per line: built per line (each line's components with its own move_ref).
  4. The workflow's void_open_receipt goes "to: none"; the void record needs a state: receipt status voided (the signed process.yaml is untouched).
  5. Reads pos makes that their owners list for other callers, not pos (conftest LENDERS holds each to a listed caller's schema; tests/test_seams.py fails the day pos is listed): product GET /products/{id}/convert (PRD-21, with GET /products/{id}); gl GET /companies/{code} (GL-61); inventory GET /warehouses/{id} and GET /locations (INV-01). tax's TAX-52/53 sit under external_services: (gap 3 of slice A). payments' default-bank-account read (PAY-47) is listed for ar only, so seal_cash_bag takes bank_account_id explicitly.
  6. manufacturing's explode request prose names unit_code and modifiers without "optional", so the contract check requires both: pos always sends both (modifiers empty).
  7. Added to the spec's entities (additive): session last_receipt_no, claimed_gap_*, posted_at, journal_id, snapshot_no; receipt original_receipt_id, void_*; receipt line modifiers, kind (voucher_code on the line: the spec's receipt_line has none), restock; payment line voucher_code; refund_claim gross, vat; register_snapshot price_list_id, country, currency, prices_include_tax, valid_until; tables pos_line_component (what a line consumed, frozen), pos_category_rule, pos_recipe(_line).
  8. POS-11's tax recompute (POST /documents/compute with pinned_from, POS-28) is not called: the pin is the original line's tax code and rate, copied. A refund's restock default is the category rule keyed by the line's TAX category (pos knows no product category). A voucher tender is not refunded back to the voucher.
  9. Vouchers: only the multi-purpose profile (VAT at redemption); voucher_validity_months is a setting; the stored status is constant, expired/exhausted are derived from expires_on and the claims; no expire claim (breakage is the accountant's, later).
  10. The snapshot holds the shop's whole price list (the layout, POS-02, would narrow it): one product read per priced product when a session opens.
  11. Receipt numbers are still per session, not per register (slice A gap 5).

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.

  • The shop-day journal (POS-20). Post one journal per shop and business day, source_document pos:<shop>:<date>, event day_close; post_session follows the day instead of posting each session; a supplement is pos:<shop>:<date>:supplement:<n> (gl v9's id rule, so supplement 2 is not a replay of 1).
  • Owners now list pos (remove the conftest LENDERS allowances and the test_seams.py expectations): tax TAX-52/53 are under seams:; product GET /products/{id}, /price, /convert; gl GET /periods/open, GET /companies/{code}; inventory GET /warehouses/{id}, GET /locations; business-partner GET /partners/{bp_id}.
  • Payments' default bank account. seal_cash_bag reads GET /bank-accounts/default?company_code=&currency= (PAY-47, now listed for pos) instead of taking bank_account_id typed in.
  • The void state is in the spec now (void_open_receipt goes to voided): no code change beyond matching the signed process.
  • gl purposes for paid-in and float are named: safe drop and float top-up move between pos_cash_in_safe and pos_cash_in_till; a paid-out or paid-in posts against its pos_cash_reason purpose (default shop_supplies); build pos_cash_reason.
  • The tax recompute on refunds. Keep copying the original line's code and rate at the till; at sync send the refund to tax POST /documents/compute with pinned_from (POS-28) and quarantine it if tax answers differently.
  • business_timezone is read from gl GET /companies/{company_code} per request; drop the pos_settings control. Migration stages (sign writes, discard deletes staging only).

Before you commit

pytest -q --tb=short     # stories, invariants, domain, doors, seams, budget, process, schema, scenarios
python scripts/gen_schema.py --check
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_pos/roles.yaml