Add shared cost allocation reporting
This commit is contained in:
parent
a28a40dfe9
commit
b75ad06b3a
9 changed files with 348 additions and 15 deletions
162
src/fin_hub/services/allocation.py
Normal file
162
src/fin_hub/services/allocation.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue