dodil/erp-platform

src/erp_platform/types.py

"""types — input types with their bounds, written once for every brick's use cases.

A use case's parameter annotations ARE its input contract: both doors build their schema and their
validation from them (erp_platform.bricks). An unbounded `str`, `int` or `Decimal` is a promise the
database cannot keep, and it broke in the CRM audit (2026-10-17) as a 500 instead of a refusal:

    a quantity of 2**31          INTEGER overflow inside the engine
    an amount of 1e20            NUMERIC(18,2) overflow
    a bp_id of 23 digits         BIGINT overflow
    a 200 KB name                the write refused by the engine
    "not-a-date" as a date       a ValueError in the use case

Each of those is the CALLER's mistake and deserves a 422 that says which field and why. These types make
it one through both doors, before the use case runs.

THE 64-BIT ID. `BigId` is a BIGINT key (bp_id): it is accepted as a number OR as a string of digits, and
the schema both doors publish says `type: string, format: int64` — a JavaScript client or an agent cannot
hold an integer past 2**53 as a number, and a rounded id names another customer. A brick that opts in
(`Brick(..., ids_as_strings=True)`) also RETURNS every `*_id` integer as a string (erp_platform.bricks).
"""
import re
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any

from pydantic import AfterValidator, BeforeValidator, Field, WithJsonSchema

INT64_MIN, INT64_MAX = -(2 ** 63), 2 ** 63 - 1


def _big_id(v: Any) -> int:
    if isinstance(v, bool):
        raise ValueError("an id is a whole number, not true/false")
    if isinstance(v, int):
        n = v
    elif isinstance(v, str) and v.strip().lstrip("-").isdigit() and v.strip().isascii():
        n = int(v.strip())
    elif isinstance(v, float) and v.is_integer() and abs(v) < 2 ** 53:
        n = int(v)                           # exact below 2**53; above it the digits were already lost
    else:
        raise ValueError("an id is a whole number, given as digits (a 64-bit integer)")
    if not (INT64_MIN <= n <= INT64_MAX):
        raise ValueError("an id is a 64-bit integer; this one has too many digits")
    return n


BigId = Annotated[int, BeforeValidator(_big_id),
                  WithJsonSchema({"type": "string", "format": "int64", "pattern": r"^-?[0-9]{1,19}$",
                                  "description": "a 64-bit id, as digits (a JSON number is accepted too)"})]


def text(max_length: int, *, min_length: int = 0):
    """A string of at most `max_length` characters (control characters are refused for every string by
    the doors already). `text(200)` for a name, `text(5000)` for a note."""
    return Annotated[str, Field(min_length=min_length, max_length=max_length)]


Name = text(200)              # a name, a subject, a title, a key someone types
Email = text(254)             # RFC 5321's limit on a path
Note = text(10_000)           # free text a person writes


def _bounded_date(v: Any) -> str:
    try:
        d = date.fromisoformat(str(v).strip()[:10])
    except ValueError:
        raise ValueError(f"{v!r} is not a date (YYYY-MM-DD)") from None
    if not (date(1900, 1, 1) <= d <= date(2199, 12, 31)):
        raise ValueError(f"{d.isoformat()} is outside 1900-01-01 … 2199-12-31")
    return d.isoformat()


def _bounded_time(v: Any) -> str:
    s = str(v).strip()
    try:
        d = datetime.fromisoformat(s.replace("Z", "+00:00"))
    except ValueError:
        raise ValueError(f"{v!r} is not a time (ISO 8601)") from None
    if not (1900 <= d.year <= 2199):
        raise ValueError(f"{s} is outside the years 1900 … 2199")
    return s


IsoDate = Annotated[str, Field(max_length=32), AfterValidator(_bounded_date),
                    WithJsonSchema({"type": "string", "format": "date"})]
IsoTime = Annotated[str, Field(max_length=64), AfterValidator(_bounded_time),
                    WithJsonSchema({"type": "string", "format": "date-time"})]

# NUMERIC(18,2): 16 digits before the point. Three places are let through for the 3-place currencies
# (KWD, BHD); erp_platform.money then refuses more places than the currency has.
AMOUNT_LIMIT = Decimal("1e15")


def _amount(v: Any) -> Decimal:
    if isinstance(v, bool):
        raise ValueError("an amount is a number")
    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 abs(d) >= AMOUNT_LIMIT:
        raise ValueError("an amount is below 1,000,000,000,000,000")
    if d.as_tuple().exponent < -3:
        raise ValueError(f"{d} has more decimal places than any currency")
    return d


Amount = Annotated[Decimal, BeforeValidator(_amount),
                   WithJsonSchema({"type": "string", "pattern": r"^-?[0-9]{1,16}(\.[0-9]{1,3})?$",
                                   "description": "an exact decimal, as a string (\"1250.00\")"})]
Quantity = Annotated[int, Field(ge=1, le=1_000_000)]
Percent = Annotated[Decimal, Field(ge=0, le=100)]
Limit = Annotated[int, Field(ge=1, le=1000)]

# Control characters: refused in every string a door receives (erp_platform.bricks), tab/newline/CR excepted.
# A NUL cannot be stored at all (the engine's 500 in the audit); the rest are never a person's text.
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")


def control_character(value: Any, path: str = "") -> str:
    """The first field ("lines.2.description") holding a control character, or "" when none does."""
    if isinstance(value, str):
        return (path or "input") if CONTROL.search(value) else ""
    if isinstance(value, dict):
        for k, v in value.items():
            found = control_character(v, f"{path}.{k}" if path else str(k))
            if found:
                return found
        return ""
    if isinstance(value, (list, tuple)):
        for i, v in enumerate(value):
            found = control_character(v, f"{path}.{i}" if path else str(i))
            if found:
                return found
    return ""