"""The business rules — one test per story (module.yaml stories), each written from its Given/When/Then. The pure
rules run on the domain alone; a story that needs the books runs through the real use cases on SQLite (the API door:
tests/test_doors.py proves the other door answers the same). The module's invariants close the file."""
import json
from datetime import date
from decimal import Decimal as D
import pytest
from conftest import (
CATALOGUE,
DEFAULT,
OWNER,
PRICING,
SALES,
SYSTEM,
api,
categories,
mk_list,
mk_product,
must,
on_day,
price,
publish,
row,
)
from brick_product.application import use_cases
from brick_product.domain.errors import RuleBroken
from brick_product.domain.price_list import PriceList, RowIn, plan_changes
from brick_product.domain.pricing import PriceRow
from brick_product.domain.product import Product, likely_duplicates, valid_code
SERVICE_TYPE = {"product_type_code": "SERVICE", "kind": "service", "stocked_default": False, "sellable": True,
"valuation_class": "", "required_roles": []}
def a_product(**kw) -> Product:
return Product.add_product("HOST-M", name="Managed hosting, per month", type_row=SERVICE_TYPE, **kw)
def counts():
from sqlalchemy import func, select
from brick_product.infrastructure.tables import prod_price, prod_product
with use_cases.repository() as repo:
return (repo.conn.execute(select(func.count()).select_from(prod_product)).scalar(),
repo.conn.execute(select(func.count()).select_from(prod_price)).scalar())
def partner_world():
"""LIST-GBP: HOST-M 120.00 from 2026-11-01. PARTNER-GBP: from 10 units 15% off LIST-GBP, else 10% off, for hosting.
Customer 7311 is on PARTNER-GBP. (The break comes first: the first rule that matches wins.)"""
categories(("hosting", None), ("services", None))
mk_product("HOST-M", category_code="hosting")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
mk_list("PARTNER-GBP", rules=[
{"seq": 1, "applies_to": "category", "target": "hosting", "min_quantity": "10", "discount_pct": "15", "base_list": "LIST-GBP"},
{"seq": 2, "applies_to": "category", "target": "hosting", "discount_pct": "10", "base_list": "LIST-GBP"}])
must(api("POST", "/price-lists/PARTNER-GBP/assign-customer", PRICING, {"bp_id": "7311"}))
# ── person stories ────────────────────────────────────────────────────────────────────────────
def test_prd_01_a_draft_product_host_m_exists_adding_a_second_with_the_code_is_refused():
"""PRD-01 — Given no product HOST-M, I add HOST-M "Managed hosting, per month", unit MON, category hosting, tax
category standard, billing recurring / periodic, invoicing policy ordered. Then a draft product HOST-M exists;
a second HOST-M is refused ("HOST-M already exists"); the code is fixed from now on, the name is not."""
categories(("hosting", None))
made = must(api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE",
"product_id": "HOST-M", "name": "Managed hosting, per month", "base_unit_code": "MON", "category_code": "hosting",
"tax_category": "standard", "billing_kind": "recurring", "billing_plan": "periodic",
"invoicing_policy": "ordered"}))
assert (made["product_id"], made["status"], made["billing_plan"]) == ("HOST-M", "draft", "periodic")
status, body = api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "HOST-M", "name": "Another"})
assert (status, body["detail"]) == (409, "HOST-M already exists")
assert api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "host-m", "name": "Another"})[0] == 409 # one SKU, any case
must(api("POST", "/products/HOST-M/activate-product", CATALOGUE, {}))
renamed = must(api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"name": "Managed hosting (monthly)"}))
assert (renamed["product_id"], renamed["name"]) == ("HOST-M", "Managed hosting (monthly)")
assert api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"product_id": "HOST-2"})[0] == 409 # nothing to change:
assert api("GET", "/products/HOST-2", CATALOGUE)[0] == 404 # no way to change the code
for bad in ("", " ", "has space", "-lead", "x" * 65, "a/b c"):
with pytest.raises(RuleBroken):
valid_code(bad)
def test_prd_02_refused_naming_what_is_missing_tax_category():
"""PRD-02 — Given a draft product with no tax category, activating it is refused, naming what is missing; with a
unit, a tax category, a billing type and an invoicing policy it becomes active and appears to sales."""
mk_product("HOST-M", activate=False, tax_category=None)
status, body = api("POST", "/products/HOST-M/activate-product", CATALOGUE, {})
assert status == 409 and "missing a tax category" in body["detail"] and "unit" not in body["detail"]
assert api("GET", "/products/HOST-M", SALES)[0] == 404 and must(api("GET", "/products", SALES))["products"] == []
must(api("POST", "/products/HOST-M/complete-product", CATALOGUE, {"tax_category": "standard"}))
assert must(api("POST", "/products/HOST-M/activate-product", CATALOGUE, {}))["status"] == "active"
assert [p["product_id"] for p in must(api("GET", "/products", SALES))["products"]] == ["HOST-M"]
bare = Product.add_product("BARE", name="Bare", type_row=SERVICE_TYPE)
with pytest.raises(RuleBroken, match="a base unit, a sales unit, a tax category, a billing type, an invoicing policy"):
bare.activate_product()
def test_prd_03_it_is_no_longer_offered_for_new_lines_and_documents_keep_their_copy():
"""PRD-03 — Given an active product on issued documents, archiving it: no longer offered for new lines; what the
documents copied is unchanged; reactivating is allowed and recorded."""
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
copy = must(price("HOST-M"))["line"] # what an invoice line copied
must(api("POST", "/products/HOST-M/archive-product", CATALOGUE, {}))
status, body = price("HOST-M")
assert status == 409 and "archived" in body["detail"] and "not offered for new lines" in body["detail"]
assert must(api("GET", "/products", SALES))["products"] == [] and api("GET", "/products/HOST-M", SALES)[0] == 404
assert must(api("GET", "/products/HOST-M", CATALOGUE))["name"] == copy["name"] # nothing deleted
assert api("POST", "/products/HOST-M/reactivate-product", CATALOGUE, {"reason": " "})[0] in (409, 422)
back = must(api("POST", "/products/HOST-M/reactivate-product", CATALOGUE, {"reason": "winter range returns"}))
assert back["status"] == "active"
log = [(h["event"], h["detail"]) for h in back["history"]]
assert ("archived", "") in log and ("reactivated", "winter range returns") in log
assert must(price("HOST-M"))["line"] == copy
def test_prd_04_a_move_starts_tomorrow_rules_on_services_apply_from_then_and_today_still_uses_hosting():
"""PRD-04 — Given categories hosting and services, moving a product to services: from tomorrow, price rules on the
services category apply to it; a price asked for today or earlier still uses hosting; documents already priced are
unchanged. (The audit's repro: 120.00 must not become 96.00 for a past date.)"""
categories(("hosting", None), ("services", None))
mk_product("HOST-M", category_code="hosting")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT,
rules=[{"seq": 1, "applies_to": "category", "target": "services", "discount_pct": "20"}])
held = {d: must(price("HOST-M", priced_on=d)) for d in ("2026-11-10", "2026-11-20")}
assert [a["unit_price"] for a in held.values()] == ["120.00", "120.00"]
moved = must(api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"category_code": "services"}))
assert moved["category_code"] == "hosting" # today it is still in hosting
assert moved["category_next"] == {"category_code": "services", "valid_from": "2026-11-21"}
assert {d: must(price("HOST-M", priced_on=d)) for d in held} == held # 120.00 for today and earlier
assert must(price("HOST-M", priced_on="2026-11-21"))["unit_price"] == "96.00" # 96.00 from tomorrow
assert must(price("HOST-M"))["unit_price"] == "120.00" # "today" asked without a date
status, body = api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"category_code": "hosting"})
assert status == 409 and "already moving" in body["detail"] # one move a day, never edited
assert api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"category_code": "nowhere"})[0] == 422
with on_day("2026-11-21T09:00:00+00:00"):
assert must(api("GET", "/products/HOST-M", CATALOGUE))["category_code"] == "services"
assert must(price("HOST-M"))["unit_price"] == "96.00"
def test_prd_05_a_price_row_exists_a_second_price_for_the_date_is_refused_an_earlier_row_is_not_changed():
"""PRD-05 — Given LIST-GBP in GBP, setting HOST-M to 120.00 from 2026-11-01: the row exists; a second price for
the same product and date is refused; an earlier row is not changed."""
mk_product("HOST-M")
mk_list("LIST-GBP", publish=False)
wrote = must(api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "120.00")]}))
assert wrote["written"]["rows"] == 1
listed = must(api("GET", "/price-lists/LIST-GBP/rows", PRICING))["rows"]
assert [(r["product_id"], r["valid_from"], r["unit_price"]) for r in listed] == [("HOST-M", "2026-11-01", "120.00")]
status, body = api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "130.00")]})
assert status == 409 and "already has a price of 120.00 from 2026-11-01" in body["detail"] and "never edited" in body["detail"]
must(api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2027-01-01", "130.00")]}))
again = must(api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "120.00")]}))
assert again["written"]["rows"] == 0 and again["written"]["rows_unchanged"] == 1 # the same price again: nothing
assert [r["unit_price"] for r in must(api("GET", "/price-lists/LIST-GBP/rows", PRICING))["rows"]] == ["120.00", "130.00"]
assert api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rows": [row("NOPE", "2026-11-01", "1")]})[0] == 422
def test_prd_06_102_a_unit_with_the_rule_named_5_units_108_and_the_first_rule_wins():
"""PRD-06 — Given LIST-GBP with HOST-M at 120.00 and PARTNER-GBP with '10% off LIST-GBP for hosting' and 'from 10
units 15% off': 12 units on PARTNER-GBP is 102.00 a unit with the rule named; 5 units is 108.00; the rules apply
in the list's stated order and the first that matches wins."""
partner_world()
twelve = must(price("HOST-M", bp_id="7311", quantity="12", priced_on="2026-12-01"))
assert (twelve["unit_price"], twelve["price_list_id"], twelve["via"]) == ("102.00", "PARTNER-GBP", "rule")
assert (twelve["rule"]["seq"], twelve["rule"]["method"], twelve["rule"]["discount_pct"]) == (1, "discount_pct", "15.0000")
assert twelve["how"]["base"]["price_list_id"] == "LIST-GBP"
assert must(price("HOST-M", bp_id="7311", quantity="5", priced_on="2026-12-01"))["unit_price"] == "108.00"
# stated order: put the general 10% FIRST and it matches first — the quantity break never fires
mk_list("SWAPPED-GBP", rules=[
{"seq": 1, "applies_to": "category", "target": "hosting", "discount_pct": "10", "base_list": "LIST-GBP"},
{"seq": 2, "applies_to": "category", "target": "hosting", "min_quantity": "10", "discount_pct": "15", "base_list": "LIST-GBP"}])
must(api("POST", "/price-lists/SWAPPED-GBP/assign-customer", PRICING, {"bp_id": "7312"}))
swapped = must(price("HOST-M", bp_id="7312", quantity="12", priced_on="2026-12-01"))
assert (swapped["unit_price"], swapped["rule"]["seq"]) == ("108.00", 1) # not 102.00: rule 1 won
assert must(price("HOST-M", bp_id="7311", quantity="12", priced_on="2026-12-01"))["unit_price"] == "102.00"
def test_prd_07_publish_shows_the_warning_and_waits_for_a_second_person_where_the_setting_asks():
"""PRD-07 — Given a draft list with a price 90% below the list it is based on, publishing shows the warning and,
where the setting asks for it, waits for a second person's approval; once published, sales prices from it."""
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
mk_list("TYPO-GBP", based_on="LIST-GBP", rows=[row("HOST-M", "2026-11-01", "12.00")], publish=False)
check = must(api("POST", "/price-lists/TYPO-GBP/ai-price-check", SYSTEM, {}))
assert [(f["product_id"], f["deviation_pct"]) for f in check["flagged"]] == [("HOST-M", "-90.00")]
must(api("POST", "/pricing-controls/publish_needs_second_person", OWNER, {"value": True}))
status, body = publish("TYPO-GBP")
assert status == 409 and "a second person must publish TYPO-GBP" in body["detail"]
assert api("GET", "/price-lists/TYPO-GBP", SALES)[0] == 404 # still a draft: sales cannot see it
published = must(publish("TYPO-GBP", PRICING_2))
assert published["status"] == "published"
warning, = published["publish_warnings"]
assert (warning["product_id"], warning["deviation_pct"], warning["compared_with"]) == ("HOST-M", "-90.00", "120.00")
assert must(api("GET", "/price-lists/TYPO-GBP", SALES))["status"] == "published"
def test_prd_08_a_customer_resolves_from_their_list_and_one_with_none_from_the_default():
"""PRD-08 — Given customer 7311 and the published PARTNER-GBP, assigning it for GBP: every GBP price for them
resolves from PARTNER-GBP; a customer with no assignment gets the currency's default list."""
partner_world()
assigned = must(price("HOST-M", bp_id="7311", priced_on="2026-12-01"))
assert (assigned["price_list_id"], assigned["assigned_list"], assigned["fell_back"]) == ("PARTNER-GBP", "PARTNER-GBP", False)
stranger = must(price("HOST-M", bp_id="9000", priced_on="2026-12-01"))
assert (stranger["price_list_id"], stranger["unit_price"], stranger["assigned_list"]) == ("LIST-GBP", "120.00", "")
one = must(api("GET", "/customer-price-lists/7311/GBP", PRICING))
assert (one["bp_id"], one["assignments"][0]["price_list_id"], one["assignments"][0]["valid_from"]) == ("7311", "PARTNER-GBP", "2026-11-21")
assert one["price_list_id"] == "" # it starts tomorrow, never today or earlier
assert api("GET", "/customer-price-lists/7311/GBP", SALES)[0] == 404
assert api("POST", "/price-lists/LIST-GBP/assign-customer", SALES, {"bp_id": "1"})[0] == 403
mk_list("DRAFT-GBP", publish=False)
assert api("POST", "/price-lists/DRAFT-GBP/assign-customer", PRICING, {"bp_id": "7311"})[0] == 409 # only a published list
must(api("POST", "/price-lists/PARTNER-GBP/retire-price-list", PRICING, {})) # they fall back to the default
assert must(price("HOST-M", bp_id="7311", quantity="12", priced_on="2026-12-01"))["price_list_id"] == "LIST-GBP"
def test_prd_09_forty_new_rows_as_a_draft_change_nothing_changes_before_publish_and_twice_adds_none():
"""PRD-09 — Given 40 hosting products on LIST-GBP, raising category hosting by 5% from 2027-01-01 (rounded to the
list's rule): 40 new rows valid from 2027-01-01, as a draft change; nothing changes before publish; running it
twice creates nothing more."""
categories(("hosting", None), ("other", None))
ids = [f"H{i:02d}" for i in range(40)]
for pid in ids:
mk_product(pid, category_code="hosting")
mk_product("ODD", category_code="other")
mk_list("LIST-GBP", rows=[row(p, "2026-11-01", "120.00") for p in ids] + [row("ODD", "2026-11-01", "10.00")],
default_from=DEFAULT, rounding="0.05")
raise_ = {"raise_pct": "5", "valid_from": "2027-01-01", "category_code": "hosting"}
first = must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, raise_))
assert first["written"]["rows"] == 40 and first["contents"]["rows_pending"] == 40
assert must(price("H00", priced_on="2027-02-01"))["unit_price"] == "120.00" # nothing changes before publish
again = must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, raise_))
assert again["written"]["rows"] == 0 and again["written"]["rows_unchanged"] == 40
must(api("POST", "/price-lists/LIST-GBP/publish-price-list", PRICING, {}))
assert must(price("H00", priced_on="2027-02-01"))["unit_price"] == "126.00"
assert must(price("H00", priced_on="2026-12-31"))["unit_price"] == "120.00"
assert must(price("ODD", priced_on="2027-02-01"))["unit_price"] == "10.00" # another category: untouched
after = must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, raise_)) # published, run a third time
assert after["written"]["rows"] == 0 and after["written"]["rows_unchanged"] == 40
status, body = api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"raise_pct": "5", "valid_from": "2026-11-20"})
assert status == 409 and "later date than today" in body["detail"] # a price in force is never edited
def test_prd_10_a_rep_sees_102_00_the_list_and_the_rule_and_never_the_cost():
"""PRD-10 — Given a customer on PARTNER-GBP, a rep asks for HOST-M, 12 units: they see 102.00, the list it came
from and the rule; never the cost."""
partner_world()
must(api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"cost_amount": "61.50"}))
got = must(price("HOST-M", bp_id="7311", quantity="12", priced_on="2026-12-01"))
assert (got["unit_price"], got["price_list_id"], got["rule"]["seq"]) == ("102.00", "PARTNER-GBP", 1)
assert "cost" not in json.dumps(got) and "61.5" not in json.dumps(got)
assert "cost" not in json.dumps(must(api("GET", "/products/HOST-M", SALES)))
assert must(api("GET", "/products/HOST-M", CATALOGUE))["cost_amount"] == "61.50" # those who keep the catalogue see it
assert "cost_amount" not in must(api("GET", "/products/HOST-M", PRICING))
# ── system stories ────────────────────────────────────────────────────────────────────────────
def test_prd_11_120_00_and_126_00_naming_the_list_the_rule_and_the_row_and_asking_twice_is_the_same():
"""PRD-11 — Given rows valid from 2026-11-01 (120.00) and 2027-01-01 (126.00), asked for 2026-12-31 and 2027-01-01:
120.00 and 126.00; the answer names the price list, the rule and the row it used; asking twice gives the same."""
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00"), row("HOST-M", "2027-01-01", "126.00")],
default_from=DEFAULT)
dec, jan = must(price("HOST-M", priced_on="2026-12-31")), must(price("HOST-M", priced_on="2027-01-01"))
assert (dec["unit_price"], jan["unit_price"]) == ("120.00", "126.00")
assert (dec["price_list_id"], dec["price_row"]["valid_from"], dec["rule"]) == ("LIST-GBP", "2026-11-01", None)
assert jan["price_row"]["valid_from"] == "2027-01-01" and jan["via"] == "row"
assert must(price("HOST-M", priced_on="2026-12-31")) == dec
assert api("GET", "/products/HOST-M/price", SALES, {"currency": "GBP", "priced_on": "2026-10-31"})[0] == 404 # before any row
must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2027-06-01", "140.00")]}))
assert must(price("HOST-M", priced_on="2026-12-31")) == dec # a scheduled change never moves a past answer
def test_prd_12_the_manager_is_shown_the_likely_duplicate_and_nothing_is_merged():
"""PRD-12 — Given HOST-M "Managed hosting, per month", adding HOSTING-MONTHLY "Monthly managed hosting": the
catalogue manager is shown the likely duplicate and may continue; nothing is merged. (No model is called: with no
AI provider the step leaves the ManualTask.)"""
mk_product("HOST-M")
made = must(api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "HOSTING-MONTHLY", "name": "Monthly managed hosting"}))
assert [d["product_id"] for d in made["likely_duplicates"]] == ["HOST-M"] and made["likely_duplicates"][0]["score"] >= 0.5
assert made["product_id"] == "HOSTING-MONTHLY" and made["ai"]["kind"] == "manual"
assert sorted(must(api("GET", "/products", CATALOGUE))["products"], key=lambda p: p["product_id"])[0]["product_id"] == "HOST-M"
assert len(must(api("GET", "/products", CATALOGUE))["products"]) == 2 # both exist
task, = must(api("GET", "/tasks", CATALOGUE))["tasks"]
assert task["status"] == "open" and task["step"] == "ai_check_duplicate" and task["capability"] == "ai.suggest"
assert "HOST-M" in task["output"]["baseline"][0]["product_id"] and task["plan"]
closed = must(api("POST", f"/tasks/{task['task_id']}/close", CATALOGUE, {"outcome": "dismissed", "note": "different term"}))
assert (closed["status"], closed["resolved_by"], closed["note"]) == ("dismissed", "catalogue_manager@shop.example", "different term")
unrelated = must(api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "LAPTOP", "name": "Laptop 14 inch"}))
assert unrelated["likely_duplicates"] == [] and unrelated["task_id"] is None
must(api("POST", "/settings/ai.ai_check_duplicate.mode", CATALOGUE, {"value": "off"}))
off = must(api("POST", "/products", CATALOGUE, {"product_type_code": "SERVICE", "product_id": "HOST-Q", "name": "Managed hosting, per quarter"}))
assert off["likely_duplicates"] == [] and off["ai"]["kind"] == "off"
assert [d["product_id"] for d in likely_duplicates("Managed hosting", "", [("HOST-M", "Managed hosting, per month", "")])] == ["HOST-M"]
# ── integration stories ───────────────────────────────────────────────────────────────────────
def test_prd_20_quote_asks_find_price_with_the_old_price_books_keys_and_gets_its_old_answers():
"""PRD-20 — Given brick-quote's price book in quote_prices (0.2.0), when a rep adds a line quote calls
find_price(product_id, quantity, bp_id, currency, priced_on). quote_prices is migrated here once, KEYED THE SAME
(price_book_id becomes price_list_id), and answers as quote's own lookup did: the latest row on or before the date."""
quote_prices = [("standard", "HOST-M", "GBP", "2026-01-01", "100.00"), ("standard", "HOST-M", "GBP", "2026-09-01", "110.00"),
("standard", "HOST-M", "GBP", "2027-03-01", "125.50")]
mk_product("HOST-M")
mk_list("standard", rows=[row(p, v, amount) for _, p, _, v, amount in quote_prices], default_from="2025-12-02",
at="2025-12-01T09:00:00+00:00")
for on, expected in (("2026-03-01", "100.00"), ("2026-09-01", "110.00"), ("2026-11-20", "110.00"), ("2027-03-01", "125.50")):
old = max((r for r in quote_prices if r[3] <= on), key=lambda r: r[3])[4] # quote's price(): latest valid_from <= on
got = must(price("HOST-M", quantity="3", bp_id="7311", priced_on=on))
assert got["unit_price"] == old == expected and got["price_list_id"] == "standard"
assert api("GET", "/products/HOST-M/price", SALES, {"currency": "EUR"})[0] == 404 # no EUR list: never converted
assert api("GET", "/products/HOST-M/price", SALES, {"currency": "gbp!"})[0] in (404, 422)
assert api("GET", "/products/HOST-M/price", SALES, {"currency": "GBP", "quantity": "0.00001"})[0] == 422
def test_prd_21_the_line_holds_a_copy_of_the_products_fields_and_the_product_id():
"""PRD-21 — Given HOST-M with its description, unit, tax category and invoicing policy, a line copies them and the
product_id; the product is not called again for that line. A later rename never rewrites the copy."""
mk_product("HOST-M", description="Fully managed, 99.9% uptime")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
copy = must(price("HOST-M"))["line"]
assert copy == {"product_id": "HOST-M", "name": "Managed hosting, per month", "description": "Fully managed, 99.9% uptime",
"base_unit_code": "MON", "sales_unit_code": "MON", "tax_category": "standard",
"billing_kind": "recurring", "billing_plan": "periodic", "invoicing_policy": "ordered",
"unit_code": "MON", "numerator": 1, "denominator": 1,
"einvoice_unit_code": "MON", # the unit sold in, its exact conversion and its e-invoice code
"unit_numerator": 1, "unit_denominator": 1, "product_type_code": "SERVICE", "sellable": True, "sellable_reason": None, "measures": None, # v4
"category_code": None} # v6: the product template's category (sales-order's category conditions match it)
assert not {"cost_amount", "tax_code", "tax_rate"} & set(copy) # a category, never a code or a rate
must(api("POST", "/products/HOST-M/rename-product", CATALOGUE, {"name": "Hosting, managed"}))
assert must(price("HOST-M"))["line"]["name"] == "Hosting, managed" and copy["name"] == "Managed hosting, per month"
def test_prd_22_300_products_and_300_price_rows_the_second_import_changes_nothing():
"""PRD-22 — Given a file of 300 products and their list prices, imported twice: 300 products and 300 price rows
exist, keyed on the product code and (list, product, valid_from); the second run changes nothing. The import adds
DRAFTS only, and never sees a cost."""
mk_list("LIST-GBP", publish=False)
file = [{"product_id": f"P{i:03d}", "product_type_code": "LEGACY", "name": f"Product {i:03d}", "base_unit_code": "C62", "tax_category": "standard",
"billing_kind": "one_off", "invoicing_policy": "ordered", "unit_price": f"{10 + i}.50",
"valid_from": "2026-11-01", "cost_amount": "4.25"} for i in range(300)]
first = must(api("POST", "/catalogue/import", SYSTEM, {"products": file, "price_list_id": "LIST-GBP"}))
assert (first["products_created"], first["prices_created"]) == (300, 300) and "activated" not in first
snapshot = counts()
second = must(api("POST", "/catalogue/import", SYSTEM, {"products": file, "price_list_id": "LIST-GBP"}))
assert (second["products_created"], second["products_unchanged"], second["prices_created"], second["prices_unchanged"]) == (0, 300, 0, 300)
assert counts() == snapshot == (300, 300)
changed = [{**file[0], "name": "Renamed in the file", "cost_amount": "9.99"}]
differ = must(api("POST", "/catalogue/import", SYSTEM, {"products": changed, "price_list_id": "LIST-GBP"}))
assert differ["products_differ"] == [{"product_id": "P000", "fields": ["name"]}] and differ["products_created"] == 0 # cost never compared
got = must(api("GET", "/products/P000", CATALOGUE))
assert got["name"] == "Product 000" and got["status"] == "draft" and got["cost_amount"] == "4.25" # drafts; the file never overwrites
status, body = api("POST", "/catalogue/import", SYSTEM, {"products": [{**file[0], "unit_price": "99.00"}], "price_list_id": "LIST-GBP"})
assert status == 409 and "never edited" in body["detail"] and counts() == snapshot # a changed price refuses the whole import
for who in (CATALOGUE, PRICING, person_with("*:*:*")): # a machine permission: exact grant only
assert api("POST", "/catalogue/import", who, {"products": file[:1], "price_list_id": "LIST-GBP"})[0] == 403
def test_invariant_the_import_never_publishes_and_cannot_see_cost():
"""The import adds draft products and draft price rows and nothing else; the one who holds it sees no cost, in any
answer — it is never projected in for product:catalogue:import."""
mk_product("HOST-M", cost_amount="61.50")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
out = must(api("POST", "/catalogue/import", SYSTEM, {"price_list_id": "LIST-GBP", "products": [
{"product_id": "NEW-1", "product_type_code": "SERVICE", "name": "New one", "cost_amount": "7.00", "unit_price": "10.00", "valid_from": "2026-12-01"}]}))
assert out["products_created"] == 1 and out["prices_created"] == 1
assert "cost" not in json.dumps(out)
assert "cost_amount" not in must(api("GET", "/products/HOST-M", SYSTEM)) and "cost_amount" not in must(api("GET", "/products/NEW-1", SYSTEM))
assert must(api("GET", "/products/NEW-1", CATALOGUE))["status"] == "draft" # never activated
pending = must(api("GET", "/price-lists/LIST-GBP/rows", PRICING, {"status": "draft"}))["rows"]
assert [(r["product_id"], r["unit_price"]) for r in pending] == [("NEW-1", "10.00")] # a DRAFT row on a published list
assert must(price("HOST-M", priced_on="2026-12-05"))["unit_price"] == "120.00" # nothing live changed
assert api("GET", "/products/NEW-1", SALES)[0] == 404
# ── the module's invariants ───────────────────────────────────────────────────────────────────
def test_invariant_a_price_row_is_never_updated_so_the_price_on_a_past_date_is_reproducible():
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
held = must(price("HOST-M", priced_on="2026-11-15"))
status, _ = api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2026-11-01", "99.00")]})
assert status == 409 # a published list: not in force-dated rows
status, _ = api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2026-11-19", "99.00")]})
assert status == 409 # earlier than today: would re-price a past quote
must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2026-12-01", "99.00")]}))
must(api("POST", "/price-lists/LIST-GBP/publish-price-list", PRICING, {}))
assert must(price("HOST-M", priced_on="2026-11-15")) == held and must(price("HOST-M", priced_on="2026-12-01"))["unit_price"] == "99.00"
live = [(r["valid_from"], r["unit_price"]) for r in must(api("GET", "/price-lists/LIST-GBP/rows", PRICING, {"status": "live"}))["rows"]]
assert live == [("2026-11-01", "120.00"), ("2026-12-01", "99.00")]
# a draft row was never live: it may be thrown away. A live one may not.
must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2027-05-01", "12.00")]}))
assert api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"discard": [{"product_id": "HOST-M", "valid_from": "2026-11-01"}]})[0] == 409
fixed = must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"discard": [{"product_id": "HOST-M", "valid_from": "2027-05-01"}],
"rows": [row("HOST-M", "2027-05-01", "120.00")]}))
assert fixed["written"]["discarded"] == 1 and fixed["contents"]["rows_pending"] == 1
def test_invariant_a_price_is_never_resolved_across_currencies():
mk_product("HOST-M")
mk_list("LIST-EUR", currency="EUR", rows=[row("HOST-M", "2026-11-01", "140.00")], default_from=DEFAULT)
status, body = price("HOST-M") # asked in GBP
assert status == 404 and "never converted" in body["detail"]
assert api("GET", "/products/HOST-M/price", SALES, {"currency": "EUR"})[1]["unit_price"] == "140.00"
assert api("POST", "/price-lists", PRICING, {"price_list_id": "GBP-CHILD", "currency": "GBP", "based_on": "LIST-EUR"})[0] == 422
mk_list("LIST-GBP", rows=[], default_from=DEFAULT)
assert api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rules": [
{"seq": 1, "method": "discount_pct", "discount_pct": "5", "base_list": "LIST-EUR"}]})[0] in (409, 422)
def test_invariant_on_any_date_a_currency_has_one_default_the_latest_default_from():
"""tests.invariants: on any date a currency has at most one default list. The default on a date is the list with the
latest default_from on or before it; two lists never share a default_from."""
mk_product("HOST-M")
mk_list("OLD", rows=[row("HOST-M", "2026-11-01", "100.00")], default_from="2026-10-02")
mk_list("NEW", rows=[row("HOST-M", "2026-12-01", "110.00")], default_from="2026-12-01")
got = {d: must(price("HOST-M", priced_on=d))["price_list_id"] for d in ("2026-11-15", "2026-11-30", "2026-12-01", "2027-03-01")}
assert got == {"2026-11-15": "OLD", "2026-11-30": "OLD", "2026-12-01": "NEW", "2027-03-01": "NEW"}
# a third list with the SAME default_from as one of them is refused — no tie-break by id, no second default
mk_list("CLASH", rows=[row("HOST-M", "2026-12-05", "1.00")], default_from="2026-12-01", publish=False)
status, body = publish("CLASH")
assert status == 409 and "NEW is already the default GBP list from 2026-12-01" in body["detail"]
mk_list("BETWEEN", rows=[row("HOST-M", "2026-11-10", "95.00")], default_from="2026-11-10") # slots in between: still one answer a day
assert [must(price("HOST-M", priced_on=d))["price_list_id"] for d in ("2026-11-09", "2026-11-10", "2026-11-30", "2026-12-01")] == ["OLD", "BETWEEN", "BETWEEN", "NEW"]
# two currencies keep their own defaults
mk_list("EUR-OLD", currency="EUR", rows=[row("HOST-M", "2026-11-01", "130.00")], default_from="2026-10-02")
assert api("GET", "/products/HOST-M/price", SALES, {"currency": "EUR", "priced_on": "2026-12-01"})[1]["price_list_id"] == "EUR-OLD"
def test_invariant_a_second_person_setting_is_off_by_default_and_a_list_with_a_base_needs_it_published():
mk_product("HOST-M")
mk_list("BASE", rows=[row("HOST-M", "2026-11-01", "100.00")], publish=False)
mk_list("CHILD", based_on="BASE", rows=[row("HOST-M", "2026-11-01", "90.00")], publish=False)
status, body = publish("CHILD")
assert status == 409 and "BASE, which is not published" in body["detail"]
must(publish("BASE"))
assert must(publish("CHILD"))["status"] == "published"
assert must(api("GET", "/settings", PRICING))["settings"][0]["value"] is None
def test_plan_changes_is_a_pure_decision():
new = RowIn("A", date(2026, 12, 1), D("5"))
stored = {("A", "", date(2026, 12, 1)): PriceRow("L", "A", date(2026, 12, 1), D("5.0000"))}
assert plan_changes("L", [new], [], stored, {}, today=date(2026, 11, 20), published=True).same_rows == [new]
with pytest.raises(RuleBroken, match="never edited"):
plan_changes("L", [RowIn("A", date(2026, 12, 1), D("6"))], [], stored, {}, today=date(2026, 11, 20), published=False)
with pytest.raises(RuleBroken, match="two different prices"):
plan_changes("L", [new, RowIn("A", date(2026, 12, 1), D("7"))], [], {}, {}, today=date(2026, 11, 20), published=False)
with pytest.raises(RuleBroken, match="is not one"):
PriceList.open_price_list("L", currency="pounds")
# the second person and the person who may both keep the catalogue and the prices (an owner)
from conftest import as_role, person # noqa: E402
PRICING_2 = as_role("pricing_manager", "second.pricer@shop.example")
# ── the audit's fixes (erp-brick-product dd40b0c) ─────────────────────────────────────────────
def test_a_late_publish_never_back_dates_a_price():
"""Publishing must not change the answer for a date already past. A change scheduled for 12-10 and published on
12-20 would move the 12-10 price from 120.00 to 150.00 — it is refused, and the draft is discarded and re-dated."""
from datetime import datetime, timezone
from erp_platform import work
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {
"rows": [row("HOST-M", "2026-12-10", "150.00")],
"rules": [{"seq": 1, "applies_to": "all", "discount_pct": "10", "valid_from": "2026-12-10"}]}))
work.set_clock(at=datetime(2026, 12, 20, 9, 0, tzinfo=timezone.utc), freeze=True) # the publisher was late
status, body = api("POST", "/price-lists/LIST-GBP/publish-price-list", PRICING, {})
assert status == 409 and "already past" in body["detail"] and "HOST-M per MON from 2026-12-10" in body["detail"]
assert "rule 1 from 2026-12-10" in body["detail"]
assert must(price("HOST-M", priced_on="2026-12-10"))["unit_price"] == "120.00" # still 120.00
fixed = must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {
"discard": [{"product_id": "HOST-M", "valid_from": "2026-12-10"}], "discard_rules": [1],
"rows": [row("HOST-M", "2027-01-01", "150.00")]}))
assert fixed["written"]["discarded"] == 1 and fixed["written"]["rules_discarded"] == 1
assert fixed["contents"]["rules_pending"] == 0 and fixed["contents"]["rows_pending"] == 1
assert api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"discard_rules": [1]})[0] == 409 # no such draft rule
must(api("POST", "/price-lists/LIST-GBP/publish-price-list", PRICING, {}))
assert must(price("HOST-M", priced_on="2026-12-10"))["unit_price"] == "120.00"
assert must(price("HOST-M", priced_on="2027-01-01"))["unit_price"] == "150.00"
def test_invariant_nothing_published_or_assigned_changes_a_price_for_a_date_already_past():
"""tests.invariants (SC-PRD-04's rules, one by one): LIST-GBP is the GBP default since 2026-10-02 at 100.00; a NEW-GBP at
30% off becomes the default from TOMORROW; retiring LIST-GBP, a default_from dated today, a row dated today and an
assignment from yesterday are all refused; every past date still answers 100.00."""
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-10-02", "100.00")], default_from="2026-10-02")
held = {d: must(price("HOST-M", priced_on=d)) for d in ("2026-10-15", "2026-11-19", "2026-11-20")}
assert {d: a["unit_price"] for d, a in held.items()} == {"2026-10-15": "100.00", "2026-11-19": "100.00", "2026-11-20": "100.00"}
# a default dated today cannot be published; tomorrow can
mk_list("TODAY-GBP", based_on="LIST-GBP", default_from="2026-11-20", publish=False,
rules=[{"seq": 1, "discount_pct": "30", "valid_from": "2026-11-21", "base_list": "LIST-GBP"}])
status, body = api("POST", "/price-lists/TODAY-GBP/publish-price-list", PRICING, {})
assert status == 409 and "not after today" in body["detail"]
mk_list("NEW-GBP", based_on="LIST-GBP", default_from="2026-11-21", publish=False)
with on_day("2026-11-20T09:00:00+00:00"):
must(api("POST", "/price-lists/NEW-GBP/set-prices", PRICING, {"rules": [
{"seq": 1, "discount_pct": "30", "valid_from": "2026-11-21", "base_list": "LIST-GBP"}]}))
must(api("POST", "/price-lists/NEW-GBP/publish-price-list", PRICING, {}))
assert {d: must(price("HOST-M", priced_on=d)) for d in held} == held # the past is unchanged
assert must(price("HOST-M", priced_on="2026-11-21"))["unit_price"] == "70.00" # 70.00 from tomorrow
assert must(price("HOST-M", priced_on="2026-11-21"))["price_list_id"] == "NEW-GBP"
# LIST-GBP is the default until NEW-GBP starts tomorrow, today or later: it cannot be retired, nor can NEW-GBP's base
status, body = api("POST", "/price-lists/LIST-GBP/retire-price-list", PRICING, {})
assert status == 409 and "default GBP list" in body["detail"]
# a row starting today, a rule from yesterday, and an assignment from yesterday or today are all refused
assert api("POST", "/price-lists/NEW-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2026-11-20", "1.00")]})[0] == 409
for start in ("2026-11-19", "2026-11-20"):
status, body = api("POST", "/price-lists/LIST-GBP/assign-customer", PRICING, {"bp_id": "7311", "valid_from": start})
assert status == 409 and "tomorrow or later" in body["detail"]
assert api("GET", "/customer-price-lists/7311/GBP", PRICING)[0] == 404 # nothing was written
assert {d: must(price("HOST-M", priced_on=d, bp_id="7311")) for d in held} == {d: {**a, "bp_id": "7311"} for d, a in held.items()}
assert api("POST", "/price-lists/LIST-GBP/retire-price-list", PRICING, {})[0] == 409 # and NEW-GBP starts from it
def test_a_list_is_only_based_on_one_with_the_same_vat_basis():
mk_product("HOST-M")
mk_list("NET", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
status, body = api("POST", "/price-lists", PRICING, {"price_list_id": "GROSS", "currency": "GBP", "based_on": "NET",
"prices_include_tax": True})
assert status == 422 and "same VAT basis" in body["detail"] and "including VAT" in body["detail"]
mk_list("GROSS", prices_include_tax=True, publish=False)
status, body = api("POST", "/price-lists/GROSS/set-prices", PRICING, {"rules": [
{"seq": 1, "discount_pct": "10", "base_list": "NET"}]})
assert status == 422 and "same VAT basis" in body["detail"]
mk_list("NET2", based_on="NET", rows=[row("HOST-M", "2026-12-01", "100.00")]) # the same basis is fine
assert must(price("HOST-M", priced_on="2026-11-15"))["prices_include_tax"] is False
def test_the_x_99_rounding_never_goes_below_zero():
from brick_product.domain.pricing import round_price
assert round_price(D("0.30"), "x.99") == D("0.30") and round_price(D("0.49"), "x.99") == D("0.49")
assert round_price(D("0.50"), "x.99") == D("0.99") and round_price(D("0"), "x.99") == D("0.00")
assert round_price(D("10.30"), "x.99") == D("9.99") and round_price(D("10.60"), "x.99") == D("10.99")
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "0.40")], default_from=DEFAULT, rounding="x.99",
rules=[{"seq": 1, "applies_to": "all", "discount_pct": "50"}])
assert must(price("HOST-M"))["unit_price"] == "0.20" # not -0.01
def test_a_currency_is_a_real_iso_4217_code_and_rounding_follows_its_minor_unit():
for bad in ("XYZ", "ZZZ", "EUR2", "gbp"):
assert api("POST", "/price-lists", PRICING, {"price_list_id": "L", "currency": bad})[0] == 422
jpy = must(api("POST", "/price-lists", PRICING, {"price_list_id": "LIST-JPY", "currency": "JPY"}))
kwd = must(api("POST", "/price-lists", PRICING, {"price_list_id": "LIST-KWD", "currency": "KWD"}))
assert (jpy["rounding"], kwd["rounding"]) == ("1.00", "0.001")
assert api("POST", "/price-lists", PRICING, {"price_list_id": "J2", "currency": "JPY", "rounding": "0.01"})[0] == 422
assert api("POST", "/price-lists", PRICING, {"price_list_id": "J3", "currency": "JPY", "rounding": "x.99"})[0] == 422
mk_product("HOST-M")
mk_list("LIST-JPY2", currency="JPY", rows=[row("HOST-M", "2026-11-01", "1005")], default_from=DEFAULT,
rules=[{"seq": 1, "applies_to": "all", "discount_pct": "10.55"}])
got = api("GET", "/products/HOST-M/price", SALES, {"currency": "JPY"})[1]
assert got["unit_price"] == "899.00" # 899.0 yen, no fractions of a yen
def test_a_list_still_used_as_a_base_cannot_be_retired():
mk_product("HOST-M")
mk_list("BASE", rows=[row("HOST-M", "2026-11-01", "100.00")])
mk_list("CHILD", based_on="BASE", rows=[row("HOST-M", "2026-12-01", "90.00")])
mk_list("RULED", rules=[{"seq": 1, "discount_pct": "5", "base_list": "BASE"}])
status, body = api("POST", "/price-lists/BASE/retire-price-list", PRICING, {})
assert status == 409 and "CHILD, RULED" in body["detail"]
must(api("POST", "/price-lists/CHILD/retire-price-list", PRICING, {}))
status, body = api("POST", "/price-lists/BASE/retire-price-list", PRICING, {})
assert status == 409 and "RULED" in body["detail"] and "CHILD" not in body["detail"] # a retired list no longer counts
must(api("POST", "/price-lists/RULED/retire-price-list", PRICING, {}))
assert must(api("POST", "/price-lists/BASE/retire-price-list", PRICING, {}))["status"] == "retired"
def test_a_replaced_draft_rule_keeps_its_original_author():
mk_list("LIST-GBP", publish=False)
must(api("POST", "/price-lists/LIST-GBP/set-prices", PRICING, {"rules": [{"seq": 1, "discount_pct": "5"}]}))
someone = as_role("pricing_manager", "second.pricer@shop.example")
must(api("POST", "/price-lists/LIST-GBP/set-prices", someone, {"rules": [{"seq": 1, "discount_pct": "7"}]}))
with use_cases.repository() as repo:
_, _, authors = repo.prices.pending("LIST-GBP")
rule, = repo.prices.rules_of("LIST-GBP")
assert rule.discount_pct == D("7") and authors == {"pricing_manager@shop.example"}
def test_day_one_a_currencys_first_publish_may_start_today_never_earlier_and_never_again():
"""Gate: every row and rule after today — or today while the currency has no published price yet."""
mk_product("HOST-M")
mk_list("EARLY", rows=[row("HOST-M", "2026-11-19", "5.00")], publish=False)
status, body = api("POST", "/price-lists/EARLY/publish-price-list", PRICING, {})
assert status == 409 and "HOST-M per MON from 2026-11-19" in body["detail"] # yesterday: never
mk_list("FIRST", rows=[row("HOST-M", "2026-11-20", "5.00")], publish=False) # starts today
must(api("POST", "/price-lists/FIRST/set-prices", PRICING, {"rules": [{"seq": 1, "discount_pct": "5", "valid_from": "2026-11-20"}]}))
status, body = api("POST", "/price-lists/FIRST/publish-price-list", PRICING, {})
assert status == 200 and body["status"] == "published" # day one: today is allowed
mk_list("SECOND", rows=[row("HOST-M", "2026-11-20", "6.00")], publish=False)
status, body = api("POST", "/price-lists/SECOND/publish-price-list", PRICING, {})
assert status == 409 and "already past" in body["detail"] # something is published now
mk_list("OTHER-CCY", currency="EUR", rows=[row("HOST-M", "2026-11-20", "7.00")], publish=False)
assert api("POST", "/price-lists/OTHER-CCY/publish-price-list", PRICING, {})[0] == 200 # EUR has no price yet
mk_list("UNDATED", currency="SEK", rules=[], publish=False)
must(api("POST", "/price-lists/UNDATED/set-prices", PRICING, {"rules": [{"seq": 1, "discount_pct": "5"}]}))
status, body = api("POST", "/price-lists/UNDATED/publish-price-list", PRICING, {})
assert status == 409 and "rule 1 from always" in body["detail"] # an undated rule reaches the past
def test_a_retired_list_still_answers_the_past_and_its_customers_fall_back_after_the_day_it_went():
mk_product("HOST-M")
mk_list("OLD", rows=[row("HOST-M", "2026-11-01", "100.00")], default_from="2026-10-02")
mk_list("NEW", rows=[row("HOST-M", "2026-12-01", "80.00")], default_from="2026-12-01")
mk_list("PARTNER", rows=[row("HOST-M", "2026-11-01", "70.00")], publish=True)
must(api("POST", "/price-lists/PARTNER/assign-customer", PRICING, {"bp_id": "7311", "valid_from": "2026-11-21"}))
before = {d: must(price("HOST-M", priced_on=d, bp_id="7311")) for d in ("2026-11-10", "2026-11-25", "2026-12-05")}
assert [a["price_list_id"] for a in before.values()] == ["OLD", "PARTNER", "PARTNER"]
status, body = api("POST", "/price-lists/OLD/retire-price-list", PRICING, {})
assert status == 409 and "default GBP list" in body["detail"] # NEW starts 2026-12-01, after today
with on_day("2026-12-02T09:00:00+00:00"):
assert must(api("POST", "/price-lists/OLD/retire-price-list", PRICING, {}))["retired_on"] == "2026-12-02"
assert must(api("POST", "/price-lists/PARTNER/retire-price-list", PRICING, {}))["status"] == "retired"
assert must(price("HOST-M", priced_on="2026-11-10", bp_id="7311")) == before["2026-11-10"] # no 404 for the past
assert must(price("HOST-M", priced_on="2026-11-25", bp_id="7311")) == before["2026-11-25"]
assert must(price("HOST-M", priced_on="2026-12-02", bp_id="7311"))["price_list_id"] == "PARTNER" # the day it went
after = must(price("HOST-M", priced_on="2026-12-03", bp_id="7311"))
assert (after["price_list_id"], after["unit_price"]) == ("NEW", "80.00") # they fall back after it
assert api("POST", "/price-lists/PARTNER/assign-customer", PRICING, {"bp_id": "7311"})[0] == 409 # retired: not assignable
def test_the_controls_on_pricing_authors_are_set_only_with_the_controls_permission():
"""tests.access: only product:settings:controls changes publish_needs_second_person or unusual_price_pct;
product:settings:write is refused on them. The owner, who holds controls, cannot write settings or prices."""
for key, value in (("publish_needs_second_person", True), ("unusual_price_pct", 10), ("business_timezone", "Europe/Paris")):
for who in (PRICING, CATALOGUE): # settings:write is not enough
status, body = api("POST", f"/settings/{key}", who, {"value": value})
assert status == 403 and "product:settings:controls" in body["detail"]
assert api("POST", f"/pricing-controls/{key}", who, {"value": value})[0] == 403
assert must(api("POST", f"/pricing-controls/{key}", OWNER, {"value": value}))["value"] == value
assert api("POST", f"/settings/{key}", OWNER, {"value": value})[0] == 403 # nor the other way round
assert {s["setting_key"]: s["value"] for s in must(api("GET", "/settings", SALES))["settings"]}["unusual_price_pct"] == 10
must(api("POST", "/pricing-controls/business_timezone", OWNER, {"value": None}))
assert api("POST", "/pricing-controls/product_code_format", OWNER, {"value": "typed"})[0] == 422 # only the two controls
assert api("POST", "/settings/product_code_format", PRICING, {"value": "typed"})[0] == 200 # the rest stays settings:write
assert api("POST", "/price-lists", OWNER, {"price_list_id": "X", "currency": "GBP"})[0] == 403
assert must(api("GET", "/price-lists", OWNER)) == {"price_lists": []} # sees every price: none yet
def person_with(*permissions, email="someone@shop.example"):
return person(email, permissions)
def test_invariant_moving_a_product_or_a_category_changes_no_price_for_today_or_earlier():
"""tests.invariants: moving a product or a category changes no price for today or earlier. A category re-parented
under services starts tomorrow, and so does the 20% off services it brings."""
categories(("hosting", None), ("services", None), ("misc", None))
mk_product("HOST-M", category_code="hosting")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT,
rules=[{"seq": 1, "applies_to": "category", "target": "services", "discount_pct": "20"}])
held = {d: must(price("HOST-M", priced_on=d)) for d in ("2026-11-01", "2026-11-19", "2026-11-20")}
must(api("POST", "/product-categories/hosting", CATALOGUE, {"parent_code": "services"})) # re-parent: from tomorrow
assert {d: must(price("HOST-M", priced_on=d)) for d in held} == held
assert must(price("HOST-M", priced_on="2026-11-21"))["unit_price"] == "96.00"
assert [c["parent_code"] for c in must(api("GET", "/product-categories", SALES))["product_categories"] if c["category_code"] == "hosting"] == ["services"]
status, body = api("POST", "/product-categories/hosting", CATALOGUE, {"parent_code": "misc"})
assert status == 409 and "already moving" in body["detail"] # never edited the same day
assert api("POST", "/product-categories/services", CATALOGUE, {"parent_code": "hosting"})[0] == 409 # no loops, dated or not
must(api("POST", "/product-categories/hosting", CATALOGUE, {"parent_code": "services"})) # the same again: nothing
with on_day("2026-11-22T09:00:00+00:00"): # then take it out again
must(api("POST", "/product-categories/hosting", CATALOGUE, {"parent_code": "-"}))
assert must(price("HOST-M", priced_on="2026-11-22"))["unit_price"] == "96.00" # still under services today
assert must(price("HOST-M", priced_on="2026-11-23"))["unit_price"] == "120.00"
assert {d: must(price("HOST-M", priced_on=d)) for d in held} == held
assert must(price("HOST-M", priced_on="2026-11-25"))["unit_price"] == "120.00" and must(price("HOST-M", priced_on="2026-11-21"))["unit_price"] == "96.00"
def test_invariant_today_is_the_date_in_the_business_timezone():
"""tests.invariants: 'today' is the date in business_timezone. At 23:30 UTC on 11-20 it is already 11-21 in Auckland:
an assignment from 11-21 is 'today' there (refused), in London (the default) it is tomorrow."""
mk_product("HOST-M")
mk_list("LIST-GBP", rows=[row("HOST-M", "2026-11-01", "120.00")], default_from=DEFAULT)
late = "2026-11-20T23:30:00+00:00"
with on_day(late):
assert must(api("POST", "/price-lists/LIST-GBP/assign-customer", PRICING, {"bp_id": "1", "valid_from": "2026-11-21"}))["valid_from"] == "2026-11-21"
assert must(api("POST", "/price-lists/LIST-GBP/assign-customer", PRICING, {"bp_id": "2"}))["valid_from"] == "2026-11-21" # default: tomorrow
assert api("POST", "/pricing-controls/business_timezone", OWNER, {"value": "Mars/Phobos"})[0] == 422
assert api("POST", "/pricing-controls/business_timezone", OWNER, {"value": 7})[0] == 422
must(api("POST", "/pricing-controls/business_timezone", OWNER, {"value": "Pacific/Auckland"}))
with on_day(late):
status, body = api("POST", "/price-lists/LIST-GBP/assign-customer", PRICING, {"bp_id": "3", "valid_from": "2026-11-21"})
assert status == 409 and "tomorrow or later" in body["detail"] and "2026-11-21" in body["detail"]
assert must(api("POST", "/price-lists/LIST-GBP/assign-customer", PRICING, {"bp_id": "3"}))["valid_from"] == "2026-11-22"
assert api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2026-11-21", "1.00")]})[0] == 409
must(api("POST", "/pricing-controls/business_timezone", OWNER, {"value": None})) # back to Europe/London
with on_day(late):
assert must(api("POST", "/price-lists/LIST-GBP/change-prices", PRICING, {"rows": [row("HOST-M", "2026-11-21", "1.00")]}))["written"]["rows"] == 1
def test_invariant_only_the_first_default_a_currency_ever_has_may_start_today():
"""Gate: default_from after today, or today when the currency has never had a default list. A draft's default_from stays
editable until it is published."""
mk_product("HOST-M")
mk_list("FIRST", currency="NOK", rows=[row("HOST-M", "2026-11-21", "5.00")], default_from="2026-11-19", publish=False)
status, body = api("POST", "/price-lists/FIRST/publish-price-list", PRICING, {})
assert status == 409 and "not after today" in body["detail"] and "only the first default" not in body["detail"]
must(api("POST", "/price-lists/FIRST/set-prices", PRICING, {"default_from": "2026-11-20"})) # editable: today
assert must(api("GET", "/price-lists/FIRST", PRICING))["default_from"] == "2026-11-20"
assert must(api("POST", "/price-lists/FIRST/publish-price-list", PRICING, {}))["status"] == "published" # the first may start today
assert api("POST", "/price-lists/FIRST/set-prices", PRICING, {})[0] == 409 # published: no longer a draft's to edit
mk_list("SECOND", currency="NOK", rows=[row("HOST-M", "2026-11-22", "6.00")], default_from="2026-11-20", publish=False)
status, body = api("POST", "/price-lists/SECOND/publish-price-list", PRICING, {})
assert status == 409 and "only the first default a currency has may start today" in body["detail"]
must(api("POST", "/price-lists/SECOND/set-prices", PRICING, {"default_from": "2026-11-21"}))
assert must(api("POST", "/price-lists/SECOND/publish-price-list", PRICING, {}))["status"] == "published"
mk_list("THIRD", currency="NOK", default_from="2026-11-20", publish=False)
must(api("POST", "/price-lists/THIRD/set-prices", PRICING, {"clear_default_from": True}))
assert must(api("GET", "/price-lists/THIRD", PRICING))["default_from"] is None