| """Receipt schema — for consumer receipts (retail, restaurant, gas, etc.). |
| |
| Receipts are simpler than invoices: no PO, no billing party, no payment terms. |
| Typically we care about merchant, transaction time, line items, and totals. |
| """ |
| from __future__ import annotations |
|
|
| from datetime import date, time |
|
|
| from pydantic import Field, field_validator |
|
|
| from src.schemas.base import StrictModel |
| from src.schemas.common import Address, MoneyAmount, normalize_currency, round_money |
|
|
|
|
| class ReceiptLineItem(StrictModel): |
| """A row on a consumer receipt — usually less structured than an invoice line.""" |
|
|
| description: str = Field(description="What was purchased.") |
| quantity: float | None = Field(default=None, ge=0) |
| unit_price: MoneyAmount | None = None |
| total: MoneyAmount | None = None |
|
|
| @field_validator("unit_price", "total", mode="before") |
| @classmethod |
| def _round(cls, v): |
| return round_money(v) if v is not None else v |
|
|
|
|
| class Receipt(StrictModel): |
| """A consumer-facing receipt. |
| |
| Required fields: merchant name, total, currency. Everything else optional |
| since many receipts omit fields (e.g. gas receipts often lack line items). |
| """ |
|
|
| |
| merchant: str = Field(description="Merchant / business name as printed on the receipt.") |
| merchant_address: Address | None = Field( |
| default=None, description="Structured address of the merchant if legible." |
| ) |
| merchant_phone: str | None = None |
|
|
| |
| transaction_date: date | None = Field( |
| default=None, description="Date of the transaction (ISO 8601)." |
| ) |
| transaction_time: time | None = Field( |
| default=None, description="Time of day (24h, ISO 8601)." |
| ) |
| receipt_number: str | None = Field( |
| default=None, description="Receipt / transaction / order number, if present." |
| ) |
|
|
| |
| line_items: list[ReceiptLineItem] = Field( |
| default_factory=list, description="Purchased items. Empty is allowed for e.g. lump-sum receipts." |
| ) |
| subtotal: MoneyAmount | None = None |
| tax: MoneyAmount | None = None |
| tip: MoneyAmount | None = Field(default=None, description="Gratuity, if applicable (restaurants).") |
| total: MoneyAmount = Field(description="Grand total charged.") |
|
|
| |
| currency: str = Field(description="ISO 4217 currency code (e.g. USD).") |
| payment_method: str | None = Field( |
| default=None, |
| description="Free text: 'Visa **** 1234', 'Cash', 'Apple Pay', etc.", |
| ) |
|
|
| |
|
|
| @field_validator("subtotal", "tax", "tip", "total", mode="before") |
| @classmethod |
| def _round_money(cls, v): |
| return round_money(v) if v is not None else v |
|
|
| @field_validator("currency", mode="before") |
| @classmethod |
| def _normalize_currency(cls, v): |
| return normalize_currency(v) |
|
|
| @field_validator("total") |
| @classmethod |
| def _total_nonneg(cls, v): |
| if v < 0: |
| raise ValueError(f"Receipt total cannot be negative: {v}") |
| return v |
|
|