dodil/erp-brick-pos

DODIL ERP brick: point of sale

src/brick_pos/domain/refund.py

"""Refunds (POS-11): what may be given back of a sale, and what the refund is worth.

A refund names the original receipt and its lines. The quantity of each original line is CLAIMED, one claim row per refund of a line
(`refund:<original receipt_id>:<line_no>:<claim_no>`, claim_no = how many refunds of that line came before), so two people refunding
the last latte at once never both succeed: the loser's insert is a duplicate key, it re-reads and finds nothing left. The VAT is the
ORIGINAL's tax code and rate (pinned), never today's: a rate that changed since the sale does not change the refund. The restock
decision is per line: the cashier says the item goes back on the shelf, within what its category allows (a drink is never restocked)."""
from dataclasses import dataclass
from decimal import 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
from brick_pos.domain.values import ReceiptKind, ReceiptState

ZERO = Decimal("0.00")


@dataclass(frozen=True)
class Claimed:
    """What the earlier refunds of one original line took (from the claim rows)."""
    quantity: Decimal = Decimal(0)
    gross: Decimal = ZERO
    vat: Decimal = ZERO
    claims: int = 0


def refundable(original) -> None:
    if original.kind != ReceiptKind.SALE.value:
        raise RuleBroken(f"receipt {original.receipt_id} is itself a refund: a refund is of a sale", gate="refund_of_sale",
                         fix="refund the original sale")
    if original.status != ReceiptState.FINALISED.value:
        raise RuleBroken(f"receipt {original.receipt_id} is {original.status}: only a finalised sale is refunded", gate="receipt_final",
                         fix="void it instead while it is open")


def plan(original, claimed: dict, picks: list, currency: str, restock_allowed: dict) -> list:
    """The refund's lines. `picks`: [(original line_no, quantity, restock or None)]; `claimed`: line_no -> Claimed; `restock_allowed`:
    line_no -> what the line's category says (restock on refund yes/no). Raises when a line is not on the sale, appears twice, or
    has less left than asked."""
    by_no = {ln.line_no: ln for ln in original.lines}
    seen, out = set(), []
    for no, (line_no, qty, restock) in enumerate(picks, start=1):
        if line_no in seen:
            raise RuleBroken(f"line {line_no} of {original.receipt_id} appears twice in one refund", gate="bounds",
                             fix="send each line once with its quantity")
        seen.add(line_no)
        src = by_no.get(line_no)
        if src is None:
            raise RuleBroken(f"{original.receipt_id} has no line {line_no}", gate="refund_line", fix="refund a line of the sale")
        if src.kind != "product":
            raise RuleBroken(f"line {line_no} of {original.receipt_id} is a gift voucher: what it holds is money we owe, not a refund",
                             gate="voucher_not_refunded", fix="the voucher's balance is spent, not refunded")
        done = claimed.get(line_no, Claimed())
        left = src.quantity - done.quantity
        if left <= 0:
            raise RuleBroken(f"line {line_no} of {original.receipt_id} ({src.product_id}) is already refunded in full: nothing left to refund",
                             gate="nothing_to_refund", fix="there is nothing more to give back on this line")
        if qty > left:
            raise RuleBroken(f"only {format(left, 'f')} of line {line_no} ({src.product_id}) is left to refund, not {format(qty, 'f')}",
                             gate="refund_quantity", fix=f"refund at most {format(left, 'f')}")
        if qty == left:                                        # the last of the line: exactly what is left, so the cents add up
            gross, vat = src.line_gross - done.gross, src.vat - done.vat
        else:
            gross = money.round_half_up(src.line_gross * qty / src.quantity, currency)
            vat = money.round_half_up(gross * src.rate_pct / (100 + src.rate_pct), currency)
            gross, vat = min(gross, src.line_gross - done.gross), min(vat, src.vat - done.vat)
        allowed = restock_allowed.get(line_no, False)
        if restock and not allowed:
            raise RuleBroken(f"{src.product_id} is not restocked on a refund (a made item, or its category {src.tax_category} says so)",
                             gate="restock_refused", fix="refund it without restock")
        out.append(ReceiptLine(line_no=no, product_id=src.product_id, description=src.description, quantity=qty, unit_code=src.unit_code,
                               unit_price=src.unit_price, line_gross=-gross, tax_category=src.tax_category, tax_code=src.tax_code,
                               rate_pct=src.rate_pct, vat=-vat, original_line_no=line_no,
                               restock=bool(allowed if restock is None else restock)))
    return out


def method_of_refund(original, method: str, amount: Decimal, refunded_so_far: Decimal) -> None:
    """The money goes back the way it came: the refund's method is one the sale was paid with, and the refunds by that method never
    come to more than the sale took by it."""
    taken = sum((p.amount for p in original.payments if p.counts and p.method == method), ZERO)
    if taken == 0:
        raise RuleBroken(f"receipt {original.receipt_id} was not paid by {method}: it is refunded the way it was paid", gate="refund_method",
                         fix="refund by a method the sale used")
    if refunded_so_far + amount > taken:
        raise RuleBroken(f"{method} refunds of {original.receipt_id} would come to {refunded_so_far + amount}, the sale took {taken} by {method}",
                         gate="refund_method", fix=f"refund at most {taken - refunded_so_far} by {method}")


def cash_limit(amount: Decimal, limit: Decimal, approver: Optional[tuple], opener: tuple, same_person) -> Optional[tuple]:
    """A cash refund above the limit is given by a shift lead (`approver` = (email, sub), who holds the override permission) who is
    not the person whose drawer pays it. Returns the approver (or None when under the limit)."""
    if amount <= limit:
        return None
    if approver is None:
        raise RuleBroken(f"a cash refund above {limit} needs a shift lead", gate="refund_limit", fix="ask a shift lead to give it")
    if same_person(approver[0], approver[1], opener[0], opener[1]):
        raise RuleBroken("you opened this drawer: a cash refund above the limit is approved by another person", gate="second_person",
                         fix="ask another shift lead")
    return approver