dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/domain/matching.py
"""The baseline receipt matcher: which open invoices does this receipt pay? No model - the reference text and the
sums only, so the step is useful with no provider wired and its answer is always explainable. It only SUGGESTS;
a person decides (decide_match), except an EXACT match in auto-with-review.
A suggestion names the evidence (which reference text named which invoice, which sum), a confidence and the model
(`baseline-reference-v1`). EXACT means: the reference carries each invoice's whole number, and the amount equals their outstanding."""
import re
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
from brick_payments.domain.money import ZERO
MODEL = "baseline-reference-v1"
@dataclass(frozen=True)
class Open:
invoice_id: str
number: str
outstanding: Decimal
due_date: Optional[object] = None
@dataclass
class Suggestion:
lines: list = field(default_factory=list) # [{"invoice_id", "amount"}]
evidence: dict = field(default_factory=dict)
confidence: Decimal = Decimal("0")
exact: bool = False
model: str = MODEL
def _norm(s: str) -> str:
return re.sub(r"[^a-z0-9]", "", s.lower())
def _tail_digits(number: str) -> Optional[int]:
groups = re.findall(r"\d+", number)
return int(groups[-1]) if groups else None
def names(text: str, invoice: Open) -> Optional[str]:
"""The piece of reference text that names this invoice: its whole number, or its trailing digits written alone
("INV41", "0042", "42") - a short digit run only counts when it is the invoice's own last group."""
if not text:
return None
if _norm(invoice.number) and _norm(invoice.number) in _norm(text):
return invoice.number
tail = _tail_digits(invoice.number)
if tail is None:
return None
for tok in re.findall(r"[A-Za-z]*(\d{2,})", text):
if int(tok) == tail:
return tok
return None
def names_in_full(text: str, invoice: Open) -> bool:
"""The reference carries the invoice's WHOLE number (normalised: case, spaces and punctuation ignored). Only this makes a
match EXACT: a year or a PO number that happens to equal the last group of a number only ever suggests."""
return bool(text) and bool(_norm(invoice.number)) and _norm(invoice.number) in _norm(text)
def suggest(*, amount: Decimal, remainder: Decimal, reference: str, open_invoices: list) -> Optional[Suggestion]:
"""The suggestion for a receipt with `remainder` unallocated, or None when nothing matches."""
if remainder <= ZERO or not open_invoices:
return None
named = [(inv, names(reference, inv)) for inv in open_invoices]
named = [(inv, hit) for inv, hit in named if hit]
if named:
# take the named invoices oldest due first, each for its full outstanding, until the receipt runs out
named.sort(key=lambda p: (p[0].due_date is None, p[0].due_date, p[0].invoice_id))
left, lines, hits = remainder, [], []
for inv, hit in named:
take = min(left, inv.outstanding)
if take > ZERO:
lines.append({"invoice_id": inv.invoice_id, "amount": take})
hits.append({"invoice_id": inv.invoice_id, "reference_text": hit, "outstanding": str(inv.outstanding)})
left -= take
total = sum((ln["amount"] for ln in lines), ZERO)
named_sum = sum((inv.outstanding for inv, _ in named), ZERO)
exact = named_sum == remainder and total == remainder and all(names_in_full(reference, inv) for inv, _ in named)
conf = Decimal("0.97") if exact else (Decimal("0.85") if total < remainder else Decimal("0.70"))
return Suggestion(lines, {"named_by_reference": hits, "sum_of_named": str(named_sum), "receipt_remainder": str(remainder),
"exact_sum": named_sum == remainder and total == remainder}, conf, exact)
# nothing named: only a single invoice, or the whole open set, that the amount equals exactly
for inv in open_invoices:
if inv.outstanding == remainder:
return Suggestion([{"invoice_id": inv.invoice_id, "amount": inv.outstanding}],
{"named_by_reference": [], "amount_equals_outstanding_of": inv.invoice_id,
"receipt_remainder": str(remainder), "exact_sum": True}, Decimal("0.60"), False)
if sum((i.outstanding for i in open_invoices), ZERO) == remainder:
return Suggestion([{"invoice_id": i.invoice_id, "amount": i.outstanding} for i in open_invoices],
{"named_by_reference": [], "amount_equals_all_open": True,
"receipt_remainder": str(remainder), "exact_sum": True}, Decimal("0.50"), False)
return None