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"]