dodil/erp-brick-ar

DODIL ERP brick: accounts receivable

src/brick_ar/domain/payment_request.py

"""The PaymentRequest aggregate (AR-35, AR-36): a request for money that is NOT a VAT invoice. It posts nothing, sits in no
VAT return and is never owed; the VAT invoice is the deposit invoice raised when the money arrives.

What has been received against it is DERIVED from the settlements (payment_id -> amount) - never a counter here.
"""
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_ar.domain.errors import RuleBroken
from brick_ar.domain.money import ZERO, money
from brick_ar.domain.values import RequestState

TITLES = {"down_payment": "Payment request - this is not a VAT invoice", "pro_forma": "Pro forma invoice - this is not a VAT invoice"}


@dataclass
class PaymentRequest:
    request_id: str
    bp_id: int = 0
    company_code: str = ""
    lifecycle: str = RequestState.DRAFT.value
    kind: str = "down_payment"
    sales_order_id: str = ""
    currency: str = ""
    tax_category: str = "standard"
    net_amount: Decimal = ZERO
    tax_amount_display: Decimal = ZERO
    total: Decimal = ZERO
    request_no: str = ""
    issue_date: Optional[date] = None
    due_date: Optional[date] = None
    deposit_invoice_ids: str = ""                  # comma-separated, in the order they were issued
    cancel_reason: str = ""
    created_by: str = ""
    events: list = field(default_factory=list, repr=False, compare=False)

    @property
    def title(self) -> str:
        return TITLES[self.kind]

    @classmethod
    def raise_new(cls, request_id: str, *, by: str, bp_id: int, currency: str, net_amount: Decimal, tax_amount: Decimal, kind: str,
                  sales_order_id: str = "", company_code: str = "", tax_category: str = "standard") -> "PaymentRequest":
        if kind not in TITLES:
            raise RuleBroken("a payment request is a down_payment or a pro_forma")
        if net_amount <= 0:
            raise RuleBroken("a payment request asks for an amount above zero")
        return cls(request_id=request_id, bp_id=bp_id, currency=currency.upper(), kind=kind, sales_order_id=sales_order_id,
                   net_amount=money(net_amount), tax_amount_display=money(tax_amount), total=money(net_amount + tax_amount),
                   company_code=company_code, tax_category=tax_category, created_by=by)

    def issue(self, *, number: str, today: date, due: date) -> None:
        if self.lifecycle != RequestState.DRAFT.value:
            raise RuleBroken(f"request {self.request_id} is {self.lifecycle}: only a draft is issued")
        self.request_no, self.issue_date, self.due_date = number, today, due
        self.lifecycle = RequestState.ISSUED.value

    def remaining(self, received: Decimal) -> Decimal:
        return self.total - received

    def note_settled(self, deposit_invoice_id: str, received: Decimal) -> None:
        self.deposit_invoice_ids = ",".join([*self.deposit_ids, deposit_invoice_id])
        if self.remaining(received) <= 0:
            self.lifecycle = RequestState.SETTLED.value

    @property
    def deposit_ids(self) -> list:
        return [x for x in self.deposit_invoice_ids.split(",") if x]

    def cancel(self, reason: str, received: Decimal) -> None:
        if self.lifecycle == RequestState.CANCELLED.value:
            return
        if self.lifecycle not in (RequestState.DRAFT.value, RequestState.ISSUED.value):
            raise RuleBroken(f"request {self.request_id} is {self.lifecycle}: it cannot be cancelled")
        if not (reason or "").strip():
            raise RuleBroken("cancelling a payment request needs a reason")
        if received > 0:
            raise RuleBroken(f"{received} {self.currency} was received against request {self.request_no}: it cannot be cancelled")
        self.lifecycle, self.cancel_reason = RequestState.CANCELLED.value, reason.strip()