erp-brick-product — Products & price lists
One list of what you sell, with its unit, how it is billed and how it is taxed, and the prices each customer pays: list prices per currency, customer price lists, quantity breaks and dated price changes, so a quote, an order and an invoice all charge the same price without anyone re-typing it.
A brick on erp-platform (0.9.2), built from the signed product module spec (erp-method modules/product, v1) with
erp-compose scaffold. Version 0.2.0 (spec v4 slice A: PRD-27..33). Version 0.2.2 (branch perf/batch-price) implements spec v7's
POST /prices/batch (PRD-41, a DRAFT for sign-off on erp-method spec/product-batch-price): many lines priced, rounded and converted
in one read, each part the one-line route's answer (tests/test_batch_price.py). The data model is its own; the price book that brick-quote 0.2.0 keeps in
quote_prices moves here (PRD-20).
What it does
Products (prod_product): a SKU the customer chooses (a string, fixed once set), a name that can change, a unit
(UN/ECE Recommendation 20: C62, HUR, DAY, MON, KGM …, seeded), a category tree, a TAX CATEGORY (never a tax code or a
rate), how it is billed (one_off | recurring | metered, plan single | periodic | arrears), its invoicing policy
(ordered | delivered) and a standard cost that sales never sees. A product is a draft until it has a unit, a tax
category, a billing type and an invoicing policy; then active (offered to sales); then archived — no new lines,
every document that names it keeps its own copy, and it can come back with a recorded reason. Nothing is deleted.
Units (PRD-23). A product has a BASE unit (what it is stocked in) and a sales, purchase and issue unit; every other unit it
can be counted in (a pack, a pallet) is a row prod_product_unit — "1 of it = numerator / denominator of the base unit", two
integers, never a decimal factor — or is stated as "24 of the bottle". A conversion true for every product (KGM-GRM, LTR-MLT,
HUR-MIN, seeded in prod_unit_conversion) has no product row. All arithmetic is Fraction; a quantity is rounded to the unit's
quantity_decimals only at the very end (convert_quantity shows the exact fraction beside it). A conversion a document or stock
has used (mark_unit_used, called by quote, orders, invoices and stock through a service client holding the MACHINE permission
product:unit:mark_used) never changes — nor its packaging level, decimals or e-invoice code, nor a template row its products
inherit: a changed pack size is a new unit code (add_unit). A pack code is your own (PK24, PALLET80) but never a seeded UN/ECE
measuring code (KGM as 25 pieces is refused) and carries an einvoice_unit_code (Rec 20/21, XPK, XBX) before a document uses it. Activation needs a base unit and a sales unit.
Templates and variants (PRD-24). generate_variants describes a product that comes in sizes or colours once — a template
with attributes (volume: 0.33 L, 0.75 L, 1.5 L) — and makes one DRAFT product, its own SKU, per combination (COLA-033, COLA-075,
COLA-150: the template code, then each value's code). A variant carries the template's category, tax category, billing, base
unit and the units the template gives; a product's own unit row overrides its template's. Generating again adds only the missing
combinations (new values may be added, a new attribute may not). A product with no variants is its own template. A price, a
document line and a stock row name the PRODUCT, never the template; a price rule can cover a template (applies_to: template).
Barcodes (PRD-25). Each packaging level (each, inner, case, pallet) of a product has its own GTIN (GS1 check digit
verified). A GTIN is unique across every product and unit — claimed by a plain insert into prod_gtin, so two callers racing for
one barcode cannot both win — and find_by_gtin answers the product, unit, packaging level and how many base and sales units it
holds (a pallet of COLA-075: 1 920 bottles, 1 440 LTR).
Prices per unit (PRD-26). A price row is keyed [list, product, unit, valid_from]. find_price(product_id, quantity, unit_code, ...) (default: the sales unit) uses the row in the asked unit if there is one, otherwise the base-unit row times the exact
conversion, and the answer says which (priced_from). A fixed price or surcharge in a rule is stated in the unit_code it was written in (a rule on one product defaults to that product's
sales unit AT WRITE TIME and stores it; any other scope must say it), so changing a sales unit later changes no past price. Quantity
breaks compare in the base unit, so 2 packs and 48 bottles meet the same break.
Day one. While a currency has no published price yet, the first publish may start rows and rules today (never earlier); the first default list may start today; after that, "after today" again.
Categories are dated. A product's category, and a category's parent, are append-only rows (prod_category_membership,
key [product_or_category, valid_from]); a move starts TOMORROW, and a price rule on a category is resolved by where the product
sat on the date asked — so moving a product to services leaves every price for today or earlier as it was. The product's
view shows its category today and category_next for a move that has not started.
Price lists (prod_price_list, prod_price, prod_price_rule, prod_customer_price_list): a list is in ONE
currency, standalone or based on another list of that currency, with a rounding rule (by default the currency's own smallest unit: JPY rounds to 1, KWD to 0.001; x.99 never goes
below 0.99, under 0.50 it falls back to the cent) and a flag for "prices include VAT" (a list is only based on, or
ruled from, a list with the same VAT basis). The currency must be a real ISO 4217 code. It holds dated rows (one product's price from a date; quantity breaks are rules, not rows) and rules. Both
stay draft until published. Nothing published or assigned changes a price for a date already past: publishing
(a first publish, or a scheduled change, which waits as a draft until it is published too) is refused (409) while any
row or rule starts today or earlier — so is a late publish, after the date came and went (discard the draft, discard /
discard_rules, and schedule it later) — and so is an assignment, a default_from or a row dated today or earlier. A
price row is never edited — a different price needs a later valid_from, so a document
priced last year re-prices identically and an audit can say what the price WAS on a date. A draft row that never
priced anything may be discarded.
How a price is resolved (find_price, derived on read — nothing stores "the current price")
find_price(product_id, currency, quantity, bp_id, priced_on) asks the customer's own list for that currency on that date (assignments are
append-only rows with a start date), then the currency's default list on that date; the first list that has a price
answers (and fell_back says so) — but never a list priced on the other side of VAT, so a gross customer list never falls
back to a net default. The default on a date is the list with the latest default_from on or before it: one per
currency on any date, no two lists share a default_from, and a default_from must be after today when published — or today, for the first default list a currency ever has (a draft's default_from, rows and rules stay editable until then). A
RETIRED list still answers every date up to the day it was retired, so the past never 404s. Inside a list: (1) its rules in the list's stated order (seq) — the FIRST that
matches wins; a rule matches on scope (one product, a category and everything under it, or all), the quantity from
which it applies (a quantity break) and its date window, and produces a price by fixed, discount_pct off a base
(another list, or this list's own prices) or formula (percentage off, plus a per-unit surcharge); a rule whose base
cannot be priced does not match and the next is tried; computed prices are rounded by the list's rule (0.01, 0.05,
0.10, 0.50, 1.00, x.99, none); (2) else its own rows — the latest valid_from on or before the date; (3) else the list it is based on. A price is never converted between currencies: no list in
the asked currency is an answer that says so. The answer names the list, the rule, the row and what a percentage
started from, and carries line — the product's description, unit, tax category, billing and invoicing policy, which
an order or invoice line COPIES and never asks for again (PRD-21). Cost is never in it. An archived or draft product is
refused for new lines.
The seam (PRD-20). brick-quote asks GET /products/{product_id}/price?currency=&quantity=&bp_id=&priced_on= (MCP
tool find_price) as the rep, with the rep's identity (product:price_list:read + product:product:read), and stores the
answer on its line. The migration of quote_prices is a later step: it copies the rows into prod_price keyed the
same (price_book_id becomes price_list_id; currency moves to the list) with POST /price-lists/{id}/set-prices,
and unit_price widens from NUMERIC(18,2) to the rate NUMERIC(19,4). PRD-22 is POST /catalogue/import:
products keyed on their code, prices on (list, product, valid_from) — importing the same file twice changes nothing.
The import (PRD-22) is POST /catalogue/import, held only by a service client with the machine permission
product:catalogue:import: it adds draft products (a person completes and activates them) and draft price rows, never
publishes, and a cost in the file is written, never read back.
AI steps (ai.suggest, suggest mode, a person decides; none is safe for auto): ai_check_duplicate (on by default;
a fixed word-overlap check always runs, a provider adds to it), ai_suggest_details, ai_price_check (the fixed
unusual_price_pct threshold, plus a provider). With no AI provider connected the step leaves a ManualTask in
prod_task — GET /tasks, closed with POST /tasks/{id}/close. No model is called in any test.
Permissions (module:entity:action; the brick never compares a role's name)
| Permission | Lets you |
|---|---|
product:product:read | see active products (and the answer to find_price) |
product:product:write | add, complete, activate, change and reactivate products; see drafts, history and cost |
product:product:archive | archive a product |
product:category:write | keep the category tree |
product:price_list:read | see published lists and ask find_price |
product:price_list:write | open lists, set prices and rules, schedule a change, retire; see draft lists |
product:price_list:publish | publish a list or a scheduled change (a second person where the setting asks) |
product:customer_price_list:write | assign a customer to a list |
product:settings:write | the settings, the units — NOT the two controls below (refused 403) |
product:settings:controls | the controls on pricing authors: publish_needs_second_person, unusual_price_pct (set_pricing_control); never held by whoever they control |
product:catalogue:import | machine — the one-off catalogue import (PRD-22): adds DRAFT products and DRAFT price rows, never publishes, never sees cost_amount |
product:unit:mark_used | machine — a selling brick's service client records that a document used a unit (freezes its conversion) |
product:product:suggest, product:price_list:check | machine — the AI duplicate check / details / price check; only a service client holds them, no wildcard reaches them |
Row visibility: everyone sees active products and published prices; draft/archived products, draft/retired lists and
cost_amount are for the catalogue and pricing permissions (cost for product:product:write alone). A row you may not
see is a 404, never a 403.
Default roles (src/brick_product/roles.yaml, a recommendation the composition maps its own roles onto)
| Role | Permissions |
|---|---|
catalogue_manager | product:read/write/archive, category:write, settings:write |
pricing_manager | product:read, price_list:read/write/publish, customer_price_list:write, settings:write |
owner | product:read, price_list:read, settings:controls |
sales_rep | product:read, price_list:read |
system (service client) | product:read, price_list:read, catalogue:import, product:suggest, price_list:check — no write permission |
Settings (prod_settings, one typed row each; null = the default)
| Key | Type | Default | Changes |
|---|---|---|---|
product_code_format | str | typed | typed, or a pattern with one counter such as PRD-{seq:5} (leave the code out to get the next) |
publish_needs_second_person | bool (controls) | false | the person who prepared a list (or a scheduled change) may not publish it |
business_timezone | str (IANA, controls) | Europe/London | the zone in which "today" is counted: every "after today" below is a date in this zone |
unusual_price_pct | int (controls) | 50 | how far a price may sit from its base before publishing warns |
ai.ai_check_duplicate.mode | off | suggest | suggest | the duplicate check |
ai.ai_suggest_details.mode | off | suggest | suggest | the details suggestion |
ai.ai_price_check.mode | off | suggest | suggest | the AI price check |
Also configuration rows: prod_category (tree), prod_unit (Rec 20 codes, seeded), and per list its currency, default_from,
base list, VAT-inclusive flag and rounding. Decisions the customer signed: the price book moves here; a unit price is
a RATE NUMERIC(19,4) and a line total NUMERIC(18,2); no variants (a size or a term is a product of its own).
Layout and running
src/brick_product/
domain/ the rules, plain Python: product.py, price_list.py, pricing.py (resolution), customer_price_list.py
application/ the use cases — each declared once, served as an API route AND an MCP tool; process.yaml (signed)
infrastructure/ tables + repositories, seams.py (business-partner), ai_steps.py (ai.suggest + the fallback)
roles.yaml the default roles
app.py the ASGI app: routes, /mcp, the sign-in metadata for agents
schema.sql the tables; migrations/00001_init/{up,down}.sql
tests/ rules (one per story), pricing, both doors, the generated probes, scenarios, process signature, seams
pip install -e '.[test]'
pytest -q # no bucket needed
BUCKET=my-bucket erp-migrate --schema src/brick_product/schema.sql --brick product=0.2.0
BUCKET=my-bucket DEV_ALLOW_ANON=1 DEV_USER_PERMISSIONS=product:product:write,product:price_list:write,product:price_list:publish \
uvicorn brick_product.app:app # API at /docs, agents at /mcp
erp-scenarios run tests/scenarios/catalogue.py # SC-PRD-01..03 and the generated probes, in process
Migrations. migrations/00001_init/{up,down}.sql is the versioned layout with a per-brick journal; erp-platform
has no migrator for it yet, so erp-migrate --schema schema.sql is what applies the tables today. Seed the settings
and units once (Repository(conn).seed()).
Known limits (module.yaml known_gaps, and what this build leaves)
No variants; no unit-of-measure conversion (a product is sold in one unit); no stock fields (inventory, M4); no
promotions or coupons; one standard cost per product. default_from is set when a list is opened and checked when it is published. A list that another list or rule starts from cannot be
retired, nor the currency's default today or later. A published list's rules cannot be edited, only added (dated after
today). A customer is assigned to a list only if the partner register (business-partner) has them — without it the
assignment is refused 409, a register that refused the question is reported 403, one that failed 502, one that did not
answer 503 — and customer assignments carry a start date (the spec's key [bp_id, currency] gains valid_from, so an
earlier date keeps its answer).