dodil/erp-brick-tax

DODIL ERP brick: tax

src/brick_tax/domain/values.py

"""Names and defaults of this brick. No database, no HTTP."""
import json
import re
from dataclasses import dataclass
from datetime import date
from enum import Enum
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

FOREVER = date(1900, 1, 1)          # "from the start": the first row of a customer's treatment, a setting's first value
JURISDICTION = "GB"


class Treatment(str, Enum):
    """tax_code.treatment: what the code means."""
    STANDARD = "standard"
    REDUCED = "reduced"
    ZERO = "zero"
    EXEMPT = "exempt"
    OUTSIDE_SCOPE = "outside_scope"
    REVERSE_CHARGE = "reverse_charge"
    NOT_REGISTERED = "not_registered"
    POSTPONED_IMPORT = "postponed_import"


class CodeState(str, Enum):
    DRAFT = "draft"
    ACTIVE = "active"
    RETIRED = "retired"


class RegStatus(str, Enum):
    NOT_REGISTERED = "not_registered"
    REGISTERED = "registered"
    DEREGISTERED = "deregistered"


class Scheme(str, Enum):
    STANDARD = "standard"
    FLAT_RATE = "flat_rate"                 # recorded, not computed (known_gaps)
    CASH_ACCOUNTING = "cash_accounting"     # recorded, not computed


class ChangeState(str, Enum):
    """A dated row's own state. Only an APPROVED row takes effect; a PENDING one waits for a second person; a WITHDRAWN
    one never takes effect (it was withdrawn before its date)."""
    APPROVED = "approved"
    PENDING = "pending"
    WITHDRAWN = "withdrawn"


class Supply(str, Enum):
    GOODS = "goods"
    SERVICES = "services"
    ANY = "any"


class Rounding(str, Enum):
    """The methods HMRC permits (VAT Notice 700 para 17.5 / 17.5.1). Each is code (domain/rounding.py)."""
    PER_LINE_NEAREST = "per_line_nearest"
    PER_LINE_DOWN_0_1P = "per_line_down_0_1p"
    PER_INVOICE_ROUND_DOWN = "per_invoice_round_down"


ROUND_DOWN_METHODS = (Rounding.PER_LINE_DOWN_0_1P.value, Rounding.PER_INVOICE_ROUND_DOWN.value)


class DocState(str, Enum):
    COMPUTED = "computed"
    FROZEN = "frozen"


# A code the customer recognises: S, RC, Z, ZR-EXPORT ...
CODE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,15}$")
KEY = re.compile(r"^[a-z0-9][a-z0-9_]{0,63}$")          # a category / treatment code: standard, uk_business
VAT_NUMBER = re.compile(r"^GB(\d{9}|\d{12})$")


def clean_vat_number(raw: str) -> Optional[str]:
    """GB and 9 or 12 digits, spaces ignored, upper-cased; None when it is not that."""
    v = re.sub(r"\s+", "", raw or "").upper()
    return v if VAT_NUMBER.match(v) else None


# ── settings (module.yaml config_rows → tax_settings: dated rows, one per change) ──────────────────────────────

@dataclass(frozen=True)
class SettingDef:
    key: str
    kind: str                   # str | bool | int | mode
    default: Any
    changes: str
    control: bool = False       # needs tax:settings:controls, refused to tax:settings:write
    choices: tuple = ()


SETTING_DEFS = (
    SettingDef("rounding_method", "str", Rounding.PER_LINE_NEAREST.value,
               "how VAT is rounded: per_line_nearest (para 17.5.1, the default), per_line_down_0_1p (17.5.1), "
               "per_invoice_round_down (17.5)", choices=tuple(m.value for m in Rounding)),
    SettingDef("prices_include_tax", "bool", False,
               "whether a new price list's prices include VAT (each list can say otherwise)"),
    SettingDef("retailer", "bool", False,
               "whether we are a retailer: the round-down methods are then refused (para 17.6)"),
    SettingDef("simplified_invoice_threshold", "int", 250,
               "the GBP total at or below which a simplified invoice is allowed, with the customer's agreement (para 16.6.1)"),
    SettingDef("ai.suggest_code.mode", "mode", "suggest", "the AI code suggestion: off | suggest", choices=("off", "suggest")),
    SettingDef("tax_changes_need_second_person", "bool", False,
               "whether a rate, rule or registration change waits for a second holder of tax:code:approve (TAX-14); "
               "tax:settings:controls", control=True),
    SettingDef("override_approval", "str", "second_person",
               "who approves an override that lowers tax: second_person, or self_with_reason for a firm of one (TAX-06); "
               "tax:settings:controls", control=True, choices=("second_person", "self_with_reason")),
    SettingDef("business_timezone", "str", "Europe/London",
               "the IANA time zone in which 'today' is counted: nothing may start today or earlier in this zone; "
               "tax:settings:controls", control=True),
    SettingDef("vat_return_needs_second_person", "bool", True,
               "v4: whether the person who locked a VAT return may not submit it (identity by email AND sub); a firm of one sets "
               "false; tax:settings:controls", control=True),
    SettingDef("hmrc_environment", "str", "sandbox",
               "v4: sandbox | production: which HMRC base URL is called; production only after the fraud-header test passed; "
               "tax:settings:controls", control=True, choices=("sandbox", "production")),
    SettingDef("vat_number_check_days", "int", 90,
               "v4: a VAT-number check older than this is re-run before the next reverse-charge or overseas document issues"),
    SettingDef("ai.review_vat_return.mode", "mode", "suggest", "v4: the AI review of a draft return: off | suggest", choices=("off", "suggest")),
    SettingDef("ai.check_vat_number.mode", "mode", "suggest", "v4: the AI name comparison on a checked VAT number: off | suggest",
               choices=("off", "suggest")),
    SettingDef("walk_in_treatment_code", "str", "uk_consumer", "v6: the treatment of an anonymous buyer (a till sale with no partner, TAX-52, TAX-53)"),
    SettingDef("cis_contractor_utr", "str", "", "v4: our UTR and accounts office reference as a CIS contractor; empty = we are not a contractor"),
)
SETTINGS = {s.key: s for s in SETTING_DEFS}
CONTROL_KEYS = tuple(s.key for s in SETTING_DEFS if s.control)


def check_setting(key: str, value: Any) -> Optional[str]:
    """Why `value` is not valid for this setting; None when it is (or when it puts the default back)."""
    d = SETTINGS[key]
    if value is None:
        return None
    if d.kind == "bool" and not isinstance(value, bool):
        return "true or false"
    if d.kind == "int" and (isinstance(value, bool) or not isinstance(value, int) or not (0 <= value <= 10_000_000)):
        return "a whole number of pounds, zero or more"
    if d.kind in ("str", "mode") and not isinstance(value, str):
        return "text"
    if d.choices and value not in d.choices:
        return "one of " + ", ".join(d.choices)
    if key == "business_timezone":
        try:
            ZoneInfo(value)
        except (ZoneInfoNotFoundError, ValueError, OSError):
            return "an IANA time zone such as Europe/London"
    return None


def encode_value(value: Any) -> Optional[str]:
    return None if value is None else json.dumps(value)


def decode_value(raw: Optional[str]) -> Any:
    return None if raw is None else json.loads(raw)


@dataclass(frozen=True)
class Settings:
    """The settings as they stood on one date."""
    rounding_method: str = Rounding.PER_LINE_NEAREST.value
    prices_include_tax: bool = False
    retailer: bool = False
    simplified_invoice_threshold: int = 250
    suggest_code_mode: str = "suggest"
    tax_changes_need_second_person: bool = False
    override_approval: str = "second_person"
    business_timezone: str = "Europe/London"
    vat_return_needs_second_person: bool = True
    hmrc_environment: str = "sandbox"
    vat_number_check_days: int = 90
    review_vat_return_mode: str = "suggest"
    check_vat_number_mode: str = "suggest"
    cis_contractor_utr: str = ""
    walk_in_treatment_code: str = "uk_consumer"

    @classmethod
    def from_values(cls, values: dict) -> "Settings":
        """`values`: key -> stored value (None or absent = the default)."""
        def pick(key: str):
            v = values.get(key)
            return SETTINGS[key].default if v is None else v
        return cls(rounding_method=pick("rounding_method"), prices_include_tax=pick("prices_include_tax"),
                   retailer=pick("retailer"), simplified_invoice_threshold=pick("simplified_invoice_threshold"),
                   suggest_code_mode=pick("ai.suggest_code.mode"),
                   tax_changes_need_second_person=pick("tax_changes_need_second_person"),
                   override_approval=pick("override_approval"), business_timezone=pick("business_timezone"),
                   vat_return_needs_second_person=pick("vat_return_needs_second_person"),
                   hmrc_environment=pick("hmrc_environment"), vat_number_check_days=pick("vat_number_check_days"),
                   review_vat_return_mode=pick("ai.review_vat_return.mode"), check_vat_number_mode=pick("ai.check_vat_number.mode"),
                   cis_contractor_utr=pick("cis_contractor_utr"), walk_in_treatment_code=pick("walk_in_treatment_code"))