dodil/erp-brick-gl

DODIL ERP brick: general ledger

src/brick_gl/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 NUMERIC(18,2) as an exact string; a rate NUMERIC(19,6); a statistical quantity NUMERIC(19,4)."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Optional

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

Code = text(64, min_length=1)
Short = text(8, min_length=1)
Currency = text(3)
Reason = text(500, min_length=1)
Memo = text(2000)
# The doors check SIZE only. The shape of a code, a period, a year or a date is checked by the use case itself (brick.checked), AFTER
# the permission: a caller who may not do a thing is told 403, never what is wrong with the body they sent.
CompanyCode = text(4)
LedgerCode = text(8)
PeriodId = text(7)
FiscalYearName = text(6)
IsoDate = text(32)
Limit = Annotated[int, Field(ge=1, le=500)]


def _dec(v: Any, what: str, places: int, limit: Decimal, negative: bool) -> Decimal:
    if isinstance(v, bool) or isinstance(v, float):
        raise ValueError(f"{what} is an exact number: give it as a string such as \"100.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")
    if abs(d) >= limit:
        raise ValueError(f"{what} is below {limit:,.0f}")
    if d < 0 and not negative:
        raise ValueError(f"{what} is zero or more")
    return d


def _money(v: Any) -> Decimal:
    return _dec(v, "an amount", 2, Decimal("1e16"), False)


def _money_signed(v: Any) -> Decimal:
    return _dec(v, "an amount", 2, Decimal("1e16"), True)


def _fx(v: Any) -> Decimal:
    d = _dec(v, "a rate", 6, Decimal("1e13"), False)
    if d <= 0:
        raise ValueError("a rate is a positive number")
    return d


def _qty(v: Any) -> Decimal:
    return _dec(v, "a key figure", 4, Decimal("1e15"), False)


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


Money = Annotated[Decimal, BeforeValidator(_money), _exact(r"^[0-9]{1,16}(\.[0-9]{1,2})?$", "an amount, an exact decimal with up to 2 places (\"100.00\")")]
SignedMoney = Annotated[Decimal, BeforeValidator(_money_signed), _exact(r"^-?[0-9]{1,16}(\.[0-9]{1,2})?$", "an amount, an exact decimal with up to 2 places")]
FxRate = Annotated[Decimal, BeforeValidator(_fx), _exact(r"^[0-9]{1,13}(\.[0-9]{1,6})?$", "an exchange rate, an exact decimal with up to 6 places (\"0.790000\")")]
KeyFigure = Annotated[Decimal, BeforeValidator(_qty), _exact(r"^[0-9]{1,15}(\.[0-9]{1,4})?$", "a statistical key figure, up to 4 places")]


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


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


class LineModel(_In):
    """One side of an entry. Name an account or a purpose; debit OR credit, never both."""
    account_id: Optional[BigId] = None
    purpose: Optional[Code] = None
    qualifier: Optional[Code] = None
    tax_code: Optional[Code] = None
    bank_account_id: Optional[Code] = None
    asset_class: Optional[Code] = None
    debit: Optional[Money] = None
    credit: Optional[Money] = None
    memo: Optional[Memo] = None
    bp_id: Optional[BigId] = None
    cost_centre: Optional[Code] = None
    profit_centre: Optional[Code] = None
    partner_company: Optional[CompanyCode] = None
    movement_type: Optional[Code] = None
    against_document: Optional[text(200)] = None
    ic_reference: Optional[text(200)] = None
    asset_id: Optional[text(200)] = None
    company_code: Optional[CompanyCode] = None


Lines = Annotated[list[LineModel], Field(max_length=20000)]