fin-hub/src/fin_hub/schemas/reporting_allocation.py
tegwick efd1b31001 feat: reporting-allocate AI-plan cost across work
Split a booked AI-plan fact across workplans or repos using measured
tokens, measured+estimated tokens, session counts, or an even split.
Unmeasured remainder stays a residual. The record is not resource-control
AllocationEvidence and does not create booked spend.
2026-08-15 19:35:43 +02:00

78 lines
2.8 KiB
Python

"""Fin-hub reporting allocation of a booked AI-plan fact onto work."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from fin_hub.money import money
REPORTING_METHODS = (
"measured_token_share",
"measured_estimated_token_share",
"session_count_share",
"even_split",
)
class ReportingAllocationShare(BaseModel):
model_config = ConfigDict(extra="forbid")
target_key: str = Field(min_length=1, max_length=256)
share: Decimal = Field(ge=0, le=1)
driver_value: int = Field(ge=0)
@field_validator("share", mode="before")
@classmethod
def validate_share(cls, value):
return money(value, non_negative=True)
class ReportingAllocation(BaseModel):
"""Local reporting allocation. Not resource-control AllocationEvidence."""
model_config = ConfigDict(extra="forbid")
schema_version: Literal["0.1"] = "0.1"
record_type: Literal["reporting_allocation"] = "reporting_allocation"
record_id: str = Field(min_length=1, max_length=256)
revision_of: str | None = None
financial_fact_id: str = Field(min_length=1)
method: Literal[
"measured_token_share",
"measured_estimated_token_share",
"session_count_share",
"even_split",
]
estimator_version: str = Field(min_length=1, max_length=64)
session_token_record_id: str | None = None
period_start: date
period_end: date
currency: str
booked_amount: Decimal
shares: list[ReportingAllocationShare]
residual_share: Decimal = Field(ge=0, le=1)
residual_reason: Literal["none", "unmeasured", "unattributed", "no_targets"]
source_evidence: list[str] = Field(min_length=1)
@field_validator("booked_amount", "residual_share", mode="before")
@classmethod
def validate_money_fields(cls, value):
return money(value, non_negative=True)
@model_validator(mode="after")
def validate_shares(self) -> "ReportingAllocation":
keys = [share.target_key for share in self.shares]
if len(set(keys)) != len(keys):
raise ValueError("reporting allocation target keys must be unique")
total = sum((share.share for share in self.shares), Decimal("0.00"))
if total + self.residual_share != Decimal("1.00"):
raise ValueError("allocation shares plus residual_share must equal 1")
if self.residual_share == Decimal("0.00") and self.residual_reason != "none":
raise ValueError("zero residual must use residual_reason=none")
if self.residual_share > Decimal("0.00") and self.residual_reason == "none":
raise ValueError("nonzero residual requires a residual_reason")
return self