dodil/erp-brick-tax

DODIL ERP brick: tax

src/brick_tax/application/inputs.py

"""The bounded inputs of this brick's use cases (BRICKS.md: an unbounded int or str is a promise the engine cannot keep).
Money is an exact decimal with at most two places, as a string on the wire; a rate (a percentage, a unit price) has up to four."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional, Union

from erp_platform.types import IsoDate, Name, Note, text
from pydantic import AfterValidator, BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema

Code = text(64, min_length=1)
Key = text(64, min_length=1)
Reason = text(500)
Currency = text(3, min_length=3)
Supply = Literal["goods", "services", "any"]
Consumption = Literal["eat_in", "takeaway", "any"]


def _decimal(v: Any, what: str, places: int) -> Decimal:
    if isinstance(v, (bool, float)):
        raise ValueError(f"{what} is an exact number: give it as a string such as \"120.00\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a number") from None
    if not d.is_finite():
        raise ValueError(f"{what} is a finite number")
    if d.as_tuple().exponent < -places:
        raise ValueError(f"{what} has at most {places} decimal places")
    return d


def _money(v: Any) -> Decimal:
    d = _decimal(v, "an amount", 2)
    if abs(d) >= Decimal("1e15"):
        raise ValueError("an amount is below 1,000,000,000,000,000")
    return d


def _pct(v: Any) -> Decimal:
    d = _decimal(v, "a percentage", 4)
    if not (Decimal(0) <= d <= Decimal(100)):
        raise ValueError("a percentage is between 0 and 100")
    return d


def _rate(v: Any) -> Decimal:
    d = _decimal(v, "a unit price or quantity", 4)
    if not (Decimal(0) <= d < Decimal("1e14")):
        raise ValueError("a unit price or quantity is zero or more, and below 100,000,000,000,000")
    return d


def _fx(v: Any) -> Decimal:
    d = _decimal(v, "an exchange rate", 8)
    if not (Decimal(0) < d < Decimal("1e6")):
        raise ValueError("an exchange rate is above zero")
    return d


def _non_negative(v: Decimal) -> Decimal:
    if v < 0:
        raise ValueError("an amount of VAT is not negative")
    return v


def _exact(pattern: str, description: str):
    return WithJsonSchema({"type": "string", "pattern": pattern, "description": description})


Money = Annotated[Decimal, BeforeValidator(_money), _exact(r"^-?[0-9]{1,15}(\.[0-9]{1,2})?$", "an amount, an exact decimal with up to 2 places (\"120.00\")")]
NonNegMoney = Annotated[Decimal, BeforeValidator(_money), AfterValidator(_non_negative), _exact(r"^[0-9]{1,15}(\.[0-9]{1,2})?$", "a non-negative amount, an exact decimal with up to 2 places (\"120.00\")")]
Pct = Annotated[Decimal, BeforeValidator(_pct), _exact(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "a percentage as an exact decimal (\"20\" is 20%)")]
Rate = Annotated[Decimal, BeforeValidator(_rate), _exact(r"^[0-9]{1,14}(\.[0-9]{1,4})?$", "an exact decimal with up to 4 places")]
Fx = Annotated[Decimal, BeforeValidator(_fx), _exact(r"^[0-9]{1,6}(\.[0-9]{1,8})?$", "sterling per unit of the document's currency")]


def day(v: Optional[str]) -> Optional[date]:
    return None if v in (None, "") else date.fromisoformat(v)


class _In(BaseModel):
    model_config = ConfigDict(extra="forbid")


class LineIn(_In):
    """One line of a quote, order, invoice or credit note: what it is, what it is worth, and how it is taxed in the abstract."""
    line_no: Annotated[int, Field(ge=1, le=99999)]
    description: Optional[text(500)] = None
    category_code: Optional[Key] = None            # the product's tax category (how it is taxed, in the abstract)
    supply: Supply = "any"                         # goods | services: the place-of-supply distinction a rule may make
    amount: Money                                  # net, or gross where the prices include VAT
    quantity: Optional[Rate] = None
    unit_price: Optional[Rate] = None
    pinned_from: Optional[text(200, min_length=1)] = None   # v4: an earlier FROZEN document (a deposit invoice) whose code and rate this line takes
    tax_code: Optional[Key] = None                          # v4: with pinned_from, the code of that document the line reverses
    consumption: Optional[Literal["eat_in", "takeaway"]] = None      # v6 (TAX-51): where the rules tell them apart, a line must say which


class AddressIn(_In):
    """The one postal address shape between modules (erp-method shared.yaml conventions.address)."""
    lines: Annotated[list[Annotated[str, Field(min_length=1, max_length=70)]], Field(min_length=1, max_length=4)]
    city: Annotated[str, Field(min_length=1, max_length=70)]
    postcode: Optional[Annotated[str, Field(max_length=16)]] = None
    region: Optional[Annotated[str, Field(max_length=70)]] = None
    country: Annotated[str, Field(pattern=r"^[A-Z]{2}$")]

    def one_line(self) -> str:
        return ", ".join(p for p in (*self.lines, self.city, self.postcode or "", self.region or "", self.country) if p)


class ContentsIn(_In):
    """The invoice facts that are not the tax's own (VAT Notice 700 para 16.3.1)."""
    title: Optional[Name] = None
    invoice_number: Optional[Name] = None
    issue_date: Optional[IsoDate] = None
    supplier_name: Optional[Name] = None
    supplier_address: Optional[Union[AddressIn, Note]] = None      # v6: the address object; a string is accepted until the next major as one line
    supplier_vat_number: Optional[text(32)] = None
    customer_name: Optional[Name] = None
    customer_address: Optional[Note] = None
    cash_discount_applies: bool = False
    cash_discount_pct: Optional[Pct] = None
    simplified: bool = False


class OdooDoorIn(_In):
    """What the customer answered at the door (module.yaml migration.odoo.door). Nothing is written until `confirm` is true:
    without it the migration returns the plan (the codes and treatments it proposes) and writes nothing."""
    odoo_db: Name
    company_id: Annotated[int, Field(ge=1)]
    registration_from: IsoDate                       # the HMRC effective date of registration (Odoo stores none)
    cutover_date: IsoDate                            # the first tax point this module taxes; migrated rates and rules start here
    confirm: bool = False
    code_map: dict[Key, Key] = Field(default_factory=dict)          # Odoo tax id -> our tax code (overrides the proposal)
    treatment_map: dict[Key, Key] = Field(default_factory=dict)     # Odoo fiscal position id -> treatment code
    rounding_method: Optional[Literal["per_line_nearest", "per_line_down_0_1p", "per_invoice_round_down"]] = None   # where Odoo used round_globally
    archived: Literal["retired_codes"] = "retired_codes"           # archived taxes a posted invoice used become retired codes
    missing_vat_to: Optional[Key] = None             # a treatment (without a VAT number) for customers whose position needs one but who have none
    window_from: Optional[IsoDate] = None            # the reconcile window (default: 12 months before the cutover)
    window_to: Optional[IsoDate] = None