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 # Custodian Brief — fin-hub
**Domain:** financials **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)* **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams ## Active Workstreams
@ -16,12 +16,11 @@ Progress: 6/9 done | workplan_id: `67b6de6c-4820-4478-9789-f50260204c27`
- · T06 — Generalize and operate the contract `a1309d51` - · T06 — Generalize and operate the contract `a1309d51`
### Client attribution and billing basis ### 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:** **Open tasks:**
- ! Export a per-client billing basis `b5886131`
- ! Select the external invoicing system `d30b606f` - ! Select the external invoicing system `d30b606f`
- · Define shared-infrastructure allocation `899a5c29` - · Export a per-client billing basis `b5886131`
--- ---
## MCP Orientation (when available) ## 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 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 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 margins
uv run finhub ledger allocations
uv run finhub evaluate uv run finhub evaluate
uv run finhub evidence --seed-fixtures uv run finhub evidence --seed-fixtures
uv run finhub serve 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 spend. `ingest_resource_forecast` adapts resource-control's v0.1 monthly backup
forecast without inventing resource identity. 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 ## Related Workplans
- `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella - `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-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-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-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-T03 | done | — | 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-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-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-T01 | todo | — | workplans/FIN-WP-0003-fabric-authority-boundary.md |
| task | FIN-WP-0003-T02 | 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-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-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-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-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-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 | | 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 # 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` Owners: `fin-hub` / `resource-control`
@ -176,8 +176,9 @@ reference the current record of the same type. Fin-hub stores these in
## Acceptance status ## Acceptance status
Resource-control accepted the authority boundary in principle on 2026-08-11. Resource-control ratified the v0.1 authority and schema contract on 2026-08-11
The review is preserved at 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 `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 implemented; operational acceptance remains gated by the first real provider
round trip pass. 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.cloud import parse_cloud_cost_csv
from fin_hub.ingest.hosteurope import parse_hosteurope_csv from fin_hub.ingest.hosteurope import parse_hosteurope_csv
from fin_hub.services.alerts import evaluate_budget_alerts 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.evaluate import evaluate_runway
from fin_hub.services.evidence import write_runway_evidence from fin_hub.services.evidence import write_runway_evidence
from fin_hub.services.ledger import ( from fin_hub.services.ledger import (
@ -149,6 +150,12 @@ def _cmd_ledger_margins(args: argparse.Namespace) -> int:
return 0 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: def _cmd_evaluate(args: argparse.Namespace) -> int:
report = evaluate_runway( report = evaluate_runway(
ledger_path=_ledger_path(args), 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.add_argument("--ledger", help="Ledger database path")
ledger_margins.set_defaults(func=_cmd_ledger_margins) 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 = sub.add_parser(
"evaluate", "evaluate",
help="Evaluate runway from ledger burns (cron/systemd friendly)", 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 ```task
id: FIN-WP-0002-T03 id: FIN-WP-0002-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "899a5c29-843c-481f-a63a-42d4d327af5d" 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 Test allocation totals, rounding, missing evidence, shared overhead, revised
allocations, and prevention of duplicate booked cost. 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 ## Export a per-client billing basis
```task ```task
id: FIN-WP-0002-T04 id: FIN-WP-0002-T04
status: wait status: todo
priority: medium priority: medium
state_hub_task_id: "b5886131-82f6-49fb-a7e3-ba47d5b627a8" 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 ```task
id: FIN-WP-0004-T05 id: FIN-WP-0004-T05
status: todo status: wait
priority: high priority: high
state_hub_task_id: "9a411734-76f6-4c8d-9a48-b5a08cf8fd1f" 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 joined to technical evidence, and returned as planning or variance evidence
without becoming a second financial fact. 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 ## T06 — Generalize and operate the contract
```task ```task