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