dodil/erp-brick-payments

DODIL ERP brick: payments

src/brick_payments/infrastructure/ai_steps.py

"""AI steps (the AI-step contract): the mode is part of the process. All three of this brick's steps only SUGGEST;
`ai_suggest_match` may be opted (by pay:settings:controls) into auto-with-review for EXACT matches.

  ai_suggest_match  baseline (domain/matching.py): reference text and sums, no model call - always explainable, and
                    useful with no provider wired. The suggestion row carries the evidence, a confidence and the model.
  ai_draft_reminder the level's own template, filled in (the spec's fallback); a provider may redraft it later.
  late_payment_risk a ranking on the overdue list: how late, how often - the customer's own payment history.

Tests never call a model."""
from datetime import date
from decimal import Decimal

AI_STEPS = {
    "ai_suggest_match": {"mode": "suggest", "modes_allowed": ["off", "suggest", "auto-with-review"], "safe_for_auto": False,
                         "fallback": "the receipt stays unallocated for the cashier"},
    "ai_draft_reminder": {"mode": "suggest", "modes_allowed": ["off", "suggest", "auto"], "safe_for_auto": False,
                          "fallback": "the level's template, filled in"},
    "late_payment_risk": {"mode": "suggest", "modes_allowed": ["off", "suggest"], "safe_for_auto": False,
                          "fallback": "the list sorts by age and amount"},
}


def draft_reminder(template: str, *, customer: str, invoice_number: str, outstanding: Decimal, currency: str,
                   due_date: date, level: int) -> str:
    """The level's template, filled in. {customer} {invoice} {amount} {currency} {due_date} {level}; an empty template
    gets a plain sentence that names the invoice and the amount."""
    body = template or ("Our records show invoice {invoice} for {amount} {currency}, due on {due_date}, is still unpaid. "
                        "Please arrange payment, or tell us if it has crossed with your payment.")
    return body.format(customer=customer, invoice=invoice_number, amount=f"{outstanding:,.2f}", currency=currency,
                       due_date=due_date.isoformat(), level=level)


def risk(overdue_days: int, late_paid: int, paid: int) -> dict:
    """A transparent late-payment risk: the share of this customer's paid invoices that were paid late, and how late the
    open one already is. A ranking for the collector, never a decision."""
    share = Decimal(late_paid) / Decimal(paid) if paid else Decimal(0)
    score = min(Decimal(1), share * Decimal("0.6") + Decimal(min(overdue_days, 90)) / Decimal(90) * Decimal("0.4"))
    return {"score": str(score.quantize(Decimal("0.01"))),
            "reasons": [f"{overdue_days} days past due", f"{late_paid} of {paid} earlier invoices paid late" if paid else "no payment history yet"]}