From b75ad06b3abcc5e4e86a26cf1772a6bcc12e6f5a Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 11 Aug 2026 11:05:13 +0200 Subject: [PATCH] Add shared cost allocation reporting --- .custodian-brief.md | 7 +- README.md | 10 ++ WORK-RECORDS.md | 6 +- docs/fin-resource-authority-contract-v0.1.md | 11 +- src/fin_hub/cli.py | 14 ++ src/fin_hub/services/allocation.py | 162 ++++++++++++++++++ tests/test_allocation.py | 129 ++++++++++++++ ...02-client-attribution-and-billing-basis.md | 12 +- ...WP-0004-resource-cost-evidence-contract.md | 12 +- 9 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 src/fin_hub/services/allocation.py create mode 100644 tests/test_allocation.py diff --git a/.custodian-brief.md b/.custodian-brief.md index 0169afa..18037de 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -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) diff --git a/README.md b/README.md index 7f85afe..899197b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ffa9fc7..ee27cf1 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/fin-resource-authority-contract-v0.1.md b/docs/fin-resource-authority-contract-v0.1.md index be54917..f13dae8 100644 --- a/docs/fin-resource-authority-contract-v0.1.md +++ b/docs/fin-resource-authority-contract-v0.1.md @@ -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`. diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index 0d9ba4f..88227a9 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -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)", diff --git a/src/fin_hub/services/allocation.py b/src/fin_hub/services/allocation.py new file mode 100644 index 0000000..903a59f --- /dev/null +++ b/src/fin_hub/services/allocation.py @@ -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 diff --git a/tests/test_allocation.py b/tests/test_allocation.py new file mode 100644 index 0000000..9be1344 --- /dev/null +++ b/tests/test_allocation.py @@ -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) diff --git a/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md b/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md index ba9e7fd..8d2870b 100644 --- a/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md +++ b/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md @@ -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" ``` diff --git a/workplans/FIN-WP-0004-resource-cost-evidence-contract.md b/workplans/FIN-WP-0004-resource-cost-evidence-contract.md index caa9386..39458b8 100644 --- a/workplans/FIN-WP-0004-resource-cost-evidence-contract.md +++ b/workplans/FIN-WP-0004-resource-cost-evidence-contract.md @@ -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