dodil/erp-brick-expenses
DODIL ERP brick: expenses
src/brick_expenses/domain/values.py
"""The settings a customer edits (dated rows in exp_settings) and what their defaults are. Code never knows a country:
the business date is read in `business_timezone`, the threshold above which finance looks is a row."""
# key -> (default, what it changes): the spec's config_rows.
SETTING_DEFS = {
"finance_approval_threshold": ("500.00", "claims above this go to finance after the manager; exp:settings:controls"),
"receipt_required_above": ("10.00", "a receipt line above this needs an image; exp:settings:controls"),
"leaver_claim_days": ("90", "days after the last day a leaver may still submit"),
"business_timezone": ("Europe/London", "the zone 'today' is counted in; exp:settings:controls"),
"card_receipt_days": ("14", "days before an unmatched card transaction is chased"),
"approval_reminder_days": ("5", "business days before an approver is reminded"),
"reimburse_with": ("nett", "nett (personal card spend netted from the payout) | separate"),
"duplicate_threshold": ("0.92", "image similarity above which a receipt is flagged"),
"delegation_allowed": ("true", "managers may delegate approval while absent; exp:settings:controls"),
"receipt_retention_years": ("6", "years a receipt image is kept after the date of the cost (UK 6, DE 10)"),
"home_country": ("GB", "the country whose mileage and daily allowance rates apply when a line names none (ISO 3166-1 alpha-2)"),
"ai_read_receipt_mode": ("suggest", "off | suggest: whether the receipt reader may suggest a line's fields (after the employee consented); exp:settings:controls"),
"ai_explain_policy_warning_mode": ("off", "off | suggest: whether an approver is shown why a line is unusual; exp:settings:controls"),
"finance_reminder_email": ("", "the address a reminder for a claim waiting at finance's level goes to (empty: none is sent, the pass names it)"),
"recovery_repay_days": ("30", "days a personal card transaction waits for a reimbursement to net against before finance asks for repayment"),
}
# key -> how its value is read: decimal (zero or more) | int (zero or more) | bool | enum:a|b | zone (an IANA time zone) | country (ISO 3166-1 alpha-2)
SETTING_KINDS = {
"finance_approval_threshold": "decimal", "receipt_required_above": "decimal", "leaver_claim_days": "int", "business_timezone": "zone",
"card_receipt_days": "int", "approval_reminder_days": "int", "reimburse_with": "enum:nett|separate", "duplicate_threshold": "ratio",
"delegation_allowed": "bool", "receipt_retention_years": "int", "home_country": "country", "ai_read_receipt_mode": "enum:off|suggest",
"ai_explain_policy_warning_mode": "enum:off|suggest", "recovery_repay_days": "int", "finance_reminder_email": "email",
}
# The settings only the owner changes (exp:settings:controls): who looks at a claim, whether a receipt is needed, who may delegate, the clock.
CONTROL_SETTINGS = ("finance_approval_threshold", "receipt_required_above", "delegation_allowed", "business_timezone", "reimburse_with",
"ai_read_receipt_mode", "ai_explain_policy_warning_mode")
# The categories every company starts with (company_code "*" = all companies; a company's own row of the same code wins, and of one code the row
# with the latest valid_from on the line's date wins). gl_purpose is a PURPOSE gl maps to an account, never an account number typed here;
# tax_category is tax's (entertainment is the one whose VAT is blocked). Rows are appended by migrations, never edited: a correction is a row
# with a later valid_from. (code, name, gl_purpose, tax_category, receipt_required, kind, valid_from)
DEFAULT_CATEGORIES = (
("TRAVEL_RAIL", "Train and bus", "travel", "zero", True, "receipt", "2000-01-01"),
("TRAVEL_AIR", "Flights", "travel", "standard", True, "receipt", "2000-01-01"),
("ACCOMMODATION", "Hotels", "accommodation", "standard", True, "receipt", "2000-01-01"),
("MEALS_CLIENT", "Meals with clients", "entertainment", "standard", True, "receipt", "2000-01-01"),
("MEALS_STAFF", "Meals while travelling", "meals", "standard", True, "receipt", "2000-01-01"),
("OFFICE", "Office supplies", "office_supplies", "standard", True, "receipt", "2000-01-01"),
("ALCOHOL", "Alcohol", "entertainment", "standard", True, "receipt", "2000-01-01"), # 00003: a policy rule bans it (EXP-07)
("MEALS_CLIENT", "Meals with clients", "entertainment", "entertainment", True, "receipt", "2000-01-02"), # 00005: business entertainment (EXP-11)
("MILEAGE", "Mileage in your own vehicle", "mileage", "outside_scope", False, "mileage", "2000-01-01"), # 00005: EXP-03
("PER_DIEM", "Daily allowance", "per_diem", "outside_scope", False, "per_diem", "2000-01-01"), # 00005: EXP-04
)
# The statutory rates every company starts with, as dated rows in exp_rate (a company edits them by adding a row; code never knows a country).
# (kind, country, variant, band, valid_from, rate_amount, currency, band_upto, unit, partial_day_amount, breakfast_pct, lunch_pct, dinner_pct, year_start, source)
UK_MILEAGE = "HMRC approved mileage allowance payments, ITEPA 2003 ss. 229-236"
DE_PER_DIEM = "s. 9 (4a) EStG (Verpflegungsmehraufwand); BMF"
DEFAULT_RATES = (
("mileage", "GB", "car", 1, "2011-04-06", "0.4500", "GBP", "10000", "mile", None, "0", "0", "0", "04-06", UK_MILEAGE),
("mileage", "GB", "car", 2, "2011-04-06", "0.2500", "GBP", None, "mile", None, "0", "0", "0", "04-06", UK_MILEAGE),
("mileage", "GB", "motorcycle", 1, "2011-04-06", "0.2400", "GBP", None, "mile", None, "0", "0", "0", "04-06", UK_MILEAGE),
("mileage", "GB", "bicycle", 1, "2011-04-06", "0.2000", "GBP", None, "mile", None, "0", "0", "0", "04-06", UK_MILEAGE),
("mileage", "DE", "car", 1, "2001-01-01", "0.3000", "EUR", None, "km", None, "0", "0", "0", "01-01", "s. 9 (1) no. 4a EStG (0.30 EUR a kilometre by car)"),
("per_diem", "DE", "domestic", 1, "2025-01-01", "28.0000", "EUR", None, "", "14.0000", "20", "40", "40", "01-01", DE_PER_DIEM),
)
def read_setting(key: str, raw: str) -> str:
"""The value as stored, or ValueError naming what is wrong: every setting is bounded, so a mistyped one is a 422, never a bad day at submission."""
kind = SETTING_KINDS[key]
v = str(raw).strip()
if kind == "bool":
if v.lower() not in ("true", "false"):
raise ValueError(f"{key} is true or false")
return v.lower()
if kind.startswith("enum:"):
allowed = kind[5:].split("|")
if v not in allowed:
raise ValueError(f"{key} is {' or '.join(allowed)}")
return v
if kind == "zone":
from zoneinfo import ZoneInfo
try:
ZoneInfo(v)
except Exception:
raise ValueError(f"{v!r} is not a time zone such as Europe/London") from None
return v
if kind == "email":
if v and ("@" not in v or " " in v or len(v) > 254):
raise ValueError("an email address such as finance@company.example (or empty)")
return v.lower()
if kind == "country":
if len(v) != 2 or not v.isalpha() or not v.isascii():
raise ValueError("a country is two letters such as GB")
return v.upper()
from decimal import Decimal, InvalidOperation
try:
d = Decimal(v)
except InvalidOperation:
raise ValueError(f"{key} is a number") from None
if not d.is_finite() or d < 0:
raise ValueError(f"{key} is zero or more")
if kind == "int":
if d != d.to_integral_value() or d > 3650:
raise ValueError(f"{key} is a whole number of at most 3650")
return str(int(d))
if kind == "ratio":
if d > 1:
raise ValueError(f"{key} is between 0 and 1")
return f"{d:.4f}".rstrip("0").rstrip(".") or "0"
if d.as_tuple().exponent < -2 or d >= Decimal("1e13"):
raise ValueError(f"{key} is an amount with at most two decimal places, below 10,000,000,000,000")
return f"{d:.2f}"