Add shared cost allocation reporting

This commit is contained in:
tegwick 2026-08-11 11:05:13 +02:00
parent a28a40dfe9
commit b75ad06b3a
9 changed files with 348 additions and 15 deletions

View file

@ -0,0 +1,162 @@
"""Consume authoritative technical allocation evidence for financial reporting."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import date
from decimal import Decimal, ROUND_FLOOR
from pathlib import Path
from fin_hub.money import MONEY_QUANTUM, minor_money, money_minor
from fin_hub.schemas.exchange import AllocationEvidence
from fin_hub.services.exchange import _ensure_planning_schema
from fin_hub.services.ledger import _connect, default_ledger_path
@dataclass(frozen=True)
class AllocatedTarget:
target_key: str
share: Decimal
amount: Decimal
rounding_adjustment: Decimal
def as_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class AllocationReport:
allocation_id: str
method: str
financial_fact_ids: tuple[str, ...]
period_start: date
period_end: date
environment: str | None
currency: str
booked_amount: Decimal
targets: tuple[AllocatedTarget, ...]
residual_share: Decimal
residual_amount: Decimal
residual_rounding_adjustment: Decimal
source_evidence: tuple[str, ...]
def as_dict(self) -> dict:
return asdict(self)
def _split_minor_units(
total_minor: int,
shares: list[tuple[str, Decimal]],
) -> dict[str, tuple[int, Decimal]]:
"""Allocate exact minor units with deterministic largest-remainder rounding."""
floors: dict[str, int] = {}
fractions: list[tuple[Decimal, str]] = []
for key, share in shares:
exact = Decimal(total_minor) * share
floor = int(exact.to_integral_value(rounding=ROUND_FLOOR))
floors[key] = floor
fractions.append((exact - Decimal(floor), key))
remainder = total_minor - sum(floors.values())
for _fraction, key in sorted(fractions, key=lambda item: (-item[0], item[1]))[:remainder]:
floors[key] += 1
return {
key: (
floors[key],
Decimal(floors[key]) - (Decimal(total_minor) * share),
)
for key, share in shares
}
def shared_cost_allocations(*, ledger_path: Path | None = None) -> list[AllocationReport]:
"""Reconcile current allocation evidence to current booked facts."""
ledger = ledger_path or default_ledger_path()
with _connect(ledger) as conn:
_ensure_planning_schema(conn)
allocation_rows = conn.execute(
"SELECT payload_json FROM planning_evidence "
"WHERE record_type = 'allocation' AND is_current = 1 ORDER BY record_id"
).fetchall()
claimed_facts: dict[str, str] = {}
reports: list[AllocationReport] = []
for allocation_row in allocation_rows:
allocation = AllocationEvidence.model_validate_json(
allocation_row["payload_json"]
)
duplicate_ids = {
fact_id
for fact_id in allocation.financial_fact_ids
if allocation.financial_fact_ids.count(fact_id) > 1
}
if duplicate_ids:
raise ValueError(f"allocation repeats financial facts: {sorted(duplicate_ids)}")
fact_rows = []
for fact_id in allocation.financial_fact_ids:
owner = claimed_facts.get(fact_id)
if owner is not None:
raise ValueError(
f"financial fact {fact_id} is allocated by both {owner} "
f"and {allocation.record_id}"
)
fact = conn.execute(
"SELECT * FROM ledger_entries "
"WHERE financial_fact_id = ? AND is_current = 1",
(fact_id,),
).fetchone()
if fact is None:
raise ValueError(f"allocation references missing/current fact {fact_id}")
if fact["currency"] != allocation.currency:
raise ValueError(f"allocation currency does not match fact {fact_id}")
if allocation.environment and fact["environment"] != allocation.environment:
raise ValueError(f"allocation environment does not match fact {fact_id}")
fact_period = date.fromisoformat(f"{fact['period_month']}-01")
if not (allocation.period_start <= fact_period <= allocation.period_end):
raise ValueError(f"allocation period does not include fact {fact_id}")
claimed_facts[fact_id] = allocation.record_id
fact_rows.append(fact)
total_minor = sum(int(row["amount_minor"]) for row in fact_rows)
if total_minor != money_minor(allocation.allocated_amount):
raise ValueError(
f"allocation {allocation.record_id} amount does not reconcile "
"to its current booked facts"
)
shares = [(share.target_key, share.share) for share in allocation.shares]
if len({key for key, _share in shares}) != len(shares):
raise ValueError("allocation target keys must be unique")
residual_key = "__residual__"
split = _split_minor_units(
total_minor,
[*shares, (residual_key, allocation.residual_share)],
)
targets = tuple(
AllocatedTarget(
target_key=key,
share=share,
amount=minor_money(split[key][0]),
rounding_adjustment=(split[key][1] * MONEY_QUANTUM),
)
for key, share in shares
)
residual_minor, residual_adjustment = split[residual_key]
reports.append(
AllocationReport(
allocation_id=allocation.record_id,
method=allocation.method,
financial_fact_ids=tuple(allocation.financial_fact_ids),
period_start=allocation.period_start,
period_end=allocation.period_end,
environment=allocation.environment,
currency=allocation.currency,
booked_amount=minor_money(total_minor),
targets=targets,
residual_share=allocation.residual_share,
residual_amount=minor_money(residual_minor),
residual_rounding_adjustment=residual_adjustment * MONEY_QUANTUM,
source_evidence=tuple(allocation.source_evidence),
)
)
return reports