dodil/erp-brick-payments

DODIL ERP brick: payments

src/brick_payments/domain/credit.py

"""Credit: a customer's limit (dated rows - a new limit is a new row from a later day) and the HOLD, which is
DERIVED from the limit, the oldest overdue invoice and the people's overrides - never stored."""
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Optional

from brick_payments.domain.readiness import Gate, fmt, met, unmet


@dataclass(frozen=True)
class Limit:
    valid_from: date
    amount: Decimal
    currency: str


@dataclass(frozen=True)
class Override:
    kind: str                # hold | release
    starts_at: datetime
    ends_on: date
    reason: str = ""
    set_by: str = ""


@dataclass(frozen=True)
class Hold:
    held: bool
    reason: str = ""
    source: str = ""         # rule | manual | released (a release suppressed a rule hold) | ""


def limit_on(limits: list, on: date) -> Optional[Limit]:
    """The limit in force on a date: the row with the latest valid_from on or before it. What the limit WAS on any
    past date stays answerable because nothing is edited."""
    inforce = [lim for lim in limits if lim.valid_from <= on]
    return max(inforce, key=lambda lim: lim.valid_from) if inforce else None


def active_override(overrides: list, now: datetime, today: date) -> Optional[Override]:
    """The latest override started by now and not yet ended (ends_on is its last day)."""
    live = [o for o in overrides if o.starts_at <= now and o.ends_on >= today]
    return max(live, key=lambda o: o.starts_at) if live else None


def derive(*, today: date, now: datetime, limit: Optional[Limit], balance_in_limit_currency: Decimal,
           oldest_overdue_days: int, days_rule: int, over_limit_rule: bool, overrides: list) -> Hold:
    """Hold from the rules (an invoice overdue by the rule's days, or over the limit), then the person's override:
    a manual hold holds; a release lifts a rule hold until its end date."""
    reasons = []
    if oldest_overdue_days >= days_rule:
        reasons.append(f"an invoice is {oldest_overdue_days} days overdue (the rule holds at {days_rule})")
    if over_limit_rule and limit is not None and balance_in_limit_currency > limit.amount:
        reasons.append(f"the balance {fmt(balance_in_limit_currency)} {limit.currency} is over the credit limit {fmt(limit.amount)}")
    ov = active_override(overrides, now, today)
    if ov is not None and ov.kind == "hold":
        return Hold(True, f"held by {ov.set_by or 'the owner'}: {ov.reason}" + (f"; also {'; '.join(reasons)}" if reasons else ""), "manual")
    if reasons and ov is not None and ov.kind == "release":
        return Hold(False, f"released by {ov.set_by or 'the owner'} until {ov.ends_on}: {ov.reason} (the rule would hold: {'; '.join(reasons)})",
                    "released")
    if reasons:
        return Hold(True, "; ".join(reasons), "rule")
    return Hold(False)


def credit_ok(hold: Hold, mode: str) -> Gate:
    """The readiness gate sales-order shows on confirm (PAY-40)."""
    if not hold.held:
        return met("credit_ok")
    effect = "the order is refused" if mode == "block" else "the order is allowed with a warning"
    return unmet("credit_ok", f"The customer is on credit hold: {hold.reason}; hold mode {mode}, so {effect}",
                 "Ask the owner to release the hold (pay:credit:write), or have the customer pay what is overdue")