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")