"""Versioned fin-hub/resource-control exchange records.""" from __future__ import annotations from datetime import date, datetime from decimal import Decimal from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from fin_hub.money import currency_code, money, reporting_month class ExchangeRecord(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: Literal["0.1"] = "0.1" record_id: str = Field(min_length=1, max_length=256) revision_of: str | None = None resource_id: str | None = None service_id: str | None = None workload_id: str | None = None tenant_id: str | None = None environment: str | None = None cost_attribution_key: str | None = None period_start: date period_end: date source_evidence: list[str] created_at: datetime @model_validator(mode="after") def validate_period(self): if self.period_end < self.period_start: raise ValueError("period_end cannot precede period_start") return self class MonetaryRecord(ExchangeRecord): currency: str @field_validator("currency") @classmethod def validate_currency(cls, value: str) -> str: return currency_code(value) class BookedCostEvidence(BaseModel): """Authoritative fin-hub fact; amounts serialize as decimal strings.""" model_config = ConfigDict(extra="forbid") schema_version: Literal["0.1"] = "0.1" record_type: Literal["booked_cost"] = "booked_cost" financial_fact_id: str = Field(min_length=1, max_length=256) correction_of: str | None = None adjustment_kind: Literal["charge", "credit", "refund", "reversal", "correction"] source_type: str = Field(min_length=1) source_document_id: str = Field(min_length=1) source_line_id: str = Field(min_length=1) content_fingerprint: str = Field(min_length=1) provider: str = Field(min_length=1) provider_account_ref: str | None = None accounting_period: str service_period_start: date | None = None service_period_end: date | None = None currency: str net_amount: Decimal = Decimal("0.00") discount_amount: Decimal = Decimal("0.00") tax_status: Literal["known", "unknown", "not_applicable"] tax_amount: Decimal | None = None gross_amount: Decimal adjustment_amount: Decimal = Decimal("0.00") effective_amount: Decimal resource_id: str | None = None service_id: str | None = None workload_id: str | None = None tenant_id: str | None = None environment: str | None = None cost_attribution_key: str | None = None source_evidence_ref: str = Field(min_length=1) recorded_at: datetime @field_validator( "net_amount", "discount_amount", "gross_amount", mode="before" ) @classmethod def validate_nonnegative_money(cls, value): return money(value, non_negative=True) @field_validator("tax_amount", "adjustment_amount", "effective_amount", mode="before") @classmethod def validate_signed_money(cls, value): return None if value is None else money(value) @field_validator("currency") @classmethod def validate_currency(cls, value: str) -> str: return currency_code(value) @field_validator("accounting_period") @classmethod def validate_accounting_period(cls, value: str) -> str: return reporting_month(value) @model_validator(mode="after") def validate_accounting_relationships(self): if self.tax_status == "unknown" and self.tax_amount is not None: raise ValueError("tax_amount must be null when tax_status is unknown") if self.tax_status != "unknown" and self.tax_amount is None: raise ValueError("tax_amount is required when tax status is known") tax = self.tax_amount or Decimal("0.00") if self.tax_status != "unknown": expected_gross = money(self.net_amount - self.discount_amount + tax) if self.gross_amount != expected_gross: raise ValueError("gross_amount must equal net - discount + tax") if self.effective_amount != money(self.gross_amount + self.adjustment_amount): raise ValueError("effective_amount must equal gross + adjustment") if self.adjustment_kind != "charge" and self.correction_of is None: raise ValueError("credit/refund/reversal/correction requires correction_of") if self.adjustment_kind in {"credit", "refund", "reversal"} and self.adjustment_amount >= 0: raise ValueError("credit/refund/reversal adjustment_amount must be negative") if ( self.service_period_start is not None and self.service_period_end is not None and self.service_period_end < self.service_period_start ): raise ValueError("service period end cannot precede start") return self class CostBreakdown(BaseModel): model_config = ConfigDict(extra="forbid") infrastructure: Decimal = Decimal("0.00") internal_labor: Decimal = Decimal("0.00") external_services: Decimal = Decimal("0.00") setup: Decimal = Decimal("0.00") other: Decimal = Decimal("0.00") @field_validator("*", mode="before") @classmethod def validate_money(cls, value): return money(value, non_negative=True) @property def total(self) -> Decimal: return money( sum( (getattr(self, name) for name in self.__class__.model_fields), Decimal("0"), ) ) class ForecastEvidence(MonetaryRecord): record_type: Literal["forecast"] = "forecast" scenario: Literal["low", "base", "high"] forecast_version: str = Field(min_length=1) costs: CostBreakdown uncertainty: str | None = None assumptions: list[str] class UsageMeasure(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1) value: Decimal unit: str = Field(min_length=1) class UsageObservation(ExchangeRecord): record_type: Literal["usage_observation"] = "usage_observation" measures: list[UsageMeasure] class AllocationShare(BaseModel): model_config = ConfigDict(extra="forbid") target_key: str = Field(min_length=1) share: Decimal = Field(ge=0, le=1) class AllocationEvidence(MonetaryRecord): record_type: Literal["allocation"] = "allocation" financial_fact_ids: list[str] = Field(min_length=1) method: str = Field(min_length=1) allocated_amount: Decimal shares: list[AllocationShare] residual_share: Decimal = Field(ge=0, le=1) @field_validator("allocated_amount", mode="before") @classmethod def validate_amount(cls, value): return money(value, non_negative=True) @model_validator(mode="after") def validate_shares(self): total = sum((share.share for share in self.shares), Decimal("0")) if total + self.residual_share != Decimal("1"): raise ValueError("allocation shares plus residual_share must equal 1") return self class OptimizationEvidence(MonetaryRecord): record_type: Literal["optimization"] = "optimization" baseline: CostBreakdown alternative: CostBreakdown one_time_cost: Decimal expected_period_savings: Decimal assumptions: list[str] @field_validator("one_time_cost", "expected_period_savings", mode="before") @classmethod def validate_amount(cls, value): return money(value, non_negative=True) class CommitmentCandidate(MonetaryRecord): record_type: Literal["commitment_candidate"] = "commitment_candidate" setup_cost: Decimal recurring_cost: Decimal cadence: Literal["monthly", "quarterly", "annual", "one_time"] term_start: date term_end: date | None = None approval_status: Literal["candidate"] = "candidate" @field_validator("setup_cost", "recurring_cost", mode="before") @classmethod def validate_amount(cls, value): return money(value, non_negative=True) class FinancialConstraintSignal(BaseModel): """Bounded fin-hub authority signal for procurement ranking.""" model_config = ConfigDict(extra="forbid") schema_version: Literal["0.1"] = "0.1" record_type: Literal["financial_constraint"] = "financial_constraint" signal_id: str = Field(min_length=1, max_length=256) signal_kind: Literal[ "budget_ceiling", "active_commitment", "burn_pressure", "runway_pressure" ] classification: Literal[ "policy_constraint", "informational_warning", "informational" ] domain_slug: str = Field(min_length=1, max_length=64) resource_id: str | None = None service_id: str | None = None environment: str | None = None currency: str period_start: date period_end: date amount: Decimal | None = None metric_value: Decimal | None = None metric_unit: Literal["currency_per_month", "ratio", "months"] | None = None commitment_state: Literal["active"] | None = None source_evidence: list[str] = Field(min_length=1) generated_at: datetime summary: str = Field(min_length=1, max_length=512) @field_validator("currency") @classmethod def validate_currency(cls, value: str) -> str: return currency_code(value) @field_validator("amount", mode="before") @classmethod def validate_amount(cls, value): return None if value is None else money(value, non_negative=True) @field_validator("metric_value", mode="before") @classmethod def validate_metric(cls, value): if value is None: return None normalized = Decimal(str(value)) if not normalized.is_finite() or normalized < 0: raise ValueError("metric_value must be a finite non-negative decimal") return normalized @model_validator(mode="after") def validate_signal(self): if self.period_end < self.period_start: raise ValueError("period_end cannot precede period_start") if self.signal_kind in {"budget_ceiling", "active_commitment"}: if self.amount is None: raise ValueError(f"{self.signal_kind} requires amount") if self.classification != "policy_constraint": raise ValueError(f"{self.signal_kind} must be a policy_constraint") if self.signal_kind == "active_commitment" and self.commitment_state != "active": raise ValueError("active_commitment requires commitment_state=active") if self.signal_kind == "burn_pressure" and self.metric_unit != "ratio": raise ValueError("burn_pressure requires a ratio metric") if self.signal_kind == "runway_pressure" and self.metric_unit != "months": raise ValueError("runway_pressure requires a months metric") return self PlanningEvidence = Annotated[ ForecastEvidence | UsageObservation | AllocationEvidence | OptimizationEvidence | CommitmentCandidate, Field(discriminator="record_type"), ]