dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
src/brick_ar/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).
A price per unit is a RATE: up to four decimal places, as an exact string on the wire."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Optional
from erp_platform.types import IsoDate, Name, Note, text
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema
Code = text(64, min_length=1)
Currency = text(3)
Reason = text(500)
Word = text(40, min_length=1)
def _decimal(v: Any, what: str, places: int, lo: Decimal, hi: Decimal) -> Decimal:
if isinstance(v, bool) or isinstance(v, float):
raise ValueError(f"{what} is an exact number: give it as a string such as \"120.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 not (lo <= d < hi):
raise ValueError(f"{what} is between {lo} and {hi}")
return d
def _exact(pattern: str, description: str):
return WithJsonSchema({"type": "string", "pattern": pattern, "description": description})
Rate = Annotated[Decimal, BeforeValidator(lambda v: _decimal(v, "a price", 4, Decimal("-1e14"), Decimal("1e14"))),
_exact(r"^-?[0-9]{1,14}(\.[0-9]{1,4})?$", "a price per unit, an exact decimal with up to 4 places (\"120.00\")")]
FxRate = Annotated[Decimal, BeforeValidator(lambda v: _decimal(v, "an exchange rate", 4, Decimal("0.0001"), Decimal("1e15"))),
_exact(r"^[0-9]{1,15}(\.[0-9]{1,4})?$", "an exchange rate above zero, an exact decimal with up to 4 places (\"0.8600\")")]
Qty = Annotated[Decimal, BeforeValidator(lambda v: _decimal(v, "a quantity", 4, Decimal("0"), Decimal("1e12"))),
_exact(r"^[0-9]{1,12}(\.[0-9]{1,4})?$", "a quantity, an exact decimal with up to 4 places (\"12\")")]
Pct = Annotated[Decimal, BeforeValidator(lambda v: _decimal(v, "a percentage", 4, Decimal("0"), Decimal("100.0001"))),
_exact(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "a percentage as an exact decimal (\"5\" is 5%)")]
Money = Annotated[Decimal, BeforeValidator(lambda v: _decimal(v, "an amount", 2, Decimal("0"), Decimal("1e15"))),
_exact(r"^[0-9]{1,15}(\.[0-9]{1,2})?$", "an amount, an exact decimal with up to 2 places (\"12.40\")")]
LineNo = Annotated[int, Field(ge=1, le=100_000)]
def day(v: Optional[str]) -> Optional[date]:
return None if v in (None, "") else date.fromisoformat(v)
class _In(BaseModel):
model_config = ConfigDict(extra="forbid")
class LineIn(_In):
"""One line to bill. The system computes its net amount and tax; a usage line leaves the quantity empty for the biller."""
description: Optional[Note] = None
quantity: Optional[Qty] = None
unit_price: Optional[Rate] = None
discount_pct: Optional[Pct] = None
unit_code: Optional[Code] = None # the product's (UN/ECE Rec 20); copied from the product when left out
product_id: Optional[Code] = None
tax_category: Optional[Code] = None # what tax decides the code from; copied from the product when left out
tax_code: Optional[Code] = None # only to keep a code a source already chose (a deposit's)
sales_order_id: Optional[Code] = None # the order line this bills: (sales_order_id, so_line_no)
so_line_no: Optional[LineNo] = None
service_from: Optional[IsoDate] = None
service_to: Optional[IsoDate] = None
item_id: Optional[Code] = None
cost_centre: Optional[Code] = None
usage: bool = False
pricing_quantity: Optional[Annotated[int, Field(ge=1, le=100_000)]] = None # v7: the count unit_price is per (2.4500 per 100 -> 100); default 1
pricing_unit_code: Optional[Code] = None # v6: the unit that price is per
quantity_priced: Optional[Qty] = None # v7: the line's quantity in pricing_unit_code, exact; required with pricing_unit_code
net_amount: Optional[Money] = None # v7: the caller's exact net (the order's conditions): kept as sent, never recomputed
class DeductionIn(_In):
"""v6 (sales-order's wire shape): a deposit and the NET a regular or final invoice takes from it. The deduction line is pinned to the
deposit's frozen tax code, so tax_category is accepted and the deposit's category wins."""
deposit_invoice_id: Name
net_amount: Money
tax_category: Optional[Code] = None
class DepositIn(_In):
"""A deposit a final invoice deducts from, and the NET it takes: the whole of what remains when left out, or a part."""
invoice_id: Name
amount: Optional[Money] = None
class CreditLineIn(_In):
description: Optional[Note] = None
quantity: Optional[Qty] = None
unit_price: Optional[Rate] = None
discount_pct: Optional[Pct] = None
unit_code: Optional[Code] = None
product_id: Optional[Code] = None
tax_category: Optional[Code] = None
tax_code: Optional[Code] = None
sales_order_id: Optional[Code] = None
so_line_no: Optional[LineNo] = None
cost_centre: Optional[Code] = None
class DoorsIn(_In):
"""The customer's answers to the migration doors (migration.odoo.door); an undecided door blocks the load."""
history_years: Optional[Annotated[int, Field(ge=1, le=50)]] = None # how far back; None = every year
numbering: Optional[text(12)] = None # continue | new_prefix
carry_drafts: Optional[bool] = None # False: re-raise Odoo drafts by hand (recommended)
instalments: Optional[text(12)] = None # as_odoo | simplified
company_id: Optional[Annotated[int, Field(ge=1)]] = None
ledger: Optional[text(12)] = None # post | export