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