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