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

@ -2,7 +2,7 @@
# Custodian Brief — fin-hub
**Domain:** financials
**Last synced:** 2026-08-11 08:37 UTC
**Last synced:** 2026-08-11 09:04 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
@ -16,12 +16,11 @@ Progress: 6/9 done | workplan_id: `67b6de6c-4820-4478-9789-f50260204c27`
- · T06 — Generalize and operate the contract `a1309d51`
### Client attribution and billing basis
Progress: 3/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9`
Progress: 4/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9`
**Open tasks:**
- ! Export a per-client billing basis `b5886131`
- ! Select the external invoicing system `d30b606f`
- · Define shared-infrastructure allocation `899a5c29`
- · Export a per-client billing basis `b5886131`
---
## MCP Orientation (when available)

View file

@ -24,6 +24,7 @@ uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000
uv run finhub ledger import cloud tests/fixtures/cloud-costs.csv
uv run finhub ledger set-price --client acme --application portal --instance prod-01 --period 2026-07 --amount 100 --source agreement-2026-01
uv run finhub ledger margins
uv run finhub ledger allocations
uv run finhub evaluate
uv run finhub evidence --seed-fixtures
uv run finhub serve
@ -68,6 +69,15 @@ allocations, optimization cases, and commitment candidates outside booked
spend. `ingest_resource_forecast` adapts resource-control's v0.1 monthly backup
forecast without inventing resource identity.
Shared-infrastructure allocation remains authoritative in resource-control.
Fin-hub's `ledger allocations` command consumes current `AllocationEvidence`,
requires every referenced financial fact to be current and uniquely claimed,
checks period/environment/currency and booked totals, and calculates target and
residual amounts in integer minor units. The producer-supplied versioned method
and provenance remain visible. Deterministic largest-remainder rounding makes
all target amounts plus the explicit unattributed residual reconcile exactly
to booked cost.
## Related Workplans
- `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella

View file

@ -26,8 +26,8 @@
| task | FIN-WP-0002-T00 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T01 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T02 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T03 | todo | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T04 | wait | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T03 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T04 | todo | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0002-T05 | wait | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md |
| task | FIN-WP-0003-T01 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
| task | FIN-WP-0003-T02 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
@ -36,7 +36,7 @@
| task | FIN-WP-0004-T02 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T03 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T04 | todo | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T05 | todo | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T05 | wait | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T06 | todo | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T07 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |
| task | FIN-WP-0004-T08 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |

View file

@ -1,6 +1,6 @@
# Fin-hub ↔ resource-control authority contract v0.1
Status: authority boundary reviewed; v0.1 implementation acceptance pending
Status: v0.1 ratified; operational booked-cost round trip pending
Owners: `fin-hub` / `resource-control`
@ -176,8 +176,9 @@ reference the current record of the same type. Fin-hub stores these in
## Acceptance status
Resource-control accepted the authority boundary in principle on 2026-08-11.
The review is preserved at
Resource-control ratified the v0.1 authority and schema contract on 2026-08-11
after cross-repository preflight matched and idempotently replayed all 12
current backup forecast records. The review is preserved at
`history/2026-08-11-resource-control-review-fin-wp-0004.md`. Contract v0.1 is
not accepted until the implementation-integrity tasks and reconciled backup
round trip pass.
implemented; operational acceptance remains gated by the first real provider
fact and reconciled backup round trip in `FIN-WP-0004-T05`.

View file

@ -14,6 +14,7 @@ from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
from fin_hub.ingest.cloud import parse_cloud_cost_csv
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.allocation import shared_cost_allocations
from fin_hub.services.evaluate import evaluate_runway
from fin_hub.services.evidence import write_runway_evidence
from fin_hub.services.ledger import (
@ -149,6 +150,12 @@ def _cmd_ledger_margins(args: argparse.Namespace) -> int:
return 0
def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
return 0
def _cmd_evaluate(args: argparse.Namespace) -> int:
report = evaluate_runway(
ledger_path=_ledger_path(args),
@ -261,6 +268,13 @@ def build_parser() -> argparse.ArgumentParser:
ledger_margins.add_argument("--ledger", help="Ledger database path")
ledger_margins.set_defaults(func=_cmd_ledger_margins)
ledger_allocations = ledger_sub.add_parser(
"allocations",
help="Reconcile resource-control allocation evidence to booked facts",
)
ledger_allocations.add_argument("--ledger", help="Ledger database path")
ledger_allocations.set_defaults(func=_cmd_ledger_allocations)
evaluate = sub.add_parser(
"evaluate",
help="Evaluate runway from ledger burns (cron/systemd friendly)",

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

129
tests/test_allocation.py Normal file
View file

@ -0,0 +1,129 @@
import sqlite3
from decimal import Decimal
from pathlib import Path
import pytest
from fin_hub.services.allocation import shared_cost_allocations
from fin_hub.services.exchange import ingest_planning_evidence
from fin_hub.services.ledger import import_csv
def _booked_fact(tmp_path: Path, *, amount: str = "100.01", currency: str = "EUR"):
ledger = tmp_path / "ledger.db"
source = tmp_path / "shared.csv"
source.write_text(
"product,amount,currency,invoice_date,environment\n"
f"Shared cluster,{amount},{currency},2026-07-01,production\n",
encoding="utf-8",
)
import_csv(source, "hosteurope", ledger_path=ledger)
with sqlite3.connect(ledger) as conn:
fact_id = conn.execute(
"SELECT financial_fact_id FROM ledger_entries WHERE is_current = 1"
).fetchone()[0]
return ledger, fact_id
def _allocation(fact_id: str, **overrides) -> dict:
payload = {
"schema_version": "0.1",
"record_type": "allocation",
"record_id": "allocation:shared-cluster:2026-07:v1",
"revision_of": None,
"resource_id": "resource:shared_cluster",
"service_id": "shared-cluster",
"workload_id": None,
"tenant_id": None,
"environment": "production",
"cost_attribution_key": "platform:shared-cluster",
"period_start": "2026-07-01",
"period_end": "2026-07-31",
"currency": "EUR",
"source_evidence": ["resource-control:allocation:namespace-cpu-v1"],
"created_at": "2026-08-11T09:00:00Z",
"financial_fact_ids": [fact_id],
"method": "namespace-cpu-v1",
"allocated_amount": "100.01",
"shares": [
{"target_key": "client:acme|app:portal|instance:prod-01", "share": "0.3333"},
{"target_key": "client:beta|app:portal|instance:prod-01", "share": "0.3333"},
],
"residual_share": "0.3334",
}
payload.update(overrides)
return payload
def test_shared_allocation_reconciles_targets_rounding_and_residual(tmp_path: Path):
ledger, fact_id = _booked_fact(tmp_path)
ingest_planning_evidence(_allocation(fact_id), ledger_path=ledger)
report = shared_cost_allocations(ledger_path=ledger)[0]
assert report.booked_amount == Decimal("100.01")
assert sum((target.amount for target in report.targets), report.residual_amount) == Decimal(
"100.01"
)
assert report.residual_amount > Decimal("0")
assert report.method == "namespace-cpu-v1"
assert report.source_evidence
def test_revised_allocation_supersedes_prior_shares(tmp_path: Path):
ledger, fact_id = _booked_fact(tmp_path)
original = _allocation(fact_id)
ingest_planning_evidence(original, ledger_path=ledger)
revised = _allocation(
fact_id,
record_id="allocation:shared-cluster:2026-07:v2",
revision_of=original["record_id"],
shares=[
{"target_key": "client:acme|app:portal|instance:prod-01", "share": "0.75"}
],
residual_share="0.25",
)
ingest_planning_evidence(revised, ledger_path=ledger)
reports = shared_cost_allocations(ledger_path=ledger)
assert len(reports) == 1
assert reports[0].allocation_id == revised["record_id"]
assert reports[0].targets[0].share == Decimal("0.75")
def test_allocation_rejects_missing_fact(tmp_path: Path):
ledger = tmp_path / "ledger.db"
ingest_planning_evidence(
_allocation("fact:missing"),
ledger_path=ledger,
)
with pytest.raises(ValueError, match="missing/current fact"):
shared_cost_allocations(ledger_path=ledger)
def test_allocation_rejects_currency_or_amount_mismatch(tmp_path: Path):
ledger, fact_id = _booked_fact(tmp_path)
ingest_planning_evidence(
_allocation(fact_id, currency="USD"),
ledger_path=ledger,
)
with pytest.raises(ValueError, match="currency"):
shared_cost_allocations(ledger_path=ledger)
def test_fact_cannot_be_allocated_twice(tmp_path: Path):
ledger, fact_id = _booked_fact(tmp_path)
first = _allocation(fact_id)
second = _allocation(
fact_id,
record_id="allocation:duplicate",
shares=[{"target_key": "client:acme", "share": "1"}],
residual_share="0",
)
ingest_planning_evidence(first, ledger_path=ledger)
ingest_planning_evidence(second, ledger_path=ledger)
with pytest.raises(ValueError, match="allocated by both"):
shared_cost_allocations(ledger_path=ledger)

View file

@ -116,7 +116,7 @@ prices remain auditable, and duplicate prices require an explicit revision.
```task
id: FIN-WP-0002-T03
status: todo
status: done
priority: medium
state_hub_task_id: "899a5c29-843c-481f-a63a-42d4d327af5d"
```
@ -130,11 +130,19 @@ where resource-control supplies it.
Test allocation totals, rounding, missing evidence, shared overhead, revised
allocations, and prevention of duplicate booked cost.
Completed 2026-08-11: added a reporting-only allocation consumer for current
resource-control `AllocationEvidence`. It requires uniquely claimed current
financial facts, validates period/environment/currency and producer totals,
retains the versioned method and provenance, and uses deterministic
largest-remainder minor-unit rounding. Per-instance targets plus the explicit
unattributed residual reconcile exactly to booked cost. Tests cover rounding,
missing facts, mismatches, evidence revisions, and duplicate allocation.
## Export a per-client billing basis
```task
id: FIN-WP-0002-T04
status: wait
status: todo
priority: medium
state_hub_task_id: "b5886131-82f6-49fb-a7e3-ba47d5b627a8"
```

View file

@ -162,7 +162,7 @@ without reproducing fin-hub's allocator or runway logic.
```task
id: FIN-WP-0004-T05
status: todo
status: wait
priority: high
state_hub_task_id: "9a411734-76f6-4c8d-9a48-b5a08cf8fd1f"
```
@ -179,6 +179,16 @@ Done when the same cost is booked exactly once, projected to resource-control,
joined to technical evidence, and returned as planning or variance evidence
without becoming a second financial fact.
Preflight 2026-08-11: resource-control verified fin-hub commit `0034330`, and
both implementations produced identical canonical payloads for all 12 live
backup forecast rows. Replaying them retained exactly 12 planning-store rows.
Evidence is in resource-control at
`docs/evidence/RESOURCE-WP-0003-fin-hub-contract-preflight-2026-08-11.md`.
The task now waits on `RESOURCE-WP-0002` procurement and the first real
provider fact attributable to `platform:audit-storage`. Synthetic records test
the contract but do not satisfy the operational booked-cost round trip.
## T06 — Generalize and operate the contract
```task