dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
src/brick_ar/domain/credit_note.py
"""The CreditNote aggregate: a tax document reducing what a customer owes. An issued invoice is never edited; this is the path.
It may name no invoice (a goodwill credit, or one spanning several). Approving numbers it, dates it today and freezes its tax;
what it reduces is settled by payments' allocation, never by a field here.
"""
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.invoice import TaxRow
from brick_ar.domain.money import ZERO, money
from brick_ar.domain.readiness import Gate, met, require, unmet
from brick_ar.domain.tax import TaxQuote
from brick_ar.domain.values import CreditState
@dataclass
class CreditNote:
credit_id: str
bp_id: int = 0
invoice_id: Optional[str] = None
credit_no: str = ""
series_id: str = ""
credit_date: Optional[date] = None
currency: str = ""
reason: str = ""
status: str = CreditState.DRAFT.value
subtotal: Decimal = ZERO
tax_total: Decimal = ZERO
total: Decimal = ZERO
source_kind: str = "manual"
drafted_by: str = ""
drafted_by_sub: str = "" # ... and their sub: the same person under either identity is the same person
approved_by: str = ""
approved_at: Optional[datetime] = None
export_batch_id: str = ""
migration_run_id: str = ""
payment_id: str = "" # the receipt a settlement discount was taken on (v4)
company_code: str = ""
lines: list = field(default_factory=list)
tax: list = field(default_factory=list)
events: list = field(default_factory=list, repr=False, compare=False)
def _record(self, event):
self.events.append(event)
return event
@classmethod
def draft(cls, credit_id: str, *, by: str, bp_id: int, currency: str, reason: str, lines: list,
invoice_id: Optional[str] = None, by_sub: str = "") -> "CreditNote":
if not (reason or "").strip():
raise RuleBroken("a credit note needs a reason")
cn = cls(credit_id=credit_id, bp_id=bp_id, invoice_id=invoice_id, currency=currency.upper(), reason=reason.strip(),
drafted_by=by,
drafted_by_sub=(by_sub or "").strip().lower(), lines=list(lines))
cn._price()
return cn
def _price(self) -> None:
for i, ln in enumerate(self.lines, start=1):
ln.line_no = ln.line_no or i
ln.price()
self.subtotal = sum((ln.net_amount for ln in self.lines), ZERO)
self.tax_total = sum((r.tax_amount for r in self.tax), ZERO)
self.total = self.subtotal + self.tax_total
def _assert_draft(self) -> None:
if self.status == CreditState.APPROVED.value:
raise RuleBroken(f"credit note {self.credit_no or self.credit_id} is approved and cannot be edited")
if self.status == CreditState.CANCELLED.value:
raise RuleBroken(f"credit note {self.credit_id} was cancelled")
def apply_tax(self, quote: TaxQuote) -> None:
by_line = {t.line_no: t for t in quote.lines}
for ln in self.lines:
ln.price()
t = by_line.get(ln.line_no)
if t is None:
raise RuleBroken(f"tax did not price line {ln.line_no}")
ln.tax_code, ln.tax_amount = t.tax_code, money(t.tax_amount)
self.tax = [TaxRow(r.tax_code, r.rate_pct, money(r.net_amount), money(r.tax_amount), quote.rounding_method)
for r in quote.summary]
self._price()
def gates(self, *, remaining_creditable: Optional[Decimal], second_approver: bool, approver: str,
identities: tuple = (), invoice_lifecycle: str = "issued") -> list:
"""approve_credit's gates as {gate, met, reason, fix}."""
out = []
if self.invoice_id and invoice_lifecycle != "issued":
out.append(unmet("invoice_issued", f"the invoice it corrects is {invoice_lifecycle}, not issued",
"a void or cancelled invoice has nothing left to credit: cancel this draft"))
out += [met("has_lines") if self.lines else unmet("has_lines", "the credit note has no lines",
"add a line, or credit the invoice in full")]
if self.invoice_id is None or remaining_creditable is None:
out.append(met("within_invoice"))
elif self.total <= remaining_creditable:
out.append(met("within_invoice"))
else:
out.append(unmet("within_invoice", f"it exceeds the invoice total less what is already credited "
f"({self.total} against {remaining_creditable} creditable)",
"lower the amount, or credit the customer as goodwill (no invoice)"))
if second_approver and self.drafted_by_me(approver, identities):
out.append(unmet("second_person", "you drafted it, and the owner asks for a second person", "ask another approver"))
else:
out.append(met("second_person"))
return out
def drafted_by_me(self, by: str, identities: tuple = ()) -> bool:
"""The caller drafted it if ANY of their identities (email, sub) is any of the drafter's."""
mine = {x.strip().lower() for x in (by, *identities) if x and x.strip()}
theirs = {x.strip().lower() for x in (self.drafted_by, self.drafted_by_sub) if x and x.strip()}
return bool(mine & theirs)
def approve(self, *, today: date, number: str, series_id: str, by: str, at: datetime, quote: Optional[TaxQuote],
gates: list) -> None:
self._assert_draft()
require("approve the credit note", gates)
if quote is not None:
self.apply_tax(quote)
self.credit_no, self.series_id, self.credit_date = number, series_id, today
self.status, self.approved_by, self.approved_at = CreditState.APPROVED.value, by, at
self._record(events.CreditNoteApproved(by=by, credit_id=self.credit_id, credit_no=number,
invoice_id=self.invoice_id, total=self.total))
def cancel(self, *, by: str) -> None:
if self.status == CreditState.CANCELLED.value:
return
self._assert_draft()
self.status = CreditState.CANCELLED.value
def gate(ok: bool, name: str, reason: str, fix: str) -> Gate:
return met(name) if ok else unmet(name, reason, fix)