dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/domain/collection.py
"""Collections: reminders at each level the business set, promises and disputes. State is derived on read from
the invoice's outstanding and the events - never stored."""
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from typing import Iterable, Optional
from brick_payments.domain.money import ZERO
from brick_payments.domain.readiness import Gate, met, unmet
@dataclass(frozen=True)
class Level:
level: int
days_after_due: int
channel: str = "email"
template: str = ""
mode: str = "suggest" # off | suggest | auto
@dataclass(frozen=True)
class Event:
kind: str # reminder | promise | dispute | resolution
level: int
ref_id: str
invoice_id: str = ""
occurred_on: Optional[date] = None
promise_date: Optional[date] = None
status: str = "" # a reminder: draft | sent
reason: str = ""
outcome: str = ""
def promise_in_future(promise_date: date, today: date) -> Gate:
if promise_date <= today:
return unmet("promise_in_future", f"The promise date {promise_date} is today or earlier",
"Record the date the customer promised, after today")
return met("promise_in_future")
def level_due(level: Level, due_date: date, today: date) -> Gate:
due_on = due_date + timedelta(days=level.days_after_due)
if today < due_on:
return unmet("level_due", f"Level {level.level} is due {level.days_after_due} days after {due_date}",
"Wait for the date, or chase by hand")
return met("level_due")
def open_disputes(events: Iterable[Event], invoice_id: str) -> list:
"""The disputes of an invoice that have no resolution yet."""
events = list(events)
return [e for e in events if e.kind == "dispute" and e.invoice_id == invoice_id and not any(
r.kind == "resolution" and r.invoice_id == invoice_id and r.level == e.level for r in events)]
def suppression(events: Iterable[Event], invoice_id: str, today: date) -> Optional[str]:
"""Why reminders for this invoice are suppressed today: a promise until its date (inclusive), or an open dispute."""
events = list(events)
disputes = open_disputes(events, invoice_id)
if disputes:
return f"an open dispute: {disputes[-1].reason}"
promised = [e.promise_date for e in events if e.kind == "promise" and e.promise_date and e.promise_date >= today]
if promised:
return f"a promise until {max(promised)}"
return None
def not_suppressed(events: Iterable[Event], invoice_id: str, today: date) -> Gate:
why = suppression(events, invoice_id, today)
if why:
return unmet("not_suppressed", f"{why[0].upper()}{why[1:]} suppresses reminders",
"Resolve the dispute, or wait for the promise date")
return met("not_suppressed")
def next_level(levels: Iterable[Level], events: Iterable[Event], invoice_id: str, due_date: date,
today: date) -> Optional[Level]:
"""The level to remind at now: the highest level that is due and above every level this invoice already has
(a missed pass does not send three letters at once). None when nothing is due or all levels are used."""
events = list(events)
reached = max((e.level for e in events if e.kind == "reminder" and e.invoice_id == invoice_id), default=0)
due = [lv for lv in levels if lv.mode != "off" and lv.level > reached and level_due(lv, due_date, today).met]
return max(due, key=lambda lv: lv.level) if due else None
def state_of(*, outstanding: Decimal, due_date: Optional[date], today: date, events: Iterable[Event], invoice_id: str) -> str:
"""overdue | reminded | promised | disputed | resolved | not_due - derived."""
if outstanding <= ZERO:
return "resolved"
if due_date is None or due_date >= today:
return "not_due"
events = list(events)
why = suppression(events, invoice_id, today)
if why and why.startswith("an open dispute"):
return "disputed"
if why:
return "promised"
return "reminded" if any(e.kind == "reminder" and e.invoice_id == invoice_id for e in events) else "overdue"