dodil/erp-brick-product
tests/test_pricing.py
"""Price resolution with no database (domain/pricing.py): rules in order, quantity breaks, date windows, categories,
the list a list is based on, rounding — and that the same question always gets the same answer."""
from datetime import date
from decimal import Decimal as D
from fractions import Fraction
import pytest
from brick_product.domain import pricing
from brick_product.domain.pricing import ListHead, PriceRow, PriceRule, Subject
class Book:
def __init__(self):
self.heads, self._rows, self._rules = {}, [], []
def list(self, lid, currency="GBP", based_on="", rounding="0.01", status="published", gross=False, retired_on=None):
self.heads[lid] = ListHead(lid, currency, based_on, rounding, status, gross, retired_on and date.fromisoformat(retired_on))
return self
def row(self, lid, pid, frm, price, to=None, unit="MON"):
self._rows.append(PriceRow(lid, pid, date.fromisoformat(frm), D(price), to and date.fromisoformat(to), unit))
return self
def rule(self, lid, seq, **kw):
for k in ("valid_from", "valid_to"):
if kw.get(k):
kw[k] = date.fromisoformat(kw[k])
for k in ("min_quantity", "discount_pct", "fixed_price", "surcharge_amount"):
if k in kw and kw[k] is not None:
kw[k] = D(kw[k])
kw.setdefault("unit_code", "MON")
self._rules.append(PriceRule(lid, seq, **kw))
return self
def head(self, lid):
return self.heads.get(lid)
def rows(self, lid, pid):
return [r for r in self._rows if r.price_list_id == lid and r.product_id == pid]
def rules(self, lid):
return [r for r in self._rules if r.price_list_id == lid]
HOST = Subject("HOST-M", ("hosting", "services"), unit_code="MON", base_unit_code="MON", factors={"MON": Fraction(1)})
ON = date(2026, 12, 1)
def ask(book, lids, qty=1, on=ON, subject=HOST, currency="GBP", assigned=""):
return pricing.resolve(book, lids, subject, Fraction(D(qty)), on, currency, assigned)
def partner_book():
b = Book().list("LIST-GBP").list("PARTNER-GBP")
b.row("LIST-GBP", "HOST-M", "2026-11-01", "120.00")
b.rule("PARTNER-GBP", 1, applies_to="category", target="hosting", min_quantity=10, method="discount_pct",
discount_pct=15, base_list="LIST-GBP")
b.rule("PARTNER-GBP", 2, applies_to="category", target="hosting", method="discount_pct", discount_pct=10,
base_list="LIST-GBP")
return b
def test_a_quantity_break_before_the_general_discount():
b = partner_book()
five, twelve = ask(b, ["PARTNER-GBP"], 5).resolved, ask(b, ["PARTNER-GBP"], 12).resolved
assert (five.unit_price, five.rule.seq, five.price_list_id) == (D("108.00"), 2, "PARTNER-GBP")
assert (twelve.unit_price, twelve.rule.seq) == (D("102.00"), 1)
assert twelve.base.price_list_id == "LIST-GBP" and twelve.base.row.valid_from == date(2026, 11, 1)
def test_the_first_rule_that_matches_wins_so_order_is_the_lists_to_state():
b = Book().list("LIST-GBP").list("P")
b.row("LIST-GBP", "HOST-M", "2026-11-01", "120.00")
b.rule("P", 1, applies_to="all", method="discount_pct", discount_pct=10, base_list="LIST-GBP")
b.rule("P", 2, applies_to="all", min_quantity=10, method="discount_pct", discount_pct=15, base_list="LIST-GBP")
assert ask(b, ["P"], 12).resolved.unit_price == D("108.00") # the general 10% came first and matched
def test_a_customer_with_no_list_gets_the_default():
got = ask(partner_book(), ["LIST-GBP"], 12).resolved
assert (got.unit_price, got.via, got.price_list_id) == (D("120.00"), "row", "LIST-GBP")
def test_dated_rows_the_latest_in_force_wins_and_a_window_can_close():
b = Book().list("L").row("L", "HOST-M", "2026-11-01", "120.00").row("L", "HOST-M", "2027-01-01", "126.00")
assert ask(b, ["L"], on=date(2026, 12, 31)).resolved.unit_price == D("120.00")
assert ask(b, ["L"], on=date(2027, 1, 1)).resolved.unit_price == D("126.00")
assert ask(b, ["L"], on=date(2026, 10, 31)).resolved is None # before anything started
b2 = Book().list("L").row("L", "HOST-M", "2026-11-01", "120.00", to="2026-11-30")
assert ask(b2, ["L"], on=date(2026, 12, 1)).resolved is None # the window closed
def test_a_quantity_break_is_a_rule_never_a_row():
b = Book().list("L").row("L", "HOST-M", "2026-11-01", "100.00")
b.rule("L", 1, applies_to="all", min_quantity=10, method="fixed", fixed_price="90")
assert ask(b, ["L"], 9).resolved.unit_price == D("100.00") and ask(b, ["L"], 10).resolved.unit_price == D("90.00")
def test_a_retired_list_still_answers_the_dates_up_to_the_day_it_was_retired_and_none_after():
b = Book().list("OLD", status="retired", retired_on="2026-11-20").row("OLD", "HOST-M", "2026-01-01", "100.00")
b.list("NEW").row("NEW", "HOST-M", "2026-01-01", "80.00")
assert ask(b, ["OLD"], on=date(2026, 11, 20)).resolved.unit_price == D("100.00") # no 404 for a past date
assert ask(b, ["OLD"], on=date(2026, 11, 21)).resolved is None
assert ask(b, ["OLD", "NEW"], on=date(2026, 11, 21), assigned="OLD").resolved.price_list_id == "NEW"
def test_a_gross_list_never_falls_back_to_a_net_one_nor_the_reverse():
b = Book().list("GROSS", gross=True).list("NET").row("NET", "HOST-M", "2026-01-01", "100.00")
assert ask(b, ["GROSS", "NET"], assigned="GROSS").resolved is None # 100.00 net would be answered as gross
b.list("GROSS2", gross=True).row("GROSS2", "HOST-M", "2026-01-01", "120.00")
assert ask(b, ["GROSS", "GROSS2", "NET"], assigned="GROSS").resolved.price_list_id == "GROSS2"
assert ask(b, ["NET", "GROSS2"]).resolved.price_list_id == "NET"
def test_a_date_window_on_a_rule():
b = Book().list("L").row("L", "HOST-M", "2026-01-01", "100.00")
b.rule("L", 1, applies_to="all", method="discount_pct", discount_pct=20, valid_from="2026-12-01", valid_to="2026-12-31")
assert ask(b, ["L"], on=date(2026, 12, 15)).resolved.unit_price == D("80.00")
assert ask(b, ["L"], on=date(2027, 1, 1)).resolved.unit_price == D("100.00")
assert ask(b, ["L"], on=date(2026, 11, 30)).resolved.unit_price == D("100.00")
def test_scope_one_product_a_category_and_everything_under_it():
b = Book().list("L").row("L", "HOST-M", "2026-01-01", "100.00").row("L", "OTHER", "2026-01-01", "100.00")
b.rule("L", 1, applies_to="product", target="OTHER", method="fixed", fixed_price=D("7"))
b.rule("L", 2, applies_to="category", target="services", method="discount_pct", discount_pct=50)
assert ask(b, ["L"]).resolved.unit_price == D("50.00") # hosting sits under services
assert ask(b, ["L"], subject=Subject("OTHER", ("goods",), unit_code="MON", base_unit_code="MON", factors={"MON": Fraction(1)})).resolved.unit_price == D("7.00")
assert ask(b, ["L"], subject=Subject("PLAIN", ("goods",), unit_code="MON", base_unit_code="MON", factors={"MON": Fraction(1)})).resolved is None
def test_a_formula_rule_adds_a_surcharge_per_unit():
b = Book().list("L").row("L", "HOST-M", "2026-01-01", "100.00")
b.rule("L", 1, applies_to="all", method="formula", discount_pct=10, surcharge_amount="2.5")
assert ask(b, ["L"]).resolved.unit_price == D("92.50")
def test_a_list_based_on_another_inherits_what_it_does_not_price_itself():
b = Book().list("BASE").list("CHILD", based_on="BASE")
b.row("BASE", "HOST-M", "2026-01-01", "100.00").row("CHILD", "OTHER", "2026-01-01", "5.00")
got = ask(b, ["CHILD"]).resolved
assert (got.unit_price, got.via, got.price_list_id, got.base.price_list_id) == (D("100.00"), "based_on", "CHILD", "BASE")
assert ask(b, ["CHILD"], subject=Subject("OTHER", unit_code="MON", base_unit_code="MON", factors={"MON": Fraction(1)})).resolved.via == "row"
b.rule("CHILD", 1, applies_to="all", method="discount_pct", discount_pct=10) # base_list empty: starts from its own prices
assert ask(b, ["CHILD"]).resolved.unit_price == D("90.00")
def test_lists_based_on_each_other_end_in_no_price_not_a_loop():
b = Book().list("A", based_on="B").list("B", based_on="A")
assert ask(b, ["A"]).resolved is None
def test_a_draft_or_retired_list_prices_nothing_and_a_price_is_never_converted():
b = Book().list("DRAFT", status="draft").row("DRAFT", "HOST-M", "2026-01-01", "1.00")
b.list("EUR-LIST", currency="EUR").row("EUR-LIST", "HOST-M", "2026-01-01", "9.00")
assert ask(b, ["DRAFT"]).resolved is None
got = ask(b, ["EUR-LIST"], currency="GBP")
assert got.resolved is None # a EUR list is no answer to a GBP question
def test_the_assigned_list_first_then_the_default_and_it_says_so():
b = partner_book().list("OTHER-GBP")
got = ask(b, ["OTHER-GBP", "LIST-GBP"], assigned="OTHER-GBP") # OTHER-GBP has no price for it
assert got.resolved.price_list_id == "LIST-GBP" and got.fell_back and got.tried == ["OTHER-GBP", "LIST-GBP"]
again = ask(b, ["PARTNER-GBP", "LIST-GBP"], 12, assigned="PARTNER-GBP")
assert again.resolved.unit_price == D("102.00") and not again.fell_back
@pytest.mark.parametrize("rounding,value,expected", [
("0.01", "10.005", "10.01"), ("0.05", "10.02", "10.00"), ("0.05", "10.03", "10.05"), ("0.10", "10.04", "10.00"),
("0.50", "10.30", "10.50"), ("1.00", "10.50", "11.00"), ("x.99", "10.30", "9.99"), ("x.99", "10.60", "10.99"),
("none", "10.00005", "10.0001")])
def test_rounding_a_computed_price(rounding, value, expected):
assert pricing.round_price(D(value), rounding) == D(expected)
def test_a_rate_shows_two_places_unless_it_needs_four():
assert str(pricing.rate(D("102"))) == "102.00" and str(pricing.rate(D("12.3456"))) == "12.3456"
assert str(pricing.rate(D("120.0000"))) == "120.00" and str(pricing.rate(D("0.10"))) == "0.10"
def test_the_same_question_is_the_same_answer():
b = partner_book()
first = ask(b, ["PARTNER-GBP", "LIST-GBP"], 12)
assert all(ask(b, ["PARTNER-GBP", "LIST-GBP"], 12) == first for _ in range(3))
def test_unusual_means_far_from_what_it_is_compared_with():
assert pricing.is_unusual(D("12"), D("120"), 50) and pricing.is_unusual(D("300"), D("100"), 50)
assert not pricing.is_unusual(D("100"), D("120"), 50) and not pricing.is_unusual(D("1"), None, 50)
assert pricing.deviation_pct(D("12"), D("120")) == D("-90.00")