dodil/erp-brick-pos
DODIL ERP brick: point of sale
tests/test_refunds.py
"""Slice B, item 2: voiding an open receipt, and refunds (POS-11) with stock back through inventory (INV-59) - each written from its
Given / When / Then through the doors, with the invariants that bite here: one claim per refunded quantity, the original's tax code and
rate, the money going back the way it came, side effects only from pending rows after the commit, no backward states, no self-approval on
email AND sub, 422 bounds, flat statements."""
from decimal import Decimal
import pytest
from conftest import (
ACCOUNTANT,
CASHIER,
CASHIER2,
LEAD,
LEAD2,
OPS,
OWNER,
ROLES,
SYSTEM,
api,
as_role,
finalise,
freeze,
make_shop,
must,
open_till,
pay,
person,
refused,
ring,
sell,
)
from sqlalchemy import select
from test_statement_budget import counting, lines, many_products
from brick_pos.application import use_cases
from brick_pos.infrastructure.tables import ClaimRepository, pos_claim, pos_refund_claim
def rule(category, restock, who=None):
return must(api("POST", f"/category-rules/{category}", who or OPS, {"restock_on_refund": restock}))
def refund(original, session_id, lines_, method="cash", who=None, reason="not what I ordered", **more):
return api("POST", f"/receipts/{original['receipt_id']}/refund", who or CASHIER,
{"session_id": session_id, "lines": lines_, "method": method, "reason": reason, **more})
def one(line_no, quantity="1", **more):
return {"line_no": line_no, "quantity": quantity, **more}
def get_receipt(r, who=None):
return must(api("GET", f"/receipts/{r['receipt_id']}", who or CASHIER))
def a_sale_of_three(nb):
"""The story's receipt: LATTE-REG 3.60, LATTE-OAT 4.00 and CROISSANT 2.40, 10.00 in cash, sold on 2026-11-14 on R1; the next day another
session on R1 does the refunding."""
make_shop()
rule("hot_drink", False)
rule("packaged_food", True)
s = open_till()
sale = sell(s["session_id"], [("LATTE-REG", 1), ("LATTE-OAT", 1), ("CROISSANT", 1)])
assert must(api("POST", f"/sessions/{s['session_id']}/count", CASHIER, {"counted_cash": "160.00", "float_left": "150.00"}))["status"] == "closed"
freeze("2026-11-15T10:00:00+00:00")
return sale, open_till(opening_float="150.00", who=CASHIER2)
# ── void an open receipt ────────────────────────────────────────────────────────────────────────────────
def test_void_an_open_receipt_keeps_it_as_a_record_and_takes_nothing(nb):
make_shop()
s = open_till()
sid = s["session_id"]
r = ring(sid, [("LATTE-REG", 2)])
r = pay(r, "cash", "3.00") # 3.00 handed over, the customer changes their mind
voided = must(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": "customer left", "version": r["version"]}))
assert (voided["status"], voided["void_reason"], voided["voided_by"], voided["receipt_no"]) == ("voided", "customer left", "cashier@shop.example", 1)
assert [ln["product_id"] for ln in voided["lines"]] == ["LATTE-REG"] and voided["gross"] == "7.20" # kept with its lines
sell(sid, [("CROISSANT", 1)])
assert ring(sid, [("LATTE-REG", 1)])["receipt_no"] == 3 # the voided number is never reused
summary = must(api("GET", f"/sessions/{sid}", CASHIER))["summary"]
assert (summary["receipts"], summary["voids"], summary["gross"]) == (1, 1, "2.40") # no takings, no VAT from the void
assert [r["ref"] for r in must(api("GET", "/outbox", LEAD))["rows"]] == [f"{sid}-0002"] # and no stock left the shop for it
assert [i["source_id"] for i in nb.issue_calls] == [f"{sid}-0002"]
def test_void_a_voided_receipt_never_goes_back_and_voiding_again_changes_nothing(nb):
make_shop()
sid = open_till()["session_id"]
r = ring(sid, [("LATTE-REG", 1)])
first = must(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": "wrong order"}))
again = must(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": "wrong order"}))
assert again["version"] == first["version"]
refused(api("POST", f"/receipts/{r['receipt_id']}/payments", CASHIER, {"method": "cash", "amount": "3.60"}), 409, "was voided", gate="receipt_open")
refused(api("POST", f"/receipts/{r['receipt_id']}/finalise", CASHIER, {}), 409, "was voided", gate="receipt_open")
assert get_receipt(r)["status"] == "voided" and nb.issue_calls == []
def test_void_a_final_receipt_is_refunded_never_voided(nb):
make_shop()
sid = open_till()["session_id"]
done = sell(sid, [("LATTE-REG", 1)])
refused(api("POST", f"/receipts/{done['receipt_id']}/void", CASHIER, {"reason": "oops"}), 409, "never voided", gate="receipt_open")
assert get_receipt(done)["status"] == "finalised"
def test_void_needs_a_reason_an_open_session_and_the_permission(nb):
make_shop()
s = open_till()
sid = s["session_id"]
r = ring(sid, [("LATTE-REG", 1)])
refused(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": " "}), 409, "names its reason", gate="reason_required")
refused(api("POST", f"/receipts/{r['receipt_id']}/void", ACCOUNTANT, {"reason": "x"}), 403, "pos:receipt:sell")
must(api("POST", f"/sessions/{sid}/count", CASHIER, {"counted_cash": "150.00"}))
refused(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": "x"}), 409, "open session", gate="session_open")
def test_void_a_card_the_terminal_authorised_is_reversed_there_first(nb):
make_shop()
sid = open_till()["session_id"]
r = ring(sid, [("LATTE-REG", 1)])
r = pay(r, "card", "3.60", terminal_txn_ref=f"T-{r['receipt_id']}-1", terminal_status="authorised")
refused(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": "x"}), 409, "still authorised on the terminal", gate="card_reversed")
done = must(api("POST", f"/receipts/{r['receipt_id']}/void", CASHIER, {"reason": "x", "terminal_reversals": [f"T-{r['receipt_id']}-1"]}))
assert [p["terminal_status"] for p in done["payments"]] == ["reversed"] and done["paid"] == "0.00"
def test_void_belongs_to_the_shop_that_rang_it(nb):
make_shop()
make_shop("LON-KX", registers=("R1",), staff=(as_role("cashier", "kx@shop.example"),))
r = ring(open_till()["session_id"], [("LATTE-REG", 1)])
assert api("POST", f"/receipts/{r['receipt_id']}/void", as_role("cashier", "kx@shop.example"), {"reason": "x"})[0] == 404
# ── POS-11: refund against the original receipt ─────────────────────────────────────────────────────────
def test_pos_11_the_oat_latte_is_refunded_in_cash_with_the_originals_vat_and_not_twice(engine, nb):
sale, today = a_sale_of_three(nb)
done = must(refund(sale, today["session_id"], [one(2)]))
assert (done["kind"], done["status"], done["original_receipt_id"], done["gross"], done["vat"], done["net"]) == \
("refund", "finalised", sale["receipt_id"], "-4.00", "-0.67", "-3.33")
[ln] = done["lines"]
assert (ln["product_id"], ln["original_line_no"], ln["quantity"], ln["line_gross"], ln["vat"], ln["tax_code"], ln["rate_pct"], ln["restock"]) == \
("LATTE-OAT", 2, "1", "-4.00", "-0.67", "S", "20.0000", False)
assert done["business_date"] == "2026-11-15" and done["receipt_id"] == f"{today['session_id']}-0001"
[p] = done["payments"]
assert (p["method"], p["amount"], p["idempotency_key"]) == ("cash", "-4.00", f"{done['receipt_id']}:1")
refused(refund(sale, today["session_id"], [one(2)], who=CASHIER2), 409, "already refunded in full: nothing left to refund",
gate="nothing_to_refund") # a colleague tries the same latte again
with engine.begin() as conn:
keys = sorted(r[0] for r in conn.execute(select(pos_claim.c.claim_key)) if r[0].startswith("refund:"))
claims = [tuple(r) for r in conn.execute(select(pos_refund_claim.c.line_no, pos_refund_claim.c.claim_no, pos_refund_claim.c.refund_receipt_id,
pos_refund_claim.c.refunded_after))]
assert keys == [f"refund:{sale['receipt_id']}:2:1"] and claims == [(2, 1, done["receipt_id"], Decimal("1.000"))]
assert nb.receipt_calls == [] # a drink is not restocked
assert get_receipt(sale)["status"] == "finalised" and get_receipt(sale)["gross"] == "10.00" # the sale itself is never edited
seen = must(api("GET", f"/sessions/{today['session_id']}", CASHIER2))
assert seen["summary"]["gross"] == "-4.00"
def test_pos_11_a_rate_that_changed_since_the_sale_does_not_change_the_refund(nb):
sale, today = a_sale_of_three(nb)
nb.rates["GB"] = [{**r, "tax_code": "S25", "rate_pct": "25.00"} if r["category_code"] == "hot_drink" else r for r in nb.rates["GB"]]
calls = len(nb.rate_calls)
done = must(refund(sale, today["session_id"], [one(1)]))
assert (done["lines"][0]["tax_code"], done["lines"][0]["rate_pct"], done["vat"]) == ("S", "20.0000", "-0.60") # 3.60 at the sale's 20%
assert len(nb.rate_calls) == calls # tax was not even asked: the rate is pinned
def test_pos_11_a_line_is_refunded_in_parts_and_never_past_what_was_sold(nb):
make_shop()
rule("hot_drink", False)
s = open_till()
sale = sell(s["session_id"], [("LATTE-REG", 3)]) # 10.80, VAT 1.80
must(api("POST", f"/sessions/{s['session_id']}/count", CASHIER, {"counted_cash": "160.80", "float_left": "150.00"}))
t = open_till()["session_id"]
first = must(refund(sale, t, [one(1, "1")]))
refused(refund(sale, t, [one(1, "3")]), 409, "only 2 of line 1 (LATTE-REG) is left", gate="refund_quantity")
last = must(refund(sale, t, [one(1, "2")]))
assert (first["gross"], first["vat"], last["gross"], last["vat"]) == ("-3.60", "-0.60", "-7.20", "-1.20")
assert Decimal(first["gross"]) + Decimal(last["gross"]) == -Decimal(sale["gross"]) # the last refund returns exactly what is left
assert Decimal(first["vat"]) + Decimal(last["vat"]) == -Decimal(sale["vat"])
refused(refund(sale, t, [one(1, "1")]), 409, "nothing left to refund", gate="nothing_to_refund")
def test_pos_11_three_refunds_of_a_line_that_does_not_divide_return_exactly_what_was_paid(nb):
nb.price_lists["LIST-UK-SHOP"]["prices"].append(("THIRDS", "C62", "1", "3.3333"))
nb.product_row("THIRDS", "hot_drink")
make_shop()
rule("hot_drink", False)
s = open_till()
sale = sell(s["session_id"], [("THIRDS", 3)]) # 3 x 3.3333 = 9.9999, paid as 10.00
assert (sale["gross"], sale["vat"]) == ("10.00", "1.67")
must(api("POST", f"/sessions/{s['session_id']}/count", CASHIER, {"counted_cash": "160.00", "float_left": "150.00"}))
t = open_till()["session_id"]
got = [must(refund(sale, t, [one(1, "1")])) for _ in range(3)]
assert [g["gross"] for g in got] == ["-3.33", "-3.33", "-3.34"] # the last one takes what is left, to the cent
assert sum(Decimal(g["vat"]) for g in got) == -Decimal("1.67")
def test_pos_11_a_bag_of_beans_goes_back_to_stock_a_drink_never_does(nb):
make_shop()
rule("hot_drink", False)
rule("packaged_food", True)
s = open_till()
sale = sell(s["session_id"], [("BEANS-1KG", 2), ("LATTE-REG", 1)])
must(api("POST", f"/sessions/{s['session_id']}/count", CASHIER, {"counted_cash": "191.58", "float_left": "150.00"}))
t = open_till()["session_id"]
refused(refund(sale, t, [one(2, restock=True)]), 409, "is not restocked on a refund", gate="restock_refused") # a drink, whatever is ticked
assert nb.receipt_calls == [] and must(api("GET", "/receipts", CASHIER, {"session_id": t}))["receipts"] == [] # nothing of the refusal stuck
done = must(refund(sale, t, [one(1, "1")])) # the category says restock
assert done["lines"][0]["restock"] is True
[call] = nb.receipt_calls
assert call == {"move_ref": f"pos:{done['receipt_id']}:1:1", "source_kind": "pos_refund_restock", "source_id": done["receipt_id"],
"company_code": "UK01", "product_id": "BEANS-1KG", "quantity": "1", "unit_code": "C62", "location_id": "LON-SOHO/BAR",
"received_on": "2026-11-14", "reverses_move_ref": f"pos:{sale['receipt_id']}:1:1"}
again = must(refund(sale, t, [one(1, "1", restock=False)])) # the cashier says: spoiled, not back on the shelf
assert again["lines"][0]["restock"] is False and len(nb.receipt_calls) == 1
rows = [r for r in must(api("GET", "/outbox", LEAD))["rows"] if r["target"] == "inventory.restock"]
assert [(r["ref"], r["status"]) for r in rows] == [(f"pos:{done['receipt_id']}:1:1", "sent")]
def test_pos_11_the_restock_is_sent_from_a_pending_row_after_the_commit_and_never_twice(monkeypatch, nb):
make_shop()
rule("packaged_food", True)
s = open_till()
sale = sell(s["session_id"], [("BEANS-1KG", 1)])
monkeypatch.setattr(use_cases, "after_response", lambda *a, **k: None) # the process dies after the commit
done = must(refund(sale, s["session_id"], [one(1)]))
assert nb.receipt_calls == []
rows = [r for r in must(api("GET", "/outbox", LEAD))["rows"] if r["target"] == "inventory.restock"]
assert [(r["ref"], r["status"]) for r in rows] == [(f"pos:{done['receipt_id']}:1:1", "pending")]
assert must(api("POST", "/outbox/dispatch", SYSTEM, {}))["sent"] == 1
assert must(api("POST", "/outbox/dispatch", SYSTEM, {})) == {"sent": 0, "pending": 0, "failed": 0}
assert len(nb.receipt_calls) == 1
def test_pos_11_a_refund_is_never_refused_because_inventory_is_down_and_the_row_waits(nb):
make_shop()
rule("packaged_food", True)
s = open_till()
sale = sell(s["session_id"], [("BEANS-1KG", 1)])
nb.inventory_status = 500
done = must(refund(sale, s["session_id"], [one(1)]))
assert done["status"] == "finalised" # the customer has their money
[row] = [r for r in must(api("GET", "/outbox", LEAD))["rows"] if r["target"] == "inventory.restock"]
assert row["status"] == "pending" and "inventory answered 500" in row["last_error"]
nb.inventory_status = None
assert must(api("POST", "/outbox/dispatch", SYSTEM, {}))["sent"] == 1 and len(nb.receipts) == 1
def test_pos_11_the_money_goes_back_the_way_it_came(nb):
make_shop()
rule("hot_drink", False)
s = open_till()
sid = s["session_id"]
r = ring(sid, [("LATTE-REG", 2)]) # 7.20: 3.60 cash, 3.60 card
r = pay(r, "cash", "3.60")
r = pay(r, "card", "3.60", terminal_txn_ref="T-1", terminal_status="authorised")
sale = finalise(r)
refused(refund(sale, sid, [one(1, "2")], "card", terminal_txn_ref="T-2", terminal_status="authorised"), 409,
"would come to 7.20, the sale took 3.60 by card", gate="refund_method")
refused(refund(sale, sid, [one(1)], "card"), 409, "names the terminal's transaction", gate="terminal_ref")
refused(refund(sale, sid, [one(1)], "card", terminal_txn_ref="T-2", terminal_status="declined"), 409, "did not authorise", gate="terminal_status")
refused(refund(sale, sid, [one(1)], "voucher"), 409, "cash or card", gate="method")
by_card = must(refund(sale, sid, [one(1)], "card", terminal_txn_ref="T-2", terminal_status="authorised"))
[p] = by_card["payments"]
assert (p["method"], p["amount"], p["terminal_txn_ref"], p["terminal_status"]) == ("card", "-3.60", "T-2", "authorised")
assert p["idempotency_key"] == f"{by_card['receipt_id']}:1" != f"{sale['receipt_id']}:1" # the refund is its own terminal transaction
refused(refund(sale, sid, [one(1)], "card", terminal_txn_ref="T-3", terminal_status="authorised"), 409, "by card", gate="refund_method")
assert must(refund(sale, sid, [one(1)], "cash"))["payments"][0]["amount"] == "-3.60" # the other half went back in cash
only_card = ring(sid, [("LATTE-REG", 1)])
only_card = finalise(pay(only_card, "card", "3.60", terminal_txn_ref="T-4", terminal_status="authorised"))
refused(refund(only_card, sid, [one(1)], "cash"), 409, "was not paid by cash", gate="refund_method")
def test_pos_11_a_cash_refund_above_the_limit_needs_a_shift_lead_who_is_not_the_drawers_cashier(nb):
make_shop()
rule("hot_drink", False)
sid = open_till()["session_id"] # the cashier's drawer
sale = sell(sid, [("LATTE-REG", 10)]) # 36.00
refused(refund(sale, sid, [one(1, "10")]), 409, "a cash refund above 20.00 needs a shift lead", gate="refund_limit")
refused(refund(sale, sid, [one(1, "6")]), 409, gate="refund_limit") # 21.60 is above it too
under = must(refund(sale, sid, [one(1, "5")])) # 18.00: under the limit, the cashier gives it
assert (under["gross"], under["approved_by"]) == ("-18.00", "")
big = sell(sid, [("LATTE-REG", 10)])
done = must(refund(big, sid, [one(1, "10")], who=LEAD2)) # another person's drawer: a shift lead approves
assert (done["gross"], done["approved_by"]) == ("-36.00", "lead2@shop.example")
def test_pos_11_the_shift_lead_whose_drawer_it_is_does_not_approve_their_own_refund_under_either_identity(nb):
make_shop()
rule("hot_drink", False)
own = open_till(register="R2", who=LEAD) # the lead's own drawer
sid = own["session_id"]
sale = sell(sid, [("LATTE-REG", 10)], who=LEAD)
refused(refund(sale, sid, [one(1, "10")], who=LEAD), 409, "you opened this drawer", gate="second_person")
second_login = person("lead.second@shop.example", ROLES["shift_lead"], sub="shift_lead@shop.example")
refused(refund(sale, sid, [one(1, "10")], who=second_login), 409, gate="second_person")
must(api("POST", "/shops/LON-SOHO/staff", OPS, {"sub": "lead2-sub", "email": "lead2@shop.example"}))
cashier_drawer = open_till(register="R1", who=CASHIER)["session_id"]
done = must(refund(sale, cashier_drawer, [one(1, "10")], who=LEAD)) # another person's drawer: the lead approves
assert (done["approved_by"], done["gross"]) == ("shift_lead@shop.example", "-36.00")
def test_pos_11_the_limit_is_the_owners_control(nb):
make_shop()
refused(api("POST", "/settings/cash_refund_limit", OPS, {"value": "100.00"}), 403, "pos:settings:controls")
assert must(api("POST", "/controls/cash_refund_limit", OWNER, {"value": "50.00"}))["value"] == "50.00"
assert {x["setting_key"]: x["value"] for x in must(api("GET", "/settings", OPS))["settings"]}["cash_refund_limit"] == "50.00"
def test_pos_11_only_a_final_sale_is_refunded_where_it_was_sold(nb):
make_shop()
make_shop("LON-KX", registers=("R1",), staff=(CASHIER,))
sid = open_till()["session_id"]
open_sale = ring(sid, [("LATTE-REG", 1)])
refused(refund(open_sale, sid, [one(1)]), 409, "only a finalised sale is refunded", gate="receipt_final")
sale = sell(sid, [("LATTE-REG", 1)])
refused(refund(sale, sid, [one(1), one(1)]), 409, "appears twice", gate="bounds")
done = must(refund(sale, sid, [one(1)]))
refused(refund(done, sid, [one(1)]), 409, "is itself a refund", gate="refund_of_sale")
kx = open_till("LON-KX", "R1")["session_id"]
refused(refund(sale, kx, [one(1)]), 409, "was sold at LON-SOHO", gate="same_shop")
refused(refund(sale, sid, [one(9)]), 409, "has no line 9", gate="refund_line")
refused(refund(sale, sid, []), 422, "one to 200 lines", gate="bounds")
must(api("POST", f"/sessions/{sid}/count", CASHIER, {"counted_cash": "150.00"}))
refused(refund(sale, sid, [one(1)]), 409, "open session", gate="session_open")
def test_pos_11_who_may_refund_and_which_shop(nb):
sale, today = a_sale_of_three(nb)
refused(refund(sale, today["session_id"], [one(2)], who=ACCOUNTANT), 403, "pos:receipt:refund")
stranger = as_role("cashier", "stranger@shop.example")
assert refund(sale, today["session_id"], [one(2)], who=stranger)[0] == 404
assert must(api("GET", "/receipts", CASHIER, {"session_id": today["session_id"]}))["receipts"] == []
def test_pos_11_two_refunds_of_one_line_at_once_never_both_succeed(engine, nb):
sale, today = a_sale_of_three(nb)
with engine.begin() as conn: # a racer committed claim 1 of line 2 a moment ago
ClaimRepository(conn).claim(f"refund:{sale['receipt_id']}:2:1", "refund", "x", "racer", __import__("datetime").datetime(2026, 11, 15))
body = refused(refund(sale, today["session_id"], [one(2)]), 409, "claimed a moment ago", gate="claim")
assert body["fix"] == "reload and retry"
assert must(api("GET", "/receipts", CASHIER, {"session_id": today["session_id"]}))["receipts"] == [] # no refund receipt of ours
with engine.begin() as conn:
assert conn.execute(select(pos_refund_claim.c.line_no)).all() == [] # and no claim row
def test_pos_11_the_claim_keys_of_a_refund_a_sale_and_a_bag_are_their_own(engine, nb):
sale, today = a_sale_of_three(nb)
done = must(refund(sale, today["session_id"], [one(2)], who=CASHIER2))
with engine.begin() as conn:
keys = sorted(r[0] for r in conn.execute(select(pos_claim.c.claim_key)))
assert f"refund:{sale['receipt_id']}:2:1" in keys and f"pay:{sale['receipt_id']}:1" in keys
assert f"pay:{done['receipt_id']}:1" not in keys # a refund's payment line is not a claimed sale payment
def test_pos_11_the_refund_nets_the_sessions_journal_and_the_drawer(nb):
sale, today = a_sale_of_three(nb)
sid = today["session_id"]
must(refund(sale, sid, [one(2)])) # 4.00 out of the drawer
sell(sid, [("CROISSANT", 5)], who=CASHIER2) # 12.00 in
counted = must(api("POST", f"/sessions/{sid}/count", CASHIER2, {"counted_cash": "158.00"}))
assert counted["expected_cash"] == "158.00" and counted["status"] == "closed"
must(api("POST", f"/sessions/{sid}/post", ACCOUNTANT, {}))
[body] = nb.journal_calls
lines_ = {(ln["purpose"], ln.get("tax_code", "")): (ln.get("debit", ""), ln.get("credit", "")) for ln in body["lines"]}
assert lines_[("pos_cash_in_till", "")] == ("8.00", "") # 12.00 in, 4.00 out
assert lines_[("revenue", "")] == ("", "6.67") and lines_[("tax", "S")] == ("", "1.33") # 8.00 of net sales, 1.33 VAT, after the refund
debit = sum(Decimal(ln.get("debit", "0")) for ln in body["lines"])
assert debit == sum(Decimal(ln.get("credit", "0")) for ln in body["lines"])
BAD = ["-1", "0", "1e20", "10000000000000000", "1.0001", "abc", "", "NaN", "Infinity", 1.5, True, "1000000"]
@pytest.mark.parametrize("value", BAD)
def test_every_refund_quantity_the_books_cannot_hold_is_422_bounds(nb, value):
sale, today = a_sale_of_three(nb)
status, body = refund(sale, today["session_id"], [one(2, value)])
assert status == 422 and body.get("gate") == "bounds", (status, body)
assert api("POST", f"/receipts/{sale['receipt_id']}/refund", CASHIER, {"session_id": today["session_id"], "lines": [{"line_no": 0, "quantity": "1"}],
"method": "cash", "reason": "x"})[0] == 422
def test_category_rules_are_retail_operations_and_a_boolean(nb):
make_shop()
assert must(api("GET", "/category-rules", CASHIER))["restock_on_refund"] == {}
refused(api("POST", "/category-rules/packaged_food", CASHIER, {"restock_on_refund": True}), 403, "pos:settings:write")
for bad in ("yes", 1, None):
assert api("POST", "/category-rules/packaged_food", OPS, {"restock_on_refund": bad})[0] == 422
rule("packaged_food", True)
rule("packaged_food", False) # a rule is changed by saving it again
assert must(api("GET", "/category-rules", CASHIER))["restock_on_refund"] == {"packaged_food": False}
def test_a_refund_costs_the_same_statements_for_2_lines_or_20_and_with_50_unrelated_receipts_around(engine, nb):
many_products(nb)
make_shop()
for i in range(1, 21):
nb.product_row(f"P{i:02d}", "hot_drink")
rule("hot_drink", False)
must(api("POST", "/controls/cash_refund_limit", OWNER, {"value": "10000.00"}))
s = open_till()
sid = s["session_id"]
sales = {}
for n in (2, 20):
r = must(api("POST", "/receipts", CASHIER, {"session_id": sid, "lines": lines(n)}))
r = pay(r, "cash", r["remaining"])
sales[n] = finalise(r)
must(api("POST", f"/sessions/{sid}/count", CASHIER, {"counted_cash": "0.00"}))
t = open_till(register="R2", who=CASHIER2)["session_id"]
must(api("POST", f"/sessions/{t}/cash-moves", CASHIER2, {"kind": "paid_in", "amount": "500.00", "reason_code": "TOPUP"}))
costs = {}
for n in (2, 20):
with counting(engine) as seen:
done = must(refund(sales[n], t, [one(i) for i in range(1, n + 1)], who=CASHIER2))
assert len(done["lines"]) == n
costs[n] = len(seen)
assert costs[2] == costs[20], costs
make_shop("LON-KX", registers=("R1",))
k = open_till("LON-KX", "R1")["session_id"]
for _ in range(50):
sell(k, [("FLAT-WHITE", 1)])
fresh = ring(t, [("CROISSANT", 1)])
fresh = finalise(pay(fresh, "cash", fresh["remaining"], who=CASHIER2), who=CASHIER2)
with counting(engine) as busy:
must(refund(fresh, t, [one(1)], who=CASHIER2))
assert len(busy) == costs[2] # the 50 unrelated receipts added no statement