dodil/erp-brick-tax
DODIL ERP brick: tax
src/brick_tax/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; a rate (a percentage, a unit price) has up to four."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, Literal, Optional, Union
from erp_platform.types import IsoDate, Name, Note, text
from pydantic import AfterValidator, BaseModel, BeforeValidator, ConfigDict, Field, WithJsonSchema
Code = text(64, min_length=1)
Key = text(64, min_length=1)
Reason = text(500)
Currency = text(3, min_length=3)
Supply = Literal["goods", "services", "any"]
Consumption = Literal["eat_in", "takeaway", "any"]
def _decimal(v: Any, what: str, places: int) -> Decimal:
if isinstance(v, (bool, 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")
return d
def _money(v: Any) -> Decimal:
d = _decimal(v, "an amount", 2)
if abs(d) >= Decimal("1e15"):
raise ValueError("an amount is below 1,000,000,000,000,000")
return d
def _pct(v: Any) -> Decimal:
d = _decimal(v, "a percentage", 4)
if not (Decimal(0) <= d <= Decimal(100)):
raise ValueError("a percentage is between 0 and 100")
return d
def _rate(v: Any) -> Decimal:
d = _decimal(v, "a unit price or quantity", 4)
if not (Decimal(0) <= d < Decimal("1e14")):
raise ValueError("a unit price or quantity is zero or more, and below 100,000,000,000,000")
return d
def _fx(v: Any) -> Decimal:
d = _decimal(v, "an exchange rate", 8)
if not (Decimal(0) < d < Decimal("1e6")):
raise ValueError("an exchange rate is above zero")
return d
def _non_negative(v: Decimal) -> Decimal:
if v < 0:
raise ValueError("an amount of VAT is not negative")
return v
def _exact(pattern: str, description: str):
return WithJsonSchema({"type": "string", "pattern": pattern, "description": description})
Money = Annotated[Decimal, BeforeValidator(_money), _exact(r"^-?[0-9]{1,15}(\.[0-9]{1,2})?$", "an amount, an exact decimal with up to 2 places (\"120.00\")")]
NonNegMoney = Annotated[Decimal, BeforeValidator(_money), AfterValidator(_non_negative), _exact(r"^[0-9]{1,15}(\.[0-9]{1,2})?$", "a non-negative amount, an exact decimal with up to 2 places (\"120.00\")")]
Pct = Annotated[Decimal, BeforeValidator(_pct), _exact(r"^[0-9]{1,3}(\.[0-9]{1,4})?$", "a percentage as an exact decimal (\"20\" is 20%)")]
Rate = Annotated[Decimal, BeforeValidator(_rate), _exact(r"^[0-9]{1,14}(\.[0-9]{1,4})?$", "an exact decimal with up to 4 places")]
Fx = Annotated[Decimal, BeforeValidator(_fx), _exact(r"^[0-9]{1,6}(\.[0-9]{1,8})?$", "sterling per unit of the document's currency")]
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 of a quote, order, invoice or credit note: what it is, what it is worth, and how it is taxed in the abstract."""
line_no: Annotated[int, Field(ge=1, le=99999)]
description: Optional[text(500)] = None
category_code: Optional[Key] = None # the product's tax category (how it is taxed, in the abstract)
supply: Supply = "any" # goods | services: the place-of-supply distinction a rule may make
amount: Money # net, or gross where the prices include VAT
quantity: Optional[Rate] = None
unit_price: Optional[Rate] = None
pinned_from: Optional[text(200, min_length=1)] = None # v4: an earlier FROZEN document (a deposit invoice) whose code and rate this line takes
tax_code: Optional[Key] = None # v4: with pinned_from, the code of that document the line reverses
consumption: Optional[Literal["eat_in", "takeaway"]] = None # v6 (TAX-51): where the rules tell them apart, a line must say which
class AddressIn(_In):
"""The one postal address shape between modules (erp-method shared.yaml conventions.address)."""
lines: Annotated[list[Annotated[str, Field(min_length=1, max_length=70)]], Field(min_length=1, max_length=4)]
city: Annotated[str, Field(min_length=1, max_length=70)]
postcode: Optional[Annotated[str, Field(max_length=16)]] = None
region: Optional[Annotated[str, Field(max_length=70)]] = None
country: Annotated[str, Field(pattern=r"^[A-Z]{2}$")]
def one_line(self) -> str:
return ", ".join(p for p in (*self.lines, self.city, self.postcode or "", self.region or "", self.country) if p)
class ContentsIn(_In):
"""The invoice facts that are not the tax's own (VAT Notice 700 para 16.3.1)."""
title: Optional[Name] = None
invoice_number: Optional[Name] = None
issue_date: Optional[IsoDate] = None
supplier_name: Optional[Name] = None
supplier_address: Optional[Union[AddressIn, Note]] = None # v6: the address object; a string is accepted until the next major as one line
supplier_vat_number: Optional[text(32)] = None
customer_name: Optional[Name] = None
customer_address: Optional[Note] = None
cash_discount_applies: bool = False
cash_discount_pct: Optional[Pct] = None
simplified: bool = False
class OdooDoorIn(_In):
"""What the customer answered at the door (module.yaml migration.odoo.door). Nothing is written until `confirm` is true:
without it the migration returns the plan (the codes and treatments it proposes) and writes nothing."""
odoo_db: Name
company_id: Annotated[int, Field(ge=1)]
registration_from: IsoDate # the HMRC effective date of registration (Odoo stores none)
cutover_date: IsoDate # the first tax point this module taxes; migrated rates and rules start here
confirm: bool = False
code_map: dict[Key, Key] = Field(default_factory=dict) # Odoo tax id -> our tax code (overrides the proposal)
treatment_map: dict[Key, Key] = Field(default_factory=dict) # Odoo fiscal position id -> treatment code
rounding_method: Optional[Literal["per_line_nearest", "per_line_down_0_1p", "per_invoice_round_down"]] = None # where Odoo used round_globally
archived: Literal["retired_codes"] = "retired_codes" # archived taxes a posted invoice used become retired codes
missing_vat_to: Optional[Key] = None # a treatment (without a VAT number) for customers whose position needs one but who have none
window_from: Optional[IsoDate] = None # the reconcile window (default: 12 months before the cutover)
window_to: Optional[IsoDate] = None