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)