From fcec177dedfb5d00e56c1b6096a8f34752009066 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 11 Aug 2026 14:39:48 +0200 Subject: [PATCH] Add per-client billing basis export --- .custodian-brief.md | 5 +- README.md | 8 + WORK-RECORDS.md | 2 +- src/fin_hub/attribution.py | 18 ++ src/fin_hub/cli.py | 14 + src/fin_hub/services/allocation.py | 12 + src/fin_hub/services/billing.py | 288 ++++++++++++++++++ tests/test_attribution.py | 12 +- tests/test_billing.py | 138 +++++++++ ...02-client-attribution-and-billing-basis.md | 20 +- 10 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 src/fin_hub/services/billing.py create mode 100644 tests/test_billing.py diff --git a/.custodian-brief.md b/.custodian-brief.md index 18037de..9c294ee 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,7 +2,7 @@ # Custodian Brief — fin-hub **Domain:** financials -**Last synced:** 2026-08-11 09:04 UTC +**Last synced:** 2026-08-11 12:39 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams @@ -16,11 +16,10 @@ Progress: 6/9 done | workplan_id: `67b6de6c-4820-4478-9789-f50260204c27` - · T06 — Generalize and operate the contract `a1309d51` ### Client attribution and billing basis -Progress: 4/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` +Progress: 5/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` **Open tasks:** - ! Select the external invoicing system `d30b606f` -- · Export a per-client billing basis `b5886131` --- ## MCP Orientation (when available) diff --git a/README.md b/README.md index 899197b..1ec94fa 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ 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 ledger billing-basis uv run finhub evaluate uv run finhub evidence --seed-fixtures uv run finhub serve @@ -78,6 +79,13 @@ and provenance remain visible. Deterministic largest-remainder rounding makes all target amounts plus the explicit unattributed residual reconcile exactly to booked cost. +`ledger billing-basis` produces an idempotent client-period reporting artifact. +Each record includes the current price and revision, direct and allocated cost, +margin, financial-fact/correction IDs, allocation/revision IDs, and non-secret +provenance. Residuals, non-client targets, and client costs without a price are +explicit exceptions. The artifact carries a mandatory disclaimer and never +assigns invoice numbers, performs bookkeeping, or requests/tracks payment. + ## Related Workplans - `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index ee27cf1..d34fd38 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -27,7 +27,7 @@ | 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 | 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-T04 | done | — | 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 | diff --git a/src/fin_hub/attribution.py b/src/fin_hub/attribution.py index 06853e9..64fe1ae 100644 --- a/src/fin_hub/attribution.py +++ b/src/fin_hub/attribution.py @@ -57,3 +57,21 @@ def optional_attribution( "client_id, application_id, and app_instance_id must be supplied together" ) return ClientAttribution(client_id or "", application_id or "", app_instance_id or "") + + +def parse_client_attribution_key(value: str) -> ClientAttribution: + """Parse only the canonical fin-hub client attribution namespace.""" + + parts = value.split("|") + if len(parts) != 3: + raise ValueError("invalid client attribution key") + expected = ("client:", "app:", "instance:") + values: list[str] = [] + for part, prefix in zip(parts, expected, strict=True): + if not part.startswith(prefix): + raise ValueError("invalid client attribution key") + values.append(part.removeprefix(prefix)) + attribution = ClientAttribution(*values) + if attribution.key != value: + raise ValueError("client attribution key is not canonical") + return attribution diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index 88227a9..1853605 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -15,6 +15,7 @@ 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.billing import build_billing_basis from fin_hub.services.evaluate import evaluate_runway from fin_hub.services.evidence import write_runway_evidence from fin_hub.services.ledger import ( @@ -156,6 +157,12 @@ def _cmd_ledger_allocations(args: argparse.Namespace) -> int: return 0 +def _cmd_ledger_billing_basis(args: argparse.Namespace) -> int: + export = build_billing_basis(ledger_path=_ledger_path(args)) + print(json.dumps(export.as_dict(), indent=2, default=str)) + return 0 + + def _cmd_evaluate(args: argparse.Namespace) -> int: report = evaluate_runway( ledger_path=_ledger_path(args), @@ -275,6 +282,13 @@ def build_parser() -> argparse.ArgumentParser: ledger_allocations.add_argument("--ledger", help="Ledger database path") ledger_allocations.set_defaults(func=_cmd_ledger_allocations) + billing_basis = ledger_sub.add_parser( + "billing-basis", + help="Export deterministic per-client reporting basis (never an invoice)", + ) + billing_basis.add_argument("--ledger", help="Ledger database path") + billing_basis.set_defaults(func=_cmd_ledger_billing_basis) + 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 index 903a59f..7bdf5e3 100644 --- a/src/fin_hub/services/allocation.py +++ b/src/fin_hub/services/allocation.py @@ -8,6 +8,7 @@ from datetime import date from decimal import Decimal, ROUND_FLOOR from pathlib import Path +from fin_hub.attribution import parse_client_attribution_key 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 @@ -28,6 +29,7 @@ class AllocatedTarget: @dataclass(frozen=True) class AllocationReport: allocation_id: str + revision_of: str | None method: str financial_fact_ids: tuple[str, ...] period_start: date @@ -108,6 +110,15 @@ def shared_cost_allocations(*, ledger_path: Path | None = None) -> list[Allocati ).fetchone() if fact is None: raise ValueError(f"allocation references missing/current fact {fact_id}") + if fact["cost_attribution_key"] is not None: + try: + parse_client_attribution_key(fact["cost_attribution_key"]) + except ValueError: + pass + else: + raise ValueError( + f"allocation cannot reallocate directly attributed 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: @@ -145,6 +156,7 @@ def shared_cost_allocations(*, ledger_path: Path | None = None) -> list[Allocati reports.append( AllocationReport( allocation_id=allocation.record_id, + revision_of=allocation.revision_of, method=allocation.method, financial_fact_ids=tuple(allocation.financial_fact_ids), period_start=allocation.period_start, diff --git a/src/fin_hub/services/billing.py b/src/fin_hub/services/billing.py new file mode 100644 index 0000000..cd8828f --- /dev/null +++ b/src/fin_hub/services/billing.py @@ -0,0 +1,288 @@ +"""Idempotent per-client billing-basis reporting (never invoice generation).""" + +from __future__ import annotations + +import hashlib +from calendar import monthrange +from dataclasses import asdict, dataclass +from datetime import date +from decimal import Decimal +from pathlib import Path + +from fin_hub.attribution import parse_client_attribution_key +from fin_hub.money import minor_money +from fin_hub.services.allocation import AllocationReport, shared_cost_allocations +from fin_hub.services.ledger import _connect, default_ledger_path + + +@dataclass(frozen=True) +class BillingBasisRecord: + billing_basis_id: str + cost_attribution_key: str + client_id: str + application_id: str + app_instance_id: str + period_month: str + currency: str + price_id: str + price_revision_of: str | None + price_source: str + revenue: Decimal + direct_cost: Decimal + allocated_cost: Decimal + total_cost: Decimal + margin: Decimal + financial_fact_ids: tuple[str, ...] + financial_fact_corrections: tuple[tuple[str, str], ...] + allocation_ids: tuple[str, ...] + allocation_revisions: tuple[tuple[str, str], ...] + provenance: tuple[str, ...] + + def as_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class BillingBasisException: + reason: str + cost_attribution_key: str | None + period_month: str + currency: str + amount: Decimal + evidence_ids: tuple[str, ...] + + def as_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class BillingBasisExport: + schema_version: str + artifact_type: str + records: tuple[BillingBasisRecord, ...] + exceptions: tuple[BillingBasisException, ...] + disclaimer: str + + def as_dict(self) -> dict: + return asdict(self) + + +def _stable_id(*parts: object) -> str: + payload = "\x1f".join(str(part) for part in parts) + return "billing-basis:" + hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _allocation_month(report: AllocationReport) -> str: + if report.period_start.year != report.period_end.year or report.period_start.month != report.period_end.month: + raise ValueError( + f"allocation {report.allocation_id} spans multiple billing months" + ) + expected_end = date( + report.period_start.year, + report.period_start.month, + monthrange(report.period_start.year, report.period_start.month)[1], + ) + if report.period_start.day != 1 or report.period_end != expected_end: + raise ValueError( + f"allocation {report.allocation_id} must cover a complete billing month" + ) + return report.period_start.strftime("%Y-%m") + + +def build_billing_basis(*, ledger_path: Path | None = None) -> BillingBasisExport: + """Build deterministic client-period reporting with explicit omissions.""" + + ledger = ledger_path or default_ledger_path() + allocations = shared_cost_allocations(ledger_path=ledger) + with _connect(ledger) as conn: + price_rows = conn.execute( + "SELECT * FROM engagement_prices WHERE is_current = 1 " + "ORDER BY period_month, cost_attribution_key, currency" + ).fetchall() + fact_rows = conn.execute( + "SELECT * FROM ledger_entries WHERE is_current = 1 ORDER BY financial_fact_id" + ).fetchall() + + prices = { + (row["cost_attribution_key"], row["period_month"], row["currency"]): row + for row in price_rows + } + facts_by_id = {row["financial_fact_id"]: row for row in fact_rows} + direct: dict[tuple[str, str, str], list] = {} + for fact in fact_rows: + key = fact["cost_attribution_key"] + if key is None: + continue + try: + parse_client_attribution_key(key) + except ValueError: + continue + direct.setdefault((key, fact["period_month"], fact["currency"]), []).append(fact) + + allocated: dict[tuple[str, str, str], list[tuple[AllocationReport, Decimal]]] = {} + exceptions: list[BillingBasisException] = [] + for allocation in allocations: + period = _allocation_month(allocation) + for target in allocation.targets: + try: + parse_client_attribution_key(target.target_key) + except ValueError: + exceptions.append( + BillingBasisException( + reason="non_client_allocation_target", + cost_attribution_key=target.target_key, + period_month=period, + currency=allocation.currency, + amount=target.amount, + evidence_ids=(allocation.allocation_id,), + ) + ) + continue + allocated.setdefault( + (target.target_key, period, allocation.currency), [] + ).append((allocation, target.amount)) + if allocation.residual_amount: + exceptions.append( + BillingBasisException( + reason="unattributed_allocation_residual", + cost_attribution_key=None, + period_month=period, + currency=allocation.currency, + amount=allocation.residual_amount, + evidence_ids=(allocation.allocation_id,), + ) + ) + + cost_keys = set(direct) | set(allocated) + for key in sorted(cost_keys - set(prices)): + facts = direct.get(key, []) + allocation_parts = allocated.get(key, []) + amount_minor = sum(int(row["amount_minor"]) for row in facts) + sum( + int(amount / Decimal("0.01")) for _allocation, amount in allocation_parts + ) + exceptions.append( + BillingBasisException( + reason="missing_price_basis", + cost_attribution_key=key[0], + period_month=key[1], + currency=key[2], + amount=minor_money(amount_minor), + evidence_ids=tuple( + sorted( + [row["financial_fact_id"] for row in facts] + + [allocation.allocation_id for allocation, _amount in allocation_parts] + ) + ), + ) + ) + + records: list[BillingBasisRecord] = [] + for key, price in sorted(prices.items()): + attribution = parse_client_attribution_key(key[0]) + facts = direct.get(key, []) + allocation_parts = allocated.get(key, []) + direct_minor = sum(int(row["amount_minor"]) for row in facts) + allocated_minor = sum( + int(amount / Decimal("0.01")) for _allocation, amount in allocation_parts + ) + revenue_minor = int(price["amount_minor"]) + allocated_fact_ids = { + fact_id + for allocation, _amount in allocation_parts + for fact_id in allocation.financial_fact_ids + } + all_evidence_facts = [ + *facts, + *(facts_by_id[fact_id] for fact_id in sorted(allocated_fact_ids)), + ] + fact_ids = tuple( + sorted({row["financial_fact_id"] for row in all_evidence_facts}) + ) + correction_pairs = tuple( + sorted( + (row["financial_fact_id"], row["correction_of"]) + for row in all_evidence_facts + if row["correction_of"] + ) + ) + allocation_ids = tuple( + sorted(allocation.allocation_id for allocation, _amount in allocation_parts) + ) + allocation_revisions = tuple( + sorted( + (allocation.allocation_id, allocation.revision_of) + for allocation, _amount in allocation_parts + if allocation.revision_of + ) + ) + provenance = tuple( + sorted( + {price["source"]} + | { + row["correction_source"] or row["source_document_id"] + for row in all_evidence_facts + } + | { + evidence + for allocation, _amount in allocation_parts + for evidence in allocation.source_evidence + } + ) + ) + total_minor = direct_minor + allocated_minor + basis_id = _stable_id( + key, + price["id"], + price["revision_of"], + fact_ids, + correction_pairs, + allocation_ids, + allocation_revisions, + direct_minor, + allocated_minor, + revenue_minor, + ) + records.append( + BillingBasisRecord( + billing_basis_id=basis_id, + cost_attribution_key=key[0], + client_id=attribution.client_id, + application_id=attribution.application_id, + app_instance_id=attribution.app_instance_id, + period_month=key[1], + currency=key[2], + price_id=price["id"], + price_revision_of=price["revision_of"], + price_source=price["source"], + revenue=minor_money(revenue_minor), + direct_cost=minor_money(direct_minor), + allocated_cost=minor_money(allocated_minor), + total_cost=minor_money(total_minor), + margin=minor_money(revenue_minor - total_minor), + financial_fact_ids=fact_ids, + financial_fact_corrections=correction_pairs, + allocation_ids=allocation_ids, + allocation_revisions=allocation_revisions, + provenance=provenance, + ) + ) + return BillingBasisExport( + schema_version="0.1", + artifact_type="billing_basis_report", + records=tuple(records), + exceptions=tuple( + sorted( + exceptions, + key=lambda item: ( + item.period_month, + item.currency, + item.reason, + item.cost_attribution_key or "", + ), + ) + ), + disclaimer=( + "Reporting basis only; not an invoice, bookkeeping record, payment request, " + "or evidence of payment." + ), + ) diff --git a/tests/test_attribution.py b/tests/test_attribution.py index 26b7020..a145ebe 100644 --- a/tests/test_attribution.py +++ b/tests/test_attribution.py @@ -1,11 +1,16 @@ import pytest -from fin_hub.attribution import ClientAttribution, optional_attribution +from fin_hub.attribution import ( + ClientAttribution, + optional_attribution, + parse_client_attribution_key, +) def test_client_attribution_key_is_stable(): attribution = ClientAttribution(" acme ", "portal", "prod-01") assert attribution.key == "client:acme|app:portal|instance:prod-01" + assert parse_client_attribution_key(attribution.key) == attribution def test_empty_attribution_is_explicitly_unattributed(): @@ -17,3 +22,8 @@ def test_empty_attribution_is_explicitly_unattributed(): def test_client_attribution_rejects_unsafe_parts(invalid: str): with pytest.raises(ValueError): ClientAttribution(invalid, "portal", "prod-01") + + +def test_client_attribution_parser_rejects_other_namespaces(): + with pytest.raises(ValueError, match="invalid"): + parse_client_attribution_key("platform:shared-cluster") diff --git a/tests/test_billing.py b/tests/test_billing.py new file mode 100644 index 0000000..353dd82 --- /dev/null +++ b/tests/test_billing.py @@ -0,0 +1,138 @@ +import sqlite3 +from decimal import Decimal +from pathlib import Path + +from fin_hub.services.billing import build_billing_basis +from fin_hub.services.exchange import ingest_planning_evidence +from fin_hub.services.ledger import import_csv, record_engagement_price + +CLIENT_KEY = "client:acme|app:portal|instance:prod-01" + + +def _seed_billing_ledger(tmp_path: Path): + ledger = tmp_path / "ledger.db" + direct = tmp_path / "direct.csv" + direct.write_text( + "product,amount,currency,invoice_date,environment,client_id,application_id,app_instance_id\n" + "Dedicated service,20.00,EUR,2026-07-01,production,acme,portal,prod-01\n", + encoding="utf-8", + ) + shared = tmp_path / "shared.csv" + shared.write_text( + "product,amount,currency,invoice_date,environment\n" + "Shared cluster,100.00,EUR,2026-07-01,production\n", + encoding="utf-8", + ) + import_csv(direct, "hosteurope", ledger_path=ledger) + import_csv(shared, "hosteurope", ledger_path=ledger) + with sqlite3.connect(ledger) as conn: + shared_fact_id = conn.execute( + "SELECT financial_fact_id FROM ledger_entries " + "WHERE cost_attribution_key IS NULL" + ).fetchone()[0] + price = record_engagement_price( + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + period_month="2026-07", + amount="200.00", + currency="EUR", + source="agreement-v1", + ledger_path=ledger, + ) + allocation = { + "schema_version": "0.1", + "record_type": "allocation", + "record_id": "allocation:shared: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:namespace-cpu-v1"], + "created_at": "2026-08-11T10:00:00Z", + "financial_fact_ids": [shared_fact_id], + "method": "namespace-cpu-v1", + "allocated_amount": "100.00", + "shares": [{"target_key": CLIENT_KEY, "share": "0.80"}], + "residual_share": "0.20", + } + ingest_planning_evidence(allocation, ledger_path=ledger) + return ledger, direct, price, allocation + + +def test_billing_basis_combines_direct_and_allocated_cost_idempotently(tmp_path: Path): + ledger, _direct, price, allocation = _seed_billing_ledger(tmp_path) + + first = build_billing_basis(ledger_path=ledger) + second = build_billing_basis(ledger_path=ledger) + + assert first == second + assert first.artifact_type == "billing_basis_report" + assert "not an invoice" in first.disclaimer + record = first.records[0] + assert record.price_id == price.id + assert record.revenue == Decimal("200.00") + assert record.direct_cost == Decimal("20.00") + assert record.allocated_cost == Decimal("80.00") + assert record.total_cost == Decimal("100.00") + assert record.margin == Decimal("100.00") + assert record.allocation_ids == (allocation["record_id"],) + assert record.financial_fact_ids + assert "agreement-v1" in record.provenance + assert first.exceptions[0].reason == "unattributed_allocation_residual" + assert first.exceptions[0].amount == Decimal("20.00") + + +def test_billing_basis_id_changes_and_exposes_correction_and_price_revision(tmp_path: Path): + ledger, direct, price, _allocation = _seed_billing_ledger(tmp_path) + before = build_billing_basis(ledger_path=ledger).records[0] + direct.write_text( + "product,amount,currency,invoice_date,environment,client_id,application_id,app_instance_id\n" + "Dedicated service,25.00,EUR,2026-07-01,production,acme,portal,prod-01\n", + encoding="utf-8", + ) + import_csv(direct, "hosteurope", ledger_path=ledger, force=True) + revised_price = record_engagement_price( + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + period_month="2026-07", + amount="210.00", + currency="EUR", + source="agreement-v2", + revision_of=price.id, + ledger_path=ledger, + ) + + after = build_billing_basis(ledger_path=ledger).records[0] + + assert after.billing_basis_id != before.billing_basis_id + assert after.price_id == revised_price.id + assert after.price_revision_of == price.id + assert after.financial_fact_corrections + assert after.direct_cost == Decimal("25.00") + assert after.margin == Decimal("105.00") + + +def test_client_cost_without_price_is_an_explicit_exception(tmp_path: Path): + ledger = tmp_path / "ledger.db" + source = tmp_path / "direct.csv" + source.write_text( + "product,amount,currency,invoice_date,client_id,application_id,app_instance_id\n" + "Dedicated service,20.00,EUR,2026-07-01,acme,portal,prod-01\n", + encoding="utf-8", + ) + import_csv(source, "hosteurope", ledger_path=ledger) + + export = build_billing_basis(ledger_path=ledger) + + assert export.records == () + assert export.exceptions[0].reason == "missing_price_basis" + assert export.exceptions[0].cost_attribution_key == CLIENT_KEY + assert export.exceptions[0].amount == Decimal("20.00") 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 8d2870b..9e8ae66 100644 --- a/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md +++ b/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md @@ -142,7 +142,7 @@ missing facts, mismatches, evidence revisions, and duplicate allocation. ```task id: FIN-WP-0002-T04 -status: todo +status: done priority: medium state_hub_task_id: "b5886131-82f6-49fb-a7e3-ba47d5b627a8" ``` @@ -155,6 +155,14 @@ currency, corrections, and provenance. The export must not issue invoices, assign legal invoice numbers, execute or track payments, or represent itself as bookkeeping evidence. +Completed 2026-08-11: added a deterministic v0.1 billing-basis artifact and +`ledger billing-basis` command. Each client-period record contains the current +price/revision, direct and allocated cost, margin, financial-fact/correction +IDs, allocation/revision IDs, and non-secret provenance. Stable IDs change +only when an input basis changes. Residuals, non-client targets, and missing +prices are explicit exceptions. The artifact carries a mandatory reporting- +only disclaimer and has no invoice, bookkeeping, or payment behavior. + ## Select the external invoicing system ```task @@ -172,12 +180,12 @@ not required to settle T00–T03. ## Acceptance -- [ ] Repository scope explicitly allows the reporting and export boundary. -- [ ] Costs can be reported by client × application × instance without making +- [x] Repository scope explicitly allows the reporting and export boundary. +- [x] Costs can be reported by client × application × instance without making fin-hub authoritative for client or resource identity. -- [ ] Revenue and margin are period-aware, currency-aware, and distinguishable +- [x] Revenue and margin are period-aware, currency-aware, and distinguishable from invoices and payments. -- [ ] Shared-infrastructure allocations reconcile to authoritative booked +- [x] Shared-infrastructure allocations reconcile to authoritative booked costs with assumptions and unattributed residuals visible. -- [ ] The billing-basis export is idempotent and contains no payment execution. +- [x] The billing-basis export is idempotent and contains no payment execution. - [ ] A human records the external invoicing/bookkeeping system decision.