dodil/erp-brick-gl

DODIL ERP brick: general ledger

src/brick_gl/domain/values.py

"""The words of the books: codes, enums and the settings a customer may turn."""
import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional

COMPANY_CODE = re.compile(r"^[A-Z0-9]{1,4}$")                  # shared.yaml keys.company_code
LEDGER_CODE = re.compile(r"^[A-Z0-9]{2,8}$")                   # keys.ledger_id
CURRENCY = re.compile(r"^[A-Z]{3}$")
CODE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$")     # an account code, a profit centre, a purpose qualifier

DEFAULT_LEDGER = "LEAD"        # a leading ledger is created with the first company when none was named
BOOKS = ("sales", "purchases", "cash", "misc", "fx", "accrual", "allocation", "adjustment", "opening", "migration")
ACCOUNT_TYPES = ("asset", "liability", "equity", "income", "expense")
SUBTYPES = ("receivable", "payable", "bank", "tax", "retained_earnings", "asset_cost", "accumulated_depreciation", "ic", "other")
BALANCE_SHEET = ("asset", "liability", "equity")
FIELD_STATUS = ("required", "optional", "forbidden")
REVAL_METHODS = ("closing_rate", "losses_only")
MOVEMENT_TYPES = ("addition", "depreciation", "disposal", "transfer", "revaluation", "impairment", "opening")
RATE_TYPES = ("spot", "average", "closing")
LEDGER_KINDS = ("leading", "non_leading")
CHART_KINDS = ("operational", "group")

# The purposes the composed modules post (GL-12). Open-ended: a module may name another; a purpose with no row in force
# is refused naming it. `qualifier` is the tax_code, bank_account_id, partner company or asset class.
PURPOSES = ("receivable", "payable", "revenue", "deposit", "tax", "bank", "bad_debt", "fx_gain", "fx_loss", "rounding",
            "retained_earnings", "split_clearing", "ic_receivable", "ic_payable", "accrual_balance", "prepaid",
            "deferred_revenue", "assessment", "asset_cost", "accumulated_depreciation", "depreciation_expense",
            "asset_clearing", "disposal_gain", "disposal_loss", "vat_control", "transfer_clearing", "cash_over_short",
            # v7 (GL-30, wave 3): what payments, pos, expenses, manufacturing and inventory's source movements post
            "cash_in_transit", "card_clearing", "card_fees", "card_claim", "employee_payable", "voucher_liability", "tips_payable",
            "pos_cash_in_till", "pos_cash_in_safe", "shop_supplies", "tax_rounding", "card_liability", "employee_receivable", "per_diem",
            "mileage", "travel", "accommodation", "meals", "entertainment", "training", "office_supplies", "telephone", "other_expense",
            "wip", "activity_absorption", "abnormal_scrap", "material_usage_variance", "activity_variance", "cost_of_sales",
            "stock_valuation")
# purposes whose accounts are kept by their module: a manual journal on them is refused (source_only_account)
SOURCE_ONLY_SUBTYPES = ("receivable", "payable", "asset_cost", "accumulated_depreciation")


class JournalState(str, Enum):
    PROPOSED = "proposed"
    POSTED = "posted"
    REJECTED = "rejected"
    REVERSED = "reversed"


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


# key -> (kind, default, control, choices, scope). A control is changed with gl:settings:controls (the owner's) and never
# with gl:settings:write: it is a rule on the people who keep the books. scope "company" settings may be set per company.
SETTINGS = {
    "business_timezone": ("str", "Europe/London", True, (), "bucket"),
    "functional_currency": ("str", "GBP", True, (), "bucket"),
    "financial_year_end_month": ("int", 12, True, (), "bucket"),
    "document_splitting": ("str", "off", True, ("off", "on"), "company"),
    "allocation_cycle_approval": ("bool", True, True, (), "bucket"),
    "close_cockpit": ("bool", False, True, (), "company"),
    "statutory_tax_rate": ("str", None, True, (), "company"),
    "ic_match_required": ("str", "refuse", True, ("refuse", "warn", "off"), "bucket"),
    "ic_tolerance": ("str", "1.00", False, (), "bucket"),
    "show_result_in_equity": ("bool", False, False, (), "bucket"),
    "manual_journal_approval": ("str", "above_threshold", True, ("none", "above_threshold", "always"), "bucket"),
    "manual_journal_threshold": ("int", 0, True, (), "bucket"),
    "reopen_second_person": ("bool", True, True, (), "bucket"),
    "gap_free_numbers": ("bool", True, True, (), "bucket"),
    "revaluation": ("bool", False, False, (), "company"),
    "soft_close": ("bool", False, True, (), "bucket"),
    "ai.ai_check_journal.mode": ("str", "suggest", True, ("off", "suggest"), "bucket"),
    "ai.explain_reconciliation_break.mode": ("str", "suggest", True, ("off", "suggest"), "bucket"),
}
CONTROL_KEYS = frozenset(k for k, v in SETTINGS.items() if v[2])


def check_setting(key: str, value, scope: str = "") -> 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, where = SETTINGS[key]
    if scope and where != "company":
        raise ValueError(f"{key} is one value for the whole bucket, not per company")
    if value is 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 or value > 10 ** 12:
            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 CURRENCY.match(value):
        raise ValueError(f"{value!r} is not an ISO 4217 currency code such as GBP")
    if key == "financial_year_end_month" and not 1 <= value <= 12:
        raise ValueError("financial_year_end_month is a month, 1 to 12")
    if key == "statutory_tax_rate":
        from decimal import Decimal, InvalidOperation
        try:
            if not 0 <= Decimal(value) <= 1 or Decimal(value).as_tuple().exponent < -6:
                raise ValueError
        except (InvalidOperation, ValueError):
            raise ValueError("statutory_tax_rate is a decimal string between 0 and 1, such as 0.25 for 25%") from None
    if key == "ic_tolerance":
        from decimal import Decimal, InvalidOperation
        try:
            if Decimal(value) < 0 or Decimal(value) >= Decimal("1e16"):
                raise ValueError
        except (InvalidOperation, ValueError):
            raise ValueError("ic_tolerance is an amount such as 1.00") from None
    return value


@dataclass(frozen=True)
class Settings:
    """The settings in force on one day for one company (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 get(self, key: str):
        return self.values[key]

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