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()