"""Pydantic request/response models for the finance domain. PR1 covers the operational surface (rollup refresh, period close/reopen, FX pin, reconciliation). PR2 extends this with the read DTOs (summary / pivot / timeseries / detail). """ from __future__ import annotations import re from datetime import datetime from decimal import Decimal from pydantic import BaseModel, Field, field_validator _PERIOD_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") _MAX_BUDGET_AMOUNT = Decimal("99999999999999.99") def _check_period(v: str) -> str: if not _PERIOD_RE.match((v or "").strip()): raise ValueError("period must be 'YYYY-MM'") return v.strip() class PeriodBody(BaseModel): period: str = Field(..., description="Calendar month 'YYYY-MM'.") @field_validator("period") @classmethod def _v(cls, v: str) -> str: return _check_period(v) class CloseBody(PeriodBody): note: str | None = Field(None, max_length=2000) class NetSuiteGLSyncBody(BaseModel): """Manual NetSuite GL pull trigger. ``period`` optional — when omitted the masters are refreshed and the previous + current month are pulled.""" period: str | None = Field(None, description="Calendar month 'YYYY-MM'; null = prev+current.") @field_validator("period") @classmethod def _v(cls, v: str | None) -> str | None: return _check_period(v) if v else None class NetSuiteProbeBody(BaseModel): """Read-only GL probe. ``period`` optional — defaults to the most recent synced month; ``limit`` caps the raw rows returned.""" period: str | None = Field(None) limit: int = Field(default=25, ge=1, le=200) @field_validator("period") @classmethod def _v(cls, v: str | None) -> str | None: return _check_period(v) if v else None class NetSuiteSubsidiaryMapBody(BaseModel): """Set / clear the subsidiary → Helix legal-entity mapping.""" legal_entity_id: str | None = Field( None, max_length=36, description="asset_legal_entities.id, or null to clear the mapping.", ) _COST_TYPES = { "cloud", "software", "ai", "hardware", "procurement", "expense", "__none__", # deliberate exclude sentinel (revenue / intercompany) } class NetSuiteAccountCostTypeBody(BaseModel): """Set / clear a GL account's Helix cost_type for reconciliation.""" mapped_cost_type: str | None = Field( None, description=( "cloud / software / ai / hardware / procurement / expense, " "'__none__' to exclude from cost reconciliation, or null to " "reset to the heuristic (re-seeded on next master sync)." ), ) @field_validator("mapped_cost_type") @classmethod def _v(cls, v: str | None) -> str | None: if v is None: return None v = v.strip() if v not in _COST_TYPES: raise ValueError( f"mapped_cost_type must be one of {sorted(_COST_TYPES)} or null" ) return v class SavedViewBody(BaseModel): name: str = Field(..., min_length=1, max_length=128) config: dict = Field(..., description="Opaque explorer state (group_by/filters/range).") is_shared: bool = Field(default=False) class BudgetCreateBody(BaseModel): fiscal_year: int = Field(..., ge=2000, le=2100) amount_usd: Decimal = Field( ..., ge=0, le=_MAX_BUDGET_AMOUNT, description="Annual budget ceiling in USD.", ) cost_type: str | None = Field(None, max_length=32, description="NULL = all cost types.") department: str | None = Field(None, max_length=128, description="NULL = all departments.") cost_center: str | None = Field(None, max_length=64, description="NULL = all cost centres.") owner_person_id: str | None = Field(None, max_length=36) note: str | None = Field(None, max_length=2000) class BudgetUpdateBody(BaseModel): amount_usd: Decimal | None = Field(None, ge=0, le=_MAX_BUDGET_AMOUNT) cost_type: str | None = Field(None, max_length=32) department: str | None = Field(None, max_length=128) cost_center: str | None = Field(None, max_length=64) owner_person_id: str | None = Field(None, max_length=36) note: str | None = Field(None, max_length=2000) class FiscalYearConfigBody(BaseModel): start_month: int = Field(..., ge=1, le=12, description="Fiscal-year start month, 1-12 (4 = April).") class FindingAckBody(BaseModel): kind: str = Field(..., min_length=1, max_length=32) ref: str = Field(..., min_length=1, max_length=255) status: str = Field(..., description="acknowledged | snoozed | resolved") snooze_until: datetime | None = Field(None, description="Required when status=snoozed.") note: str | None = Field(None, max_length=2000) @field_validator("status") @classmethod def _vs(cls, v: str) -> str: v = (v or "").strip() if v not in {"acknowledged", "snoozed", "resolved"}: raise ValueError("status must be acknowledged | snoozed | resolved") return v class FxPinBody(BaseModel): period: str = Field(..., description="Calendar month 'YYYY-MM'.") currency: str = Field(..., min_length=3, max_length=8) usd_per_unit: Decimal = Field( ..., gt=0, description="USD per one unit of the currency (e.g. 0.14 for CNY).", ) note: str | None = Field(None, max_length=255) @field_validator("period") @classmethod def _vp(cls, v: str) -> str: return _check_period(v) @field_validator("currency") @classmethod def _vc(cls, v: str) -> str: return v.strip().upper()