dodil/erp-brick-pos
DODIL ERP brick: point of sale
tests/test_domain.py
"""The rules with no database and no HTTP: same-person matching, the business date, price and VAT, the cash arithmetic, the
state machines."""
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from brick_pos.domain import money, pricing
from brick_pos.domain.businessday import business_date
from brick_pos.domain.errors import OutOfBounds, RuleBroken
from brick_pos.domain.receipt import Receipt, ReceiptLine
from brick_pos.domain.session import Session, expected_cash, same_person
D = Decimal
AT = datetime(2026, 11, 14, 9, 0)
def a_session(**kw) -> Session:
s = Session.open_session(shop_id="LON-SOHO", register_id="R1", session_no=1, business_date=date(2026, 11, 14), currency="GBP",
opening_float=D("150.00"), previous_closing_float=None, at=AT, by="a@shop", sub="sub-a")
for k, v in kw.items():
setattr(s, k, v)
return s
def counted(diff="-6.50", **kw) -> Session:
s = a_session()
s.count_session(counted=D("150.00") + D(diff), expected=D("150.00"), threshold=D("5.00"), closing_float=None, register_last=None,
server_last=0, at=AT, by="a@shop", sub="sub-a")
return s
@pytest.mark.parametrize("a,b,same", [
(("a@shop", "s1"), ("a@shop", "s1"), True),
(("a@shop", "s1"), ("A@Shop ", "s2"), True), # the same address (case and spaces do not make a second person)
(("a@shop", "s1"), ("b@shop", "s1"), True), # a second login keeps the sub
(("a@shop", "s1"), ("b@shop", "s2"), False),
(("a@shop", ""), ("b@shop", ""), False), # a blank is no evidence
(("", "s1"), ("", "s1"), True),
(("", ""), ("", ""), False),
])
def test_the_same_person_is_matched_on_email_or_sub(a, b, same):
assert same_person(*a, *b) is same
@pytest.mark.parametrize("instant,tz,cutoff,expected", [
(datetime(2026, 11, 15, 0, 30, tzinfo=timezone.utc), "Europe/London", "04:00", date(2026, 11, 14)),
(datetime(2026, 7, 15, 3, 30, tzinfo=timezone.utc), "Europe/London", "04:00", date(2026, 7, 15)), # 04:30 BST
(datetime(2026, 7, 15, 2, 59, tzinfo=timezone.utc), "Europe/London", "04:00", date(2026, 7, 14)), # 03:59 BST
(datetime(2026, 11, 14, 23, 30, tzinfo=timezone.utc), "Europe/Vienna", "04:00", date(2026, 11, 14)), # 00:30 CET: before the cutoff...
(datetime(2026, 11, 15, 3, 30, tzinfo=timezone.utc), "Europe/Vienna", "04:00", date(2026, 11, 15)), # ...04:30 CET
(datetime(2026, 11, 14, 23, 30, tzinfo=timezone.utc), "Europe/Vienna", "00:00", date(2026, 11, 15)), # no cutoff: the local date
])
def test_the_business_date(instant, tz, cutoff, expected):
assert business_date(instant, tz, cutoff) == expected
def test_a_bad_cutoff_is_a_rule_not_a_crash():
with pytest.raises(RuleBroken, match="a time like 04:00"):
business_date(AT, "Europe/London", "four o'clock")
def line(no, gross, code="S", rate="20.00"):
return ReceiptLine(line_no=no, product_id=f"P{no}", quantity=D(1), unit_code="C62", unit_price=D(gross), line_gross=D(gross),
tax_category="c", tax_code=code, rate_pct=D(rate))
def test_vat_is_rounded_once_per_tax_code_and_the_cents_are_shared_across_the_lines():
lines = [line(1, "0.10"), line(2, "0.10"), line(3, "0.10")] # each 0.0167: three roundings would say 0.06, the total says 0.05
pricing.apply_vat(lines, "GBP")
assert [ln.vat for ln in lines] == [D("0.02"), D("0.02"), D("0.01")] and sum(ln.vat for ln in lines) == D("0.05")
lines = [line(1, "3.60"), line(2, "4.00"), line(3, "2.40")]
pricing.apply_vat(lines, "GBP")
assert [ln.vat for ln in lines] == [D("0.60"), D("0.67"), D("0.40")] and sum(ln.vat for ln in lines) == D("1.67")
def test_each_tax_code_is_rounded_on_its_own_and_a_zero_rate_has_no_vat():
lines = [line(1, "2.50", "S19", "19.00"), line(2, "2.50", "R7", "7.00"), line(3, "18.99", "Z", "0.00")]
pricing.apply_vat(lines, "EUR")
assert [ln.vat for ln in lines] == [D("0.40"), D("0.16"), D("0.00")]
def test_vat_to_the_yen_has_no_cents():
lines = [line(1, "480", "S10", "10.00")]
pricing.apply_vat(lines, "JPY")
assert lines[0].vat == D("44")
def rate(cat, consumption, code, pct, supply="goods"):
return pricing.Rate(cat, supply, consumption, code, D(pct))
def test_the_rate_is_the_one_for_the_answer_and_asks_only_when_it_matters():
rates = [rate("food", "eat_in", "S19", "19"), rate("food", "takeaway", "R7", "7"), rate("drink", "any", "S19", "19")]
assert pricing.pick_rate(rates, "food", True).tax_code == "S19" and pricing.pick_rate(rates, "food", False).tax_code == "R7"
assert pricing.pick_rate(rates, "drink", None).tax_code == "S19"
with pytest.raises(RuleBroken) as e:
pricing.pick_rate(rates, "food", None)
assert e.value.gate == "eat_in_known"
with pytest.raises(RuleBroken) as e:
pricing.pick_rate(rates, "unknown", True)
assert e.value.gate == "tax_rate_known"
def price(pid, unit, frm, amount, per=1):
return pricing.Price(pid, unit, D(frm), D(amount), per)
def test_the_price_is_the_list_row_for_the_quantity_and_unit_and_never_guessed():
prices = [price("B", "KGM", "1", "20.00"), price("B", "KGM", "10", "18.00"), price("P", "C62", "1", "2.00"), price("P", "BX", "1", "20.00")]
assert pricing.pick_price(prices, "B", D(3), None, date(2026, 11, 14)).unit_price == D("20.00")
assert pricing.pick_price(prices, "B", D(10), None, date(2026, 11, 14)).unit_price == D("18.00") # the scale that applies
assert pricing.pick_price(prices, "P", D(1), "BX", date(2026, 11, 14)).unit_price == D("20.00")
with pytest.raises(RuleBroken) as e:
pricing.pick_price(prices, "P", D(1), None, date(2026, 11, 14))
assert e.value.gate == "unit_code"
with pytest.raises(RuleBroken) as e:
pricing.pick_price(prices, "X", D(1), None, date(2026, 11, 14))
assert e.value.gate == "price_known" and "X" in str(e.value)
assert pricing.line_gross(price("B", "KGM", "1", "20.00", per=4), D(2), "GBP") == D("10.00") # per 4 kg, 2 kg bought
assert pricing.line_gross(price("B", "KGM", "1", "1.0050"), D(1), "GBP") == D("1.01") # half up, once
def test_money_is_exact_bounded_and_to_the_currencys_places():
assert money.amount("10", "GBP", "x") == D("10.00") and money.amount(D("0"), "GBP", "x") == D("0.00")
assert money.amount("480", "JPY", "x") == D("480") and money.amount("9999999999999999.99", "GBP", "x") == D("9999999999999999.99")
for bad in (None, "", " ", "abc", "NaN", "inf", "-0.01", 1.5, True, "1.001", "10000000000000000"):
with pytest.raises(OutOfBounds):
money.amount(bad, "GBP", "x")
with pytest.raises(OutOfBounds):
money.amount("0", "GBP", "x", positive=True)
with pytest.raises(OutOfBounds):
money.amount("1.5", "JPY", "x") # yen has no minor unit
assert money.quantity("2") == D(2) and money.quantity("0.250") == D("0.25") and money.quantity("10") == D(10)
for bad in ("0", "-1", "1000000", "1.0001", "x", 2.0):
with pytest.raises(OutOfBounds):
money.quantity(bad)
def test_the_expected_cash_is_the_float_plus_cash_taken_less_what_went_out():
moves = {"safe_drop": D("100.00"), "paid_out": D("12.00"), "paid_in": D("5.00"), "float_top_up": D("20.00")}
assert expected_cash(D("150.00"), D("94.00"), moves) == D("157.00")
assert expected_cash(D("150.00"), D("0.00"), {}) == D("150.00")
def test_a_difference_at_the_threshold_closes_at_once_and_above_it_waits():
assert counted("-5.00").status == "closed" and counted("5.00").status == "closed"
assert counted("-5.01").status == "counted" and counted("5.01").status == "counted"
s = counted("-5.00")
assert s.difference == D("-5.00") and s.approved_by == ""
def test_the_second_person_rule_is_a_gate_with_its_words():
s = counted()
with pytest.raises(RuleBroken) as e:
s.approve_difference(reason="x", at=AT, by="b@shop", sub="sub-a")
assert (e.value.gate, e.value.fix, str(e.value)) == ("second_person", "ask another shift lead", "you counted this session")
assert s.status == "counted" # a refusal changes nothing
s.approve_difference(reason="coins", at=AT, by="b@shop", sub="sub-b")
assert s.status == "closed" and s.approved_sub == "sub-b"
def test_the_float_left_is_not_more_than_the_count():
with pytest.raises(RuleBroken, match="not more than"):
a_session().count_session(counted=D("100.00"), expected=D("150.00"), threshold=D("5.00"), closing_float=D("120.00"),
register_last=None, server_last=0, at=AT, by="a", sub="a")
def test_a_receipt_pays_by_the_authorised_lines_only_and_finalises_only_when_they_equal_the_bill():
s = a_session()
r = Receipt.ring_up(session=s, receipt_no=1, lines=[line(1, "10.00")], eat_in=None, by="a", sub="a")
pricing.apply_vat(r.lines, "GBP")
r.take_payment(method="card", amount=D("10.00"), tip=D("1.00"), terminal_txn_ref="T1", terminal_status="declined",
session_status="open", by="a")
assert r.paid() == D("0.00") and r.tips() == D("0.00") and r.payments[0].tip_amount == D("0.00") # a declined card tips nothing
r.take_payment(method="cash", amount=D("20.00"), tip=D("0.50"), terminal_txn_ref="", terminal_status="", session_status="open", by="a")
assert (r.paid(), r.remaining(), r.payments[1].change_amount, r.tips()) == (D("10.00"), D("0.00"), D("10.00"), D("0.50"))
with pytest.raises(RuleBroken, match="paid in full"):
r.take_payment(method="cash", amount=D("1.00"), tip=D("0"), terminal_txn_ref="", terminal_status="", session_status="open", by="a")
assert r.finalise(at=AT, by="a") is not None and r.finalise(at=AT, by="a") is None
with pytest.raises(RuleBroken, match="never changed"):
r.take_payment(method="cash", amount=D("1.00"), tip=D("0"), terminal_txn_ref="", terminal_status="", session_status="open", by="a")
def test_a_receipt_needs_a_line_a_total_and_an_open_session():
s = a_session()
for lines in ([], [line(i, "1.00") for i in range(1, 202)], [line(1, "0.00")]):
with pytest.raises(RuleBroken):
Receipt.ring_up(session=s, receipt_no=1, lines=lines, eat_in=None, by="a", sub="a")
s.status = "counted"
with pytest.raises(RuleBroken, match="open session"):
Receipt.ring_up(session=s, receipt_no=1, lines=[line(1, "1.00")], eat_in=None, by="a", sub="a")