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