dodil/erp-brick-ar

DODIL ERP brick: accounts receivable

src/brick_ar/domain/writeoff.py

"""The Writeoff aggregate: a balance the business accepts it will not collect.

Proposed with a reason; approved by someone with the approval permission. Above the owner's threshold the approver is asked to
confirm; where the owner asks for a second person, the proposer cannot approve. Approving allocates the amount against the
invoice in payments (like a receipt); the loss posts as bad debt where the ledger is installed.
"""
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Optional

from brick_ar.domain import events
from brick_ar.domain.errors import RuleBroken
from brick_ar.domain.money import ZERO, money
from brick_ar.domain.readiness import met, require, unmet
from brick_ar.domain.values import WriteoffState


@dataclass
class Writeoff:
    writeoff_id: str
    invoice_id: str = ""
    amount: Decimal = ZERO
    reason: str = ""
    status: str = WriteoffState.PROPOSED.value
    proposed_by: str = ""
    proposed_by_sub: str = ""                  # ... and their sub: the same person under either identity is the same person
    writeoff_date: Optional[date] = None
    approved_by: str = ""
    approved_at: Optional[datetime] = None
    rejected_reason: str = ""
    events: list = field(default_factory=list, repr=False, compare=False)

    @classmethod
    def propose(cls, writeoff_id: str, *, by: str, invoice_id: str, amount: Decimal, reason: str,
                by_sub: str = "") -> "Writeoff":
        if not (reason or "").strip():
            raise RuleBroken("a write-off needs a reason")
        if money(amount) <= ZERO:
            raise RuleBroken("a write-off is an amount above zero")
        return cls(writeoff_id=writeoff_id, invoice_id=invoice_id, amount=money(amount), reason=reason.strip(), proposed_by=by,
                   proposed_by_sub=(by_sub or "").strip().lower())

    def gates(self, *, outstanding: Optional[Decimal], threshold: int, confirmed: bool, second_approver: bool,
              approver: str, identities: tuple = ()) -> list:
        out = []
        if outstanding is not None and self.amount > outstanding:
            out.append(unmet("outstanding", f"the amount {self.amount} exceeds what is outstanding on the invoice ({outstanding})",
                             "write off the outstanding amount"))
        else:
            out.append(met("outstanding"))
        if self.amount > Decimal(threshold) and not confirmed:
            out.append(unmet("threshold", f"{self.amount} is above the owner's write-off threshold ({threshold}): an explicit "
                             "confirmation is asked", "confirm, with the reason"))
        else:
            out.append(met("threshold"))
        if second_approver and self.proposed_by_me(approver, identities):
            out.append(unmet("second_person", "you proposed it, and the owner asks for a second person", "ask another approver"))
        else:
            out.append(met("second_person"))
        return out

    def proposed_by_me(self, by: str, identities: tuple = ()) -> bool:
        """The caller proposed it if ANY of their identities (email, sub) is any of the proposer's."""
        mine = {x.strip().lower() for x in (by, *identities) if x and x.strip()}
        theirs = {x.strip().lower() for x in (self.proposed_by, self.proposed_by_sub) if x and x.strip()}
        return bool(mine & theirs)

    def approve(self, *, today: date, by: str, at: datetime, gates: list) -> None:
        self._assert_proposed()
        require("approve the write-off", gates)
        self.status, self.writeoff_date, self.approved_by, self.approved_at = WriteoffState.APPROVED.value, today, by, at
        self.events.append(events.WriteoffApproved(by=by, writeoff_id=self.writeoff_id, invoice_id=self.invoice_id,
                                                   amount=self.amount))

    def reject(self, *, reason: str, by: str, at: datetime) -> None:
        self._assert_proposed()
        if not (reason or "").strip():
            raise RuleBroken("rejecting a write-off needs a reason")
        self.status, self.rejected_reason, self.approved_by, self.approved_at = (WriteoffState.REJECTED.value, reason.strip(), by, at)

    def _assert_proposed(self) -> None:
        if self.status != WriteoffState.PROPOSED.value:
            raise RuleBroken(f"write-off {self.writeoff_id} is already {self.status}")