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