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