dodil/erp-brick-ar

DODIL ERP brick: accounts receivable

src/brick_ar/domain/values.py

"""Value objects: lifecycle states, the fixed sets of values, and the settings (module.yaml config_rows).

Stored as plain strings; naming them here makes a typo a NameError, not a new state.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional


class InvoiceState(str, Enum):
    DRAFT = "draft"
    ISSUED = "issued"
    VOID = "void"
    CANCELLED = "cancelled"


class InvoiceKind(str, Enum):
    REGULAR = "regular"
    DEPOSIT = "deposit"
    FINAL = "final"
    INTEREST = "interest"


class SourceKind(str, Enum):
    SALES_ORDER = "sales_order"
    SCHEDULE = "schedule"
    ACCEPTANCE = "acceptance"
    MANUAL = "manual"
    CONTRACT = "contract"
    ODOO = "odoo"
    PAYMENT_REQUEST = "payment_request"


# A source's invoice id starts with its prefix ("so:<order>:<request_no>", "rec:<schedule>:<period>", "acc:<item>"):
# a contract adapter names its own ("<adapter>:<source>"), and a hand-raised or migrated invoice is not keyed by a source.
ID_PREFIX = {"sales_order": "so:", "schedule": "rec:", "acceptance": "acc:", "expense": "exp:", "pos": "pos:"}


class RequestState(str, Enum):
    DRAFT = "draft"
    ISSUED = "issued"
    SETTLED = "settled"
    CANCELLED = "cancelled"


class CreditState(str, Enum):
    DRAFT = "draft"
    APPROVED = "approved"
    CANCELLED = "cancelled"


class WriteoffState(str, Enum):
    PROPOSED = "proposed"
    APPROVED = "approved"
    REJECTED = "rejected"


class ScheduleState(str, Enum):
    ACTIVE = "active"
    FINISHED = "finished"
    STOPPED = "stopped"


class RunState(str, Enum):
    LOADED = "loaded"
    RECONCILED = "reconciled"
    SIGNED = "signed"
    DISCARDED = "discarded"


class Decision(str, Enum):
    OPEN = "open"
    ACCEPTED = "accepted"
    DISMISSED = "dismissed"


BILLING_KINDS = ("recurring", "instalment", "usage")
PROVISION_STATES = ("proposed", "approved", "rejected")
EINVOICE_STATUSES = ("delivered", "AB", "IP", "UQ", "CA", "RE", "AP", "PD")
PURPOSES = ("receivable", "revenue", "tax", "bad_debt", "fx_gain", "fx_loss", "deposit")
LEDGER_MODES = ("post", "export", "off")
SOURCES = ("sales_order", "schedule", "acceptance", "contract", "manual", "expense", "pos")
AI_STEPS = ("ai_check_draft", "billing_inquiry_reply")

# key -> (kind, default, control, choices). A control is changed with ar:settings:controls (the owner's), never with
# ar:settings:write: it is a rule on the people who invoice and approve.
SETTINGS = {
    "business_timezone": ("str", "Europe/London", True, ()),
    "invoice_number_format": ("str", "INV-{yyyy}-{seq:4}", True, ()),
    "number_restarts_yearly": ("bool", True, True, ()),
    "ledger_mode": ("str", None, True, LEDGER_MODES),
    "export_format": ("str", "csv", False, ()),
    "auto_issue_sources": ("str", "", True, ()),
    "void_policy": ("str", "never_sent", True, ("never", "never_sent")),
    "second_approver": ("bool", False, True, ()),
    "writeoff_threshold": ("int", 0, True, ()),
    "billing_day": ("str", "anniversary", False, ("anniversary", "month_start")),
    "bill_in_advance": ("bool", True, False, ()),
    "prorate_first_period": ("bool", False, False, ()),
    "document_layout": ("str", "standard", False, ()),
    # the company details a VAT invoice shows (VAT Notice 700 para 16.3.1): tax's content check asks for them at issue
    "supplier_name": ("str", "", False, ()),
    "supplier_address": ("str", "", False, ()),
    # the currency the books are kept in: an invoice in any other currency is FOREIGN and needs its rate (fx_rate gate)
    "functional_currency": ("str", "GBP", True, ()),
    # v4
    "deposit_mode": ("str", "invoice", True, ("invoice", "request")),
    "settlement_discount_vat": ("str", "credit_note", True, ("credit_note", "invoice_wording")),
    "late_interest_mode": ("str", "suggest", True, ("off", "suggest")),
    "default_company_code": ("str", "", True, ()),
    # v4 slice B
    "defer_service_revenue": ("bool", True, True, ()),           # a switch takes effect from the first day of a later month (AR-46)
    "einvoice_provider": ("str", "", False, ()),                 # the access-point provider composed for Peppol; empty = no e-invoicing
    "einvoice_sender_id": ("str", "", False, ()),                # our own Peppol participant id (scheme:id, BT-34), the sender
    "supplier_country": ("str", "GB", False, ()),                # ISO 3166 alpha-2 of our address (BT-40)
    "cost_centre_required": ("bool", False, True, ()),           # the ledger requires a cost centre on revenue lines (AR-47; gl refuses a line without)
    "ai.ai_check_draft.mode": ("str", "suggest", True, ("off", "suggest")),
    "ai.billing_inquiry_reply.mode": ("str", "suggest", True, ("off", "suggest")),
}
CONTROL_KEYS = frozenset(k for k, v in SETTINGS.items() if v[2])
FIRST_OF_MONTH_KEYS = frozenset({"ledger_mode", "defer_service_revenue"})     # a switch takes effect from the first day of a LATER month


def check_setting(key: str, value) -> object:
    """The value as the setting's kind, or ValueError in the words a person reads."""
    if key not in SETTINGS:
        raise ValueError(f"there is no setting {key}")
    kind, _default, _control, choices = SETTINGS[key]
    if value is None:
        if key == "ledger_mode":
            return None
        raise ValueError(f"{key} needs a value")
    if kind == "bool":
        if not isinstance(value, bool):
            raise ValueError(f"{key} is true or false")
    elif kind == "int":
        if isinstance(value, bool) or not isinstance(value, int) or value < 0:
            raise ValueError(f"{key} is a whole number, zero or more")
    elif not isinstance(value, str):
        raise ValueError(f"{key} is text")
    if choices and value not in choices:
        raise ValueError(f"{key} is one of {', '.join(choices)}")
    if key == "business_timezone":
        from zoneinfo import ZoneInfo
        try:
            ZoneInfo(value)
        except Exception:
            raise ValueError(f"{value} is not an IANA time zone such as Europe/London") from None
    if key == "functional_currency" and not (len(value) == 3 and value.isascii() and value.isalpha() and value.isupper()):
        raise ValueError(f"{value!r} is not an ISO 4217 currency code such as GBP")
    if key == "invoice_number_format":
        from brick_ar.domain import numbering
        numbering.check_pair(value, True)
    if key == "auto_issue_sources":
        bad = [s for s in (x.strip() for x in value.split(",")) if s and s not in SOURCES]
        if bad:
            raise ValueError(f"auto_issue_sources names {', '.join(SOURCES)}, not {bad[0]}")
    return value


@dataclass(frozen=True)
class Settings:
    """The settings in force on one day (domain code never reads a table)."""
    values: dict

    @classmethod
    def of(cls, rows: Optional[dict] = None) -> "Settings":
        merged = {k: v[1] for k, v in SETTINGS.items()}
        merged.update(rows or {})
        return cls(merged)

    def __getattr__(self, name):
        try:
            return object.__getattribute__(self, "values")[name]
        except KeyError:
            raise AttributeError(name) from None

    def mode(self, step: str) -> str:
        return self.values[f"ai.{step}.mode"]

    @property
    def auto_issue(self) -> frozenset:
        return frozenset(s.strip() for s in (self.values["auto_issue_sources"] or "").split(",") if s.strip())