from __future__ import annotations from datetime import date from decimal import Decimal from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator from typing import List, Optional class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False) class Address(StrictModel): street: str = Field(min_length=1) postal_code: str = Field(min_length=1) city: str = Field(min_length=1) country: str = Field(pattern=r"^[A-Z]{2}$") class Party(StrictModel): name: str = Field(min_length=1) vat_id: Optional[str] = None tax_number: Optional[str] = None email: Optional[str] = None address: Address @field_validator("vat_id", "tax_number") @classmethod def nonempty_tax_id(cls, value): if value is not None and not value: raise ValueError("Tax identifiers must not be empty") return value @field_validator("email") @classmethod def _email_shape(cls, v: Optional[str]): if v is None: return v import re if not re.fullmatch(r"[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+", v): raise ValueError("email does not look valid") return v class Meta(StrictModel): invoice_number: str = Field(min_length=1) invoice_date: date service_date: date currency: str = Field(pattern=r"^EUR$", default="EUR") service_period_start: Optional[date] = None service_period_end: Optional[date] = None buyer_reference: Optional[str] = None order_reference: Optional[str] = None language: Optional[str] = "de" notes: Optional[str] = None class Payment(StrictModel): due_date: date reference: str = Field(min_length=1) iban: Optional[str] = None bic: Optional[str] = None bank_name: Optional[str] = None @field_validator("iban") @classmethod def valid_iban(cls, value): if value is None: return value import re value = value.replace(" ", "").upper() if not re.fullmatch(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}", value): raise ValueError("Invalid IBAN format") rearranged = value[4:] + value[:4] digits = ''.join(str(ord(c) - 55) if c.isalpha() else c for c in rearranged) if int(digits) % 97 != 1: raise ValueError("Invalid IBAN checksum") return value class Item(StrictModel): name: str = Field(min_length=1) description: Optional[str] = None quantity: Decimal = Field(gt=0) unit: str = Field(pattern=r"^[A-Z0-9]{2,3}$") # e.g. HUR, C62 unit_price_net: Decimal = Field(ge=0) tax_rate: Decimal = Field(gt=0, le=100) class Invoice(StrictModel): meta: Meta seller: Party buyer: Party payment: Payment items: List[Item] = Field(min_length=1) @model_validator(mode="after") def check_invoice(self): if not (self.seller.vat_id or self.seller.tax_number): raise ValueError("Seller requires vat_id or tax_number") if self.payment.due_date < self.meta.invoice_date: raise ValueError("Payment due_date precedes invoice_date") start, end = self.meta.service_period_start, self.meta.service_period_end if bool(start) != bool(end) or (start and end and start > end): raise ValueError("Service period requires ordered start and end") return self