dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
src/brick_ar/domain/customer.py
"""A customer's billing profile: how a party is billed (bill-to, invoice email, terms, currency), as dated rows.
The party master holds identity; invoicing holds how to bill. The profile in force on a date is the row with the latest
valid_from on or before it; a new row starts after today (a customer's first may start today); rows are never edited.
At issue the profile is COPIED onto the invoice, so a later change never alters an issued invoice.
"""
import json
from dataclasses import dataclass
from datetime import date, datetime
from typing import Optional
from brick_ar.domain.readiness import Gate, met, unmet
@dataclass
class Profile:
bp_id: int
valid_from: date
bill_to_name: str = ""
bill_to_address: str = ""
invoice_email: str = ""
terms_id: str = ""
currency: str = ""
intercompany_company_code: str = "" # the group company this party is (v4)
einvoice_channel: str = "" # email | peppol (v4)
peppol_participant_id: str = "" # scheme:id, e.g. 0088:5060012349998 (v4)
default_cost_centre: str = "" # a revenue line with none takes this (v4)
set_by: str = ""
set_at: Optional[datetime] = None
def missing(self) -> list:
return [n for n, v in (("a bill-to address", self.bill_to_address), ("an invoice email", self.invoice_email),
("payment terms", self.terms_id)) if not v]
def bill_to_json(self) -> str:
out = {"bill_to_name": self.bill_to_name, "bill_to_address": self.bill_to_address,
"invoice_email": self.invoice_email, "valid_from": self.valid_from.isoformat()}
if self.einvoice_channel == "peppol": # frozen with the bill-to: a later profile never re-routes an issued invoice
out.update(einvoice_channel="peppol", peppol_participant_id=self.peppol_participant_id)
return json.dumps(out, sort_keys=True)
def in_force(rows: list, on: date) -> Optional[Profile]:
return max((p for p in rows if p.valid_from <= on), key=lambda p: p.valid_from, default=None)
def profile_gate(profile: Optional[Profile], today: date) -> Gate:
if profile is None:
return unmet("billing_profile", "the customer has no billing profile in force today", "Customer billing (AR-25)")
gone = profile.missing()
if gone:
return unmet("billing_profile", "the customer has no " + ", ".join(gone) + " in force today", "Customer billing (AR-25)")
return met("billing_profile")