dodil/erp-brick-pos
DODIL ERP brick: point of sale
src/brick_pos/domain/pricing.py
"""Price and VAT of a till sale: pure rules over what product and tax answered.
The PRICE is product's (the shop's price list on the business date, tax-inclusive); the RATE is tax's (by the line's tax
category and, where the rules distinguish it, eat-in or takeaway). pos types neither and keeps no copy of its own: the
line carries the answer it was given, frozen with the receipt. VAT is computed per tax code on the code's total
including tax, rounded ONCE (half up), and the cents are then shared across the code's lines so the lines add up to the
total (10.00 x 20/120 = 1.67, not three roundings of three lines)."""
from collections import defaultdict
from dataclasses import dataclass
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal
from typing import Optional
from brick_pos.domain import money
from brick_pos.domain.errors import RuleBroken
from brick_pos.domain.receipt import ReceiptLine
@dataclass(frozen=True)
class Price:
product_id: str
unit_code: str
from_quantity: Decimal
unit_price: Decimal # NUMERIC(19,4), per pricing_quantity of the unit
pricing_quantity: int = 1
@dataclass(frozen=True)
class Rate:
category_code: str
supply: str
consumption: str # eat_in | takeaway | any
tax_code: str
rate_pct: Decimal
def pick_price(prices: list, product_id: str, quantity: Decimal, unit_code: Optional[str], on) -> Price:
rows = [p for p in prices if p.product_id == product_id]
if unit_code:
rows = [p for p in rows if p.unit_code == unit_code]
if not rows:
raise RuleBroken(f"{product_id} is not on the shop's price list on {on}: it cannot be sold here", gate="price_known",
fix="ask retail operations to put it on the price list")
units = sorted({p.unit_code for p in rows})
if len(units) > 1:
raise RuleBroken(f"{product_id} is priced in more than one unit ({', '.join(units)})", gate="unit_code",
fix="send unit_code")
fits = [p for p in rows if p.from_quantity <= quantity]
if not fits:
raise RuleBroken(f"{product_id} is sold from {min(p.from_quantity for p in rows)} {units[0]}", gate="price_known",
fix="ring up at least that quantity")
return max(fits, key=lambda p: p.from_quantity)
def pick_rate(rates: list, category: str, eat_in: Optional[bool]) -> Rate:
rows = [r for r in rates if r.category_code == category]
if not rows:
raise RuleBroken(f"tax has no rate for category {category} on this date: a till cannot print VAT it did not get "
"from tax", gate="tax_rate_known", fix="ask finance to set the tax rule for the category")
if eat_in is not None:
want = "eat_in" if eat_in else "takeaway"
rows = [r for r in rows if r.consumption in (want, "any")] or rows
distinct = {(r.tax_code, r.rate_pct) for r in rows}
if len(distinct) > 1:
raise RuleBroken(f"the rate of {category} depends on eating in or taking away", gate="eat_in_known",
fix="ask the customer, and send eat_in true or false")
return sorted(rows, key=lambda r: r.supply != "goods")[0]
def line_gross(price: Price, quantity: Decimal, currency: str) -> Decimal:
return money.round_half_up(price.unit_price * quantity / Decimal(price.pricing_quantity or 1), currency)
def apply_vat(lines: list, currency: str) -> None:
"""Fill each line's vat: per (tax code, rate), the code's VAT rounded once, the cents shared by largest remainder
(ties: the lower line number), so the lines add up to it exactly."""
q = money.quantum(currency)
groups = defaultdict(list)
for ln in lines:
groups[(ln.tax_code, ln.rate_pct)].append(ln)
for (_code, rate), group in groups.items():
total = (sum((ln.line_gross for ln in group), Decimal(0)) * rate / (100 + rate)).quantize(q, rounding=ROUND_HALF_UP)
exact = {ln.line_no: ln.line_gross * rate / (100 + rate) for ln in group}
shares = {n: e.quantize(q, rounding=ROUND_DOWN) for n, e in exact.items()}
left = int((total - sum(shares.values())) / q)
for n in sorted(exact, key=lambda n: (-(exact[n] - shares[n]), n))[:left]:
shares[n] += q
for ln in group:
ln.vat = shares[ln.line_no]
def build_lines(requests: list, prices: list, products: dict, rates: list, currency: str, eat_in: Optional[bool], on) -> list:
"""The receipt lines for what the cashier rang: product_id and quantity (and a unit when a product has several), priced
and taxed from the neighbours' answers. `products` maps product_id -> its tax category."""
out = []
for no, req in enumerate(requests, start=1):
qty = money.quantity(req["quantity"], f"line {no} quantity")
price = pick_price(prices, req["product_id"], qty, req.get("unit_code"), on)
rate = pick_rate(rates, products[req["product_id"]]["tax_category"], eat_in)
out.append(ReceiptLine(line_no=no, product_id=req["product_id"], description=products[req["product_id"]].get("name", ""),
quantity=qty, unit_code=price.unit_code, unit_price=price.unit_price,
line_gross=line_gross(price, qty, currency), tax_category=products[req["product_id"]]["tax_category"],
tax_code=rate.tax_code, rate_pct=rate.rate_pct))
apply_vat(out, currency)
return out