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"