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