dodil/erp-brick-payments

DODIL ERP brick: payments

src/brick_payments/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."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional

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

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)
# A date given as text; the use case parses it AFTER the permission gate, so a caller without the permission is told so
# (403) rather than 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"})]
Reason = text(500)
Ref = text(140)
Seq = Annotated[int, Field(ge=1, le=1_000_000)]
Offset = Annotated[int, Field(ge=0, le=10_000_000)]
MONEY_LIMIT = Decimal("1e15")


def _money(v: Any, zero_ok: bool = False) -> Decimal:
    if isinstance(v, bool) or isinstance(v, float):
        raise ValueError("an amount is an exact number: give it as a string such as \"1250.00\"")
    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 < MONEY_LIMIT):
        raise ValueError(f"an amount is {'zero or more' if zero_ok else 'more than zero'} and below 1,000,000,000,000,000")
    return d


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


def _balance(v: Any) -> Decimal:
    if isinstance(v, bool) or isinstance(v, float):
        raise ValueError("a balance is an exact number: give it as a string such as \"-120.50\"")
    try:
        d = v if isinstance(v, Decimal) else Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise ValueError(f"{v!r} is not a balance") from None
    if not d.is_finite() or d.as_tuple().exponent < -2 or abs(d) >= MONEY_LIMIT:
        raise ValueError("a balance is a finite number with at most two decimal places, below 1,000,000,000,000,000 either way")
    return d


Balance = Annotated[Decimal, BeforeValidator(_balance),
                    WithJsonSchema({"type": "string", "pattern": r"^-?[0-9]{1,15}(\.[0-9]{1,2})?$", "description": "an exact decimal balance, as a string; negative is overdrawn"})]


def _rate(v: Any) -> Decimal:
    if isinstance(v, bool) or isinstance(v, float):
        raise ValueError("a rate is an exact number: give it as a string such as \"1.2345\"")
    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():
        raise ValueError("a rate is a finite number")
    if d.as_tuple().exponent < -4:
        raise ValueError("a rate has at most four decimal places")
    if not (Decimal(0) < d < Decimal("1e15")):
        raise ValueError("a rate is more than zero and has fewer than 15 digits before the point")
    return d


Rate = Annotated[Decimal, BeforeValidator(_rate), WithJsonSchema({"type": "string", "pattern": r"^[0-9]{1,14}(\.[0-9]{1,4})?$",
                                          "description": "an exchange rate, an exact decimal with up to 4 places"})]


def day(v: Optional[str]) -> Optional[date]:
    if v in (None, ""):
        return None
    d = date.fromisoformat(str(v).strip()[:10])
    if not (date(1900, 1, 1) <= d <= date(2199, 12, 31)):
        raise ValueError("out of range")
    return d


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


class LineIn(_In):
    """Put `amount` of the source against this invoice."""
    invoice_id: Code
    amount: Money


class FeedLineIn(_In):
    """One transaction an account-information provider delivered (PAY-35)."""
    transaction_id: Ref
    booked_on: Day
    amount: Money
    direction: Literal["credit", "debit"]
    currency: Optional[Currency] = None
    counterparty_name: Optional[text(200)] = None
    counterparty_account: Optional[text(64)] = None
    remittance: Optional[text(500)] = None


class ReminderLevelIn(_In):
    level: Annotated[int, Field(ge=1, le=20)]
    days_after_due: Annotated[int, Field(ge=0, le=3650)]
    channel: text(20) = "email"
    template: text(2000) = ""
    mode: text(10) = "suggest"


__all__ = ["BigId", "Day", "IsoDate", "Code", "Currency", "Reason", "Ref", "Money", "Money0", "Balance", "Rate", "Seq", "Offset", "LineIn", "FeedLineIn", "ReminderLevelIn", "day"]