dodil/erp-brick-quote

tests/test_rules.py

"""The quote rules, with no database. Named for the CRM spec's stories (modules/crm/module.yaml) where
one applies, and for the invariant otherwise — each one a sentence a sales director would agree to."""
from decimal import Decimal

import pytest

from erp_platform.money import Money
from brick_quote.domain.quote import (ACCEPTED, DECLINED, DRAFT, EXPIRED, SENT, SUPERSEDED,
                                      DiscountPolicy, Line, NeedsApproval, PricedAt, Quote,
                                      QuoteIncomplete, QuoteRuleBroken)

POLICY = DiscountPolicy(auto_approve_pct=Decimal("15"), max_pct=Decimal("30"))
AT = PricedAt("standard", "2026-10-01")
NOW = "2026-10-16T09:00:00+00:00"


def line(product="AD-1", qty=1, list_price="1000", unit_price=None, months=()):
    return Line(0, product, qty, Money.of(list_price, "EUR"),
                Money.of(unit_price if unit_price is not None else list_price, "EUR"), AT,
                revenue_months=months)


def quote(owner="rep@co.example") -> Quote:
    return Quote.open("Q-1", 4242, "EUR", owner, valid_until="2026-12-31")


# ── CRM-05: totals are computed from the price book, never typed ───────────────────────────

def test_crm05_totals_are_computed_with_a_discount_on_one_line():
    q = quote()
    q.add_line(1, line("A", 2, "100"), POLICY)
    q.add_line(1, line("B", 1, "200", unit_price="190"), POLICY)    # 5% off one line
    q.add_line(1, line("C", 3, "50"), POLICY)
    v = q.version(1)
    assert v.totals["EUR"] == Money.of("540", "EUR")
    assert v.list_totals["EUR"] == Money.of("550", "EUR")
    assert v.net_discount_pct() == Decimal("1.82") and v.status == DRAFT
    assert v.discount_pct() == Decimal("5.00")              # H2: the policy reads the deepest line too


# ── CRM-06: above the threshold needs approval; above the maximum is refused ───────────────

def test_crm06_eighteen_percent_against_fifteen_needs_approval():
    q = quote()
    q.add_line(1, line(unit_price="820"), POLICY)
    assert q.needs_approval(POLICY)
    with pytest.raises(NeedsApproval) as refused:
        q.send(1, POLICY, NOW)
    assert refused.value.discount_pct == Decimal("18.00")
    assert refused.value.threshold_pct == Decimal("15.00")


def test_crm06_the_rep_cannot_approve_their_own_quote():
    q = quote()
    q.add_line(1, line(unit_price="820"), POLICY)
    with pytest.raises(QuoteRuleBroken, match="cannot approve"):
        q.approve_discount(1, "rep@co.example", POLICY)


def test_crm06_approved_then_sent():
    q = quote()
    q.add_line(1, line(unit_price="820"), POLICY)
    q.approve_discount(1, "director@co.example", POLICY)
    assert not q.needs_approval(POLICY)
    assert q.send(1, POLICY, NOW).status == SENT


def test_crm06_above_the_maximum_is_refused_at_the_line_not_at_the_end():
    q = quote()
    with pytest.raises(QuoteRuleBroken, match="maximum"):
        q.add_line(1, line(unit_price="600"), POLICY)               # 40% off
    assert q.version(1).lines == []


def test_an_approval_covers_only_the_figure_it_was_given_for():
    q = quote()
    q.add_line(1, line(unit_price="820"), POLICY)
    q.approve_discount(1, "director@co.example", POLICY)            # 18%
    q.edit_line(1, 1, POLICY, unit_price="750")                     # now 25%
    assert q.version(1).approval is None                            # withdrawn, not left lying
    with pytest.raises(NeedsApproval):
        q.send(1, POLICY, NOW)


def test_an_approval_nobody_needed_is_refused():
    q = quote()
    q.add_line(1, line(unit_price="950"), POLICY)                   # 5%
    with pytest.raises(QuoteRuleBroken, match="needs no approval"):
        q.approve_discount(1, "director@co.example", POLICY)


def test_h2_the_deepest_line_decides_even_when_the_net_looks_fine():
    """A 36% line is past the 30% maximum, though the quote's net is 18% (the old rule read the net only)."""
    q = quote()
    q.add_line(1, line("A", 1, "1000", unit_price="1000"), POLICY)
    with pytest.raises(QuoteRuleBroken, match="line 2 .*36.00%.*30.00% maximum"):
        q.add_line(1, line("B", 1, "1000", unit_price="640"), POLICY)
    q.add_line(1, line("B", 1, "1000", unit_price="820"), POLICY)   # 18% on this line, 9% net
    v = q.version(1)
    assert (v.net_discount_pct(), v.deepest_line_discount_pct(), v.discount_pct()) == \
        (Decimal("9.00"), Decimal("18.00"), Decimal("18.00"))
    assert q.needs_approval(POLICY)                               # the 18% line needs the desk


def test_h2_a_price_above_list_never_offsets_another_lines_discount():
    """The audit's quote: one line at +80%, one at 40% off — net 20%, so it sent. Now the 40% line is refused."""
    q = quote()
    q.add_line(1, line("A", 1, "1000", unit_price="1800"), POLICY)      # above list: allowed
    with pytest.raises(QuoteRuleBroken, match="maximum"):
        q.add_line(1, line("B", 1, "1000", unit_price="600"), POLICY)
    q.add_line(1, line("B", 1, "1000", unit_price="900"), POLICY)       # 10% off
    v = q.version(1)
    assert v.net_discount_pct() == Decimal("5.00")                       # 100 off 2000 listed — not -35%
    assert v.discount_pct() == Decimal("10.00")


def test_h2_the_discount_is_never_negative():
    q = quote()
    q.add_line(1, line("A", 1, "1000", unit_price="1250"), POLICY)
    v = q.version(1)
    assert v.discount_pct() == Decimal("0.00") and str(v.discount_pct()) == "0.00"
    assert str(v.net_discount_pct()) == "0.00" and str(v.lines[0].discount_pct) == "0.00"
    assert str(Quote.open("Q-2", 1, "EUR", "a@b").version(1).discount_pct()) == "0.00"


# ── CRM-07: a sent quote is locked; a change is a revision ─────────────────────────────────

def test_crm07_a_sent_quote_refuses_edits_and_a_revision_is_a_new_version():
    q = quote()
    q.add_line(1, line(), POLICY)
    q.send(1, POLICY, NOW)
    with pytest.raises(QuoteRuleBroken, match="Revise"):
        q.edit_line(1, 1, POLICY, quantity=2)
    v2 = q.revise()
    assert (v2.version, v2.status, len(v2.lines)) == (2, DRAFT, 1)
    assert q.version(1).status == SUPERSEDED


def test_an_accepted_quote_cannot_be_revised():
    q = quote()
    q.add_line(1, line(), POLICY)
    q.send(1, POLICY, NOW)
    q.decide(1, ACCEPTED, today="2026-10-20")
    with pytest.raises(QuoteRuleBroken, match="new quote"):
        q.revise()


# ── the customer's answer ──────────────────────────────────────────────────────────────────

def test_accepting_supersedes_every_other_open_version():
    q = quote()
    q.add_line(1, line(), POLICY)
    q.send(1, POLICY, NOW)
    q.revise()
    q.send(2, POLICY, NOW)
    q.decide(2, ACCEPTED, today="2026-10-20")
    assert [v.status for v in q.versions] == [SUPERSEDED, ACCEPTED]
    assert q.accepted().version == 2


def test_deciding_twice_the_same_way_changes_nothing():
    q = quote()
    q.add_line(1, line(), POLICY)
    q.send(1, POLICY, NOW)
    q.decide(1, DECLINED)
    assert q.decide(1, DECLINED).status == DECLINED
    with pytest.raises(QuoteRuleBroken):
        q.decide(1, ACCEPTED)


def test_an_expired_quote_cannot_be_accepted():
    q = quote()
    q.add_line(1, line(), POLICY)
    q.send(1, POLICY, NOW)
    assert q.version(1).state(today="2027-01-01") == EXPIRED
    with pytest.raises(QuoteRuleBroken, match="expired"):
        q.decide(1, ACCEPTED, today="2027-01-01")


def test_an_empty_quote_cannot_be_sent():
    with pytest.raises(QuoteIncomplete):
        quote().send(1, POLICY, NOW)


# ── money ──────────────────────────────────────────────────────────────────────────────────

def test_a_line_in_another_currency_is_refused():
    other = Line(0, "A", 1, Money.of(1, "SEK"), Money.of(1, "SEK"), AT)
    with pytest.raises(QuoteRuleBroken, match="converted"):
        quote().add_line(1, other, POLICY)


def test_the_revenue_schedule_sums_back_to_the_line():
    ln = line(qty=1, list_price="100000", months=("2026-12", "2026-10", "2026-11"))
    assert ln.revenue_months == ("2026-10", "2026-11", "2026-12")
    assert sum((m.amount for m in ln.schedule().values()), Decimal(0)) == Decimal("100000.00")
    assert ln.schedule()["2026-10"].amount == Decimal("33333.34")


def test_a_month_must_be_a_month():
    with pytest.raises(QuoteRuleBroken, match="not a month"):
        line(months=("2026-13",))


def test_the_policy_must_make_sense():
    with pytest.raises(QuoteRuleBroken):
        DiscountPolicy(auto_approve_pct=Decimal("40"), max_pct=Decimal("30"))