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