dodil/erp-brick-expenses

DODIL ERP brick: expenses

src/brick_expenses/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 line can be at most 10 trillion so 200 of
them still fit NUMERIC(18,2)."""
import re
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional

from erp_platform.types import text
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema

from brick_expenses.domain.report import LINE_MAX

Code = text(64, min_length=1)
Currency = text(3)                   # the use case says why a code is not an ISO 4217 code (after the permission check)
Title = text(200)
Reason = text(500)
Ref = text(140)
Merchant = text(200)
Note = text(500)
Key = text(300)
City = text(100)
Person = text(100)
Attendees = Annotated[list[Person], Field(max_length=50)]            # the people a meal was for (names)
Nights = Annotated[int, Field(ge=1, le=366)]
Fields = Annotated[list[text(40)], Field(max_length=20)]
# A date given as text, parsed by the use case AFTER the permission gate: a caller without the permission is told so (403),
# not that their date is malformed (422).
Day = Annotated[str, Field(max_length=32), WithJsonSchema({"type": "string", "format": "date", "description": "a date like 2026-11-20"})]
# An amount given as text for the routes only another module calls: the use case answers 422 {gate: bounds}.
AmountText = Annotated[str, Field(max_length=40), WithJsonSchema({"type": "string", "description": "an exact amount like \"287.40\""})]


def parse_money(v: Any, *, zero_ok: bool = False) -> Decimal:
    if isinstance(v, (bool, float)):
        raise ValueError("an amount is an exact number: give it as a string such as \"287.40\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not an amount") from None
    if not d.is_finite():
        raise ValueError("an amount is a finite number")
    if d.as_tuple().exponent < -2:
        raise ValueError("an amount has at most two decimal places")
    if not ((Decimal(0) <= d if zero_ok else Decimal(0) < d) and d < LINE_MAX):
        raise ValueError(f"an amount is {'zero or more' if zero_ok else 'more than zero'} and below 10,000,000,000,000")
    return d


Money = Annotated[Decimal, BeforeValidator(parse_money),
                  WithJsonSchema({"type": "string", "pattern": r"^[0-9]{1,13}(\.[0-9]{1,2})?$", "description": "an exact amount as a string, more than zero"})]
Money0 = Annotated[Decimal, BeforeValidator(lambda v: parse_money(v, zero_ok=True)),
                   WithJsonSchema({"type": "string", "pattern": r"^[0-9]{1,13}(\.[0-9]{1,2})?$", "description": "an exact amount as a string, zero or more"})]


def day(v: Optional[str]) -> date:
    return date.fromisoformat(str(v).strip())


class Address(BaseModel):
    """ONE postal address shape on every wire between modules (shared.yaml conventions.address)."""
    model_config = ConfigDict(extra="forbid")
    lines: Annotated[list[text(70, min_length=1)], Field(min_length=1, max_length=4)]
    city: text(70, min_length=1)
    postcode: Optional[text(16)] = None
    region: Optional[text(70)] = None
    country: Annotated[str, Field(pattern=r"^[A-Z]{2}$")]


class ReceiptContents(BaseModel):
    """What the receipt shows, for tax's check that it is a valid VAT invoice: the supplier, their address and VAT number, the invoice number."""
    model_config = ConfigDict(extra="forbid")
    supplier_name: text(200, min_length=1)
    supplier_address: Address
    supplier_vat_number: Optional[text(30)] = None
    invoice_number: Optional[text(100)] = None
    customer_name: Optional[text(200)] = None


def parse_miles(v: Any) -> Decimal:
    if isinstance(v, (bool, float)):
        raise ValueError("miles are an exact number: give them as a string such as \"300\"")
    if not isinstance(v, Decimal) and not re.fullmatch(r"[0-9]{1,7}(\.[0-9]+)?", str(v).strip()):
        raise ValueError(f"{v!r} is not a number of miles: plain digits such as 300 or 12.5")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a number of miles") from None
    if not d.is_finite() or d.as_tuple().exponent < -4:
        raise ValueError("miles or kilometres have at most four decimal places")
    if not (Decimal(0) < d <= Decimal("100000")):
        raise ValueError("miles or kilometres are more than zero and at most 100,000")
    return d


Miles = Annotated[Decimal, BeforeValidator(parse_miles),
                  WithJsonSchema({"type": "string", "pattern": r"^[0-9]{1,6}(\.[0-9]{1,4})?$", "description": "miles or kilometres, an exact number as a string, up to 4 decimals"})]


def parse_rate(v: Any) -> Decimal:
    if isinstance(v, (bool, float)):
        raise ValueError("a rate is an exact number: give it as a string such as \"0.4500\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a rate") from None
    if not d.is_finite() or d.as_tuple().exponent < -4:
        raise ValueError("a rate has at most four decimal places")
    if not (Decimal(0) <= d < Decimal("1e9")):
        raise ValueError("a rate is zero or more and below a billion")
    return d


RateAmount = Annotated[Decimal, BeforeValidator(parse_rate), WithJsonSchema({"type": "string", "pattern": r"^[0-9]{1,9}(\.[0-9]{1,4})?$",
                                                                             "description": "an exact rate as a string, up to 4 decimals"})]
Percentage = Annotated[Decimal, BeforeValidator(lambda v: parse_rate(v)), Field(le=100),
                       WithJsonSchema({"type": "string", "pattern": r"^(100|[0-9]{1,2})(\.[0-9]{1,4})?$", "description": "a percentage 0 to 100"})]
YearStart = Annotated[str, Field(pattern=r"^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$")]
Country = Annotated[str, Field(pattern=r"^[A-Za-z]{2}$")]
Moment = Annotated[str, Field(max_length=40), WithJsonSchema({"type": "string", "description": "a local date and time like 2026-11-10T07:00"})]


class MealProvided(BaseModel):
    """A meal the employer provided on one day of a trip (hotel breakfast, a conference lunch)."""
    model_config = ConfigDict(extra="forbid")
    date: Day
    meal: Literal["breakfast", "lunch", "dinner"]