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):
- 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 astips_payable, voucher liability both ways, the drawer's difference, paid-outs and safe drops; a pending outbox row after the commit, the session ispostedonce 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 getsPOST /deposit-batches/expectedfrom a pending row). - Void and refund:
void_receipt(an open receipt kept as avoidedrecord, its number never reused; a voucher it spent goes back),refund_receipt(POS-11: each original line's quantity is claimedrefund:<original>:<line>:<n>, the ORIGINAL's tax code and rate, the money back the way it came, a cash refund abovecash_refund_limitneeds another shift lead, restocked lines to inventoryPOST /receipts(INV-59) from pending rows),set_category_rule. - Vouchers and the card terminal: voucher_sale lines in
ring_up(vouchers=), redemption as avoucherpayment (claimsvoucher:<code>:<n>, balance derived, never stored),get_voucher; a card that timed out ispendinguntilresolve_card_payment(POS-09, POS-13). - 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_sourcepos | manufacturing (POS-31:GET /boms/{product_id}/explode, checked against manufacturing's signed spec). - The register snapshot (POS-30):
open_sessionreads 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) andring_upsells from it: a sale reads no neighbour. - POS-01 setup validation:
keep_shopchecks (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 rule | domain/ - and its test in tests/test_domain.py / test_stories.py / test_invariants.py |
| price, VAT, rounding | domain/pricing.py (the answers are product's and tax's; pos types neither) |
| who may do something, or which rows | application/use_cases.py - the permission on the declaration; _scope() for rows; 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 (product, tax, inventory, gl, payments, manufacturing) |
| a setting the customer edits | domain/values.py SETTING_DEFS (controls=True means pos:settings:controls, set through set_pos_control) |
| a column or table | infrastructure/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 quantityrefund:<original>:<line>:<n>(with itspos_refund_claimrow), a voucher's issue, redemption and give-backvoucher:<code>:<n>(with itspos_voucher_claimrow, the operation is part of what is claimed), a cash bagbag:<shop>:<bag_no>andsafe:<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) andseal_cash_bag(payments) write the row in the same transaction;_dispatch(SENDERS,_after_sent) sends it afterwards (after the response, or bydispatch_outbox, the system client's job). A transient failure leaves the row pending; a 4xx from the neighbour makes itfailed(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_timezonewith 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
Anyon purpose: pydantic would turntrueinto 1), is parsed in the use case after the permission check, and anything the books cannot hold is422 {gate: bounds}, never a 500 (domain/money.py). - Statement count is flat in lines, receipts and unrelated rows (
tests/test_statement_budget.pyand 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_closeis on,get_sessionhides the cash it should hold. - Machine permissions (
pos:outbox:dispatchand 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
Anyand 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)
- product's spec lists
GET /products/{product_id}?on=as a seam for inventory only, not for pos (tests/conftest.pyallows it by name). - (FIXED in the specs by erp-method v0.1.39: the unquoted
onis quoted; the allowance is removed.) Was:{ field: on, ... }unquoted YAML 1.1 in product PRD-40 and tax TAX-52. - tax's v6 entries for pos (TAX-52
GET /rates, TAX-53POST /documents/compute) sit underexternal_services:in tax's module.yaml, not underseams:, socontract.checkcannot find them (conftest checks them against that entry).tests/test_seams.pyfails the day any of the three is fixed, so the allowance goes with it. - pos's own spec:
pos:shop:read_allis not in the permission catalog (added toroles.yamlfor retail_ops, accountant, owner: row_visibility needs a permission, not a role); the session has nocurrency,closing_float,counted_by/counted_suborfloat_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_timezoneis read from apos_settingscontrol until the platform owns the bucket's one zone. - The signed workflow has no transition for approving a float difference (POS-03) or the shift lead's recount (POS-06), and
receipt.finaliseclaims the receipt number whilering_upneeds it for the receipt_id: here ring_up takes the next number of the session (a plain INSERT of the receipt's own key). - 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)
- gl's pos seam keys ONE journal per shop and business day (
derived_id("pos:<shop>:<date>", "day_close" | "supplement")) and the workflow'spost_sessionis by system once the shop day posted. With noshop_daybuilt,post_sessionposts one journal per SESSION: source_documentpos:<session_id>, eventday_close, and is run bypos:day:post(accountant; a worker's client may hold it). The shop-day journal (sessions merged, supplement) is what is left of POS-20. - gl lists no purpose for a paid-in or a float top-up: a paid-in credits
shop_supplies, a top-up movespos_cash_in_safetopos_cash_in_till(pos_cash_reason is not built). - 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).
- The workflow's
void_open_receiptgoes "to: none"; the void record needs a state: receipt statusvoided(the signed process.yaml is untouched). - Reads pos makes that their owners list for other callers, not pos (conftest
LENDERSholds each to a listed caller's schema;tests/test_seams.pyfails the day pos is listed): productGET /products/{id}/convert(PRD-21, with GET /products/{id}); glGET /companies/{code}(GL-61); inventoryGET /warehouses/{id}andGET /locations(INV-01). tax's TAX-52/53 sit underexternal_services:(gap 3 of slice A). payments' default-bank-account read (PAY-47) is listed for ar only, soseal_cash_bagtakesbank_account_idexplicitly. - manufacturing's explode request prose names
unit_codeandmodifierswithout "optional", so the contract check requires both: pos always sends both (modifiers empty). - Added to the spec's entities (additive): session
last_receipt_no,claimed_gap_*,posted_at,journal_id,snapshot_no; receiptoriginal_receipt_id,void_*; receipt linemodifiers,kind(voucher_code on the line: the spec's receipt_line has none),restock; payment linevoucher_code; refund_claimgross,vat; register_snapshotprice_list_id,country,currency,prices_include_tax,valid_until; tablespos_line_component(what a line consumed, frozen),pos_category_rule,pos_recipe(_line). - POS-11's tax recompute (
POST /documents/computewith 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. - Vouchers: only the multi-purpose profile (VAT at redemption);
voucher_validity_monthsis a setting; the stored status is constant,expired/exhaustedare derived from expires_on and the claims; noexpireclaim (breakage is the accountant's, later). - 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.
- 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_sessionfollows the day instead of posting each session; a supplement ispos:<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
LENDERSallowances and thetest_seams.pyexpectations): tax TAX-52/53 are underseams:; productGET /products/{id},/price,/convert; glGET /periods/open,GET /companies/{code}; inventoryGET /warehouses/{id},GET /locations; business-partnerGET /partners/{bp_id}. - Payments' default bank account.
seal_cash_bagreadsGET /bank-accounts/default?company_code=¤cy=(PAY-47, now listed for pos) instead of takingbank_account_idtyped in. - The void state is in the spec now (
void_open_receiptgoes tovoided): 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_safeandpos_cash_in_till; a paid-out or paid-in posts against itspos_cash_reasonpurpose (defaultshop_supplies); buildpos_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/computewithpinned_from(POS-28) and quarantine it if tax answers differently. - business_timezone is read from gl
GET /companies/{company_code}per request; drop thepos_settingscontrol. 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