diff --git a/.custodian-brief.md b/.custodian-brief.md index f23d6c7..0cbf926 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,20 +2,19 @@ # Custodian Brief — fin-hub **Domain:** financials -**Last synced:** 2026-08-10 18:08 UTC +**Last synced:** 2026-08-10 18:32 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Client attribution and billing basis -Progress: 1/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` +Progress: 2/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` **Open tasks:** -- ! Add engagement revenue and margin reporting `f917bb0b` - ! Define shared-infrastructure allocation `899a5c29` - ! Export a per-client billing basis `b5886131` - ! Select the external invoicing system `d30b606f` -- · Add client attribution `904e9edc` +- · Add engagement revenue and margin reporting `f917bb0b` --- ## MCP Orientation (when available) diff --git a/README.md b/README.md index 824ce58..14df8f4 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,22 @@ uv run finhub ops-costs tests/fixtures/hosteurope.csv Cross-hub coupling (`--emit`) posts non-secret progress events to dev-hub when `STATE_HUB_API` is reachable. +## Client cost attribution + +HostEurope CSV rows may include `client_id`, `application_id`, and +`app_instance_id`. All three must be present together. Fin-hub validates them +as external identifiers and derives the stable key +`client:|app:|instance:`; callers +must not invent a second key format. Rows without all three columns remain +explicitly unattributed for backward compatibility. + +The SQLite ledger adds the attribution columns automatically when an existing +ledger is opened. The `ops-costs` report retains its per-service view and adds +an `attributions` view separated by currency. Client and application identity +remain authoritative outside fin-hub. + ## Related Workplans - `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella - `canon/constitution/bootstrap-protocol_v0.1.md` — funding and roles -- `canon/projects/railiance/business-model-canvas_v0.1.md` — monetization path \ No newline at end of file +- `canon/projects/railiance/business-model-canvas_v0.1.md` — monetization path diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 55b8983..5e19105 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -24,8 +24,8 @@ | task | FIN-WP-0001-T05 | done | — | workplans/FIN-WP-0001-runway-operations-lane.md | | task | FIN-WP-0001-T06 | done | — | workplans/FIN-WP-0001-runway-operations-lane.md | | task | FIN-WP-0002-T00 | done | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md | -| task | FIN-WP-0002-T01 | todo | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md | -| task | FIN-WP-0002-T02 | wait | — | 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 | todo | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md | | task | FIN-WP-0002-T03 | wait | — | 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-T05 | wait | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md | diff --git a/src/fin_hub/attribution.py b/src/fin_hub/attribution.py new file mode 100644 index 0000000..06853e9 --- /dev/null +++ b/src/fin_hub/attribution.py @@ -0,0 +1,59 @@ +"""Stable external client/application/instance attribution keys.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_PART_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + + +def _validate_part(name: str, value: str) -> str: + normalized = value.strip() + if not _PART_PATTERN.fullmatch(normalized): + raise ValueError( + f"{name} must be 1-128 characters using letters, digits, '.', '_', ':', or '-'" + ) + return normalized + + +@dataclass(frozen=True) +class ClientAttribution: + """External identity seam; fin-hub does not own these identities.""" + + client_id: str + application_id: str + app_instance_id: str + + def __post_init__(self) -> None: + object.__setattr__(self, "client_id", _validate_part("client_id", self.client_id)) + object.__setattr__( + self, "application_id", _validate_part("application_id", self.application_id) + ) + object.__setattr__( + self, "app_instance_id", _validate_part("app_instance_id", self.app_instance_id) + ) + + @property + def key(self) -> str: + return ( + f"client:{self.client_id}|app:{self.application_id}|" + f"instance:{self.app_instance_id}" + ) + + +def optional_attribution( + client_id: str | None, + application_id: str | None, + app_instance_id: str | None, +) -> ClientAttribution | None: + """Return an attribution or reject ambiguous partially attributed input.""" + + values = (client_id, application_id, app_instance_id) + if all(value is None or not value.strip() for value in values): + return None + if any(value is None or not value.strip() for value in values): + raise ValueError( + "client_id, application_id, and app_instance_id must be supplied together" + ) + return ClientAttribution(client_id or "", application_id or "", app_instance_id or "") diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index 0393558..d73b9fe 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -87,6 +87,10 @@ def _cmd_ops_costs(args: argparse.Namespace) -> int: amount=row.amount, currency=row.currency, source=row.source, + client_id=row.client_id, + application_id=row.application_id, + app_instance_id=row.app_instance_id, + cost_attribution_key=row.cost_attribution_key, ) for row in parse_hosteurope_csv(Path(args.path)) ] @@ -256,4 +260,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/src/fin_hub/coupling/ops_hub.py b/src/fin_hub/coupling/ops_hub.py index ea71651..00f0602 100644 --- a/src/fin_hub/coupling/ops_hub.py +++ b/src/fin_hub/coupling/ops_hub.py @@ -6,6 +6,8 @@ from collections import defaultdict from dataclasses import dataclass from typing import Iterable +from fin_hub.attribution import optional_attribution + @dataclass(frozen=True) class ServiceCostLine: @@ -15,6 +17,19 @@ class ServiceCostLine: amount: float currency: str source: str + client_id: str | None = None + application_id: str | None = None + app_instance_id: str | None = None + cost_attribution_key: str | None = None + + def __post_init__(self) -> None: + attribution = optional_attribution( + self.client_id, self.application_id, self.app_instance_id + ) + expected_key = attribution.key if attribution else None + if self.cost_attribution_key not in (None, expected_key): + raise ValueError("cost_attribution_key does not match its attribution dimensions") + object.__setattr__(self, "cost_attribution_key", expected_key) def build_service_cost_report( @@ -22,6 +37,7 @@ def build_service_cost_report( ) -> dict: by_service: dict[str, dict] = {} totals_by_month: dict[str, float] = defaultdict(float) + by_attribution: dict[tuple[str, str], dict] = {} for line in lines: bucket = by_service.setdefault( line.service_id, @@ -37,11 +53,31 @@ def build_service_cost_report( bucket["months"][line.period_month] = month_total bucket["total"] += line.amount totals_by_month[line.period_month] += line.amount + key = (line.cost_attribution_key or "unattributed", line.currency) + attribution = by_attribution.setdefault( + key, + { + "cost_attribution_key": line.cost_attribution_key, + "client_id": line.client_id, + "application_id": line.application_id, + "app_instance_id": line.app_instance_id, + "currency": line.currency, + "months": {}, + "total": 0.0, + }, + ) + attribution["months"][line.period_month] = ( + attribution["months"].get(line.period_month, 0.0) + line.amount + ) + attribution["total"] += line.amount services = sorted(by_service.values(), key=lambda item: item["total"], reverse=True) return { "source_hub": "fin-hub", "target_hub": "ops-hub", "signal": "service_cost_attribution", "services": services, + "attributions": sorted( + by_attribution.values(), key=lambda item: item["total"], reverse=True + ), "totals_by_month": dict(sorted(totals_by_month.items())), - } \ No newline at end of file + } diff --git a/src/fin_hub/ingest/hosteurope.py b/src/fin_hub/ingest/hosteurope.py index c3078b0..edbc159 100644 --- a/src/fin_hub/ingest/hosteurope.py +++ b/src/fin_hub/ingest/hosteurope.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import date from pathlib import Path +from fin_hub.attribution import optional_attribution from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows @@ -19,6 +20,10 @@ class HostEuropeCostRow: incurred_on: date | None environment: str = "production" source: str = "hosteurope" + client_id: str | None = None + application_id: str | None = None + app_instance_id: str | None = None + cost_attribution_key: str | None = None def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[HostEuropeCostRow]: @@ -38,6 +43,11 @@ def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[H continue currency = pick(row, "currency") or default_currency environment = pick(row, "environment", "env") or "production" + attribution = optional_attribution( + pick(row, "client_id", "client") or None, + pick(row, "application_id", "app_id", "application") or None, + pick(row, "app_instance_id", "instance_id", "instance") or None, + ) rows.append( HostEuropeCostRow( service_id=service_id, @@ -47,6 +57,10 @@ def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[H period_month=period[:7], incurred_on=incurred_on, environment=environment, + client_id=attribution.client_id if attribution else None, + application_id=attribution.application_id if attribution else None, + app_instance_id=attribution.app_instance_id if attribution else None, + cost_attribution_key=attribution.key if attribution else None, ) ) - return rows \ No newline at end of file + return rows diff --git a/src/fin_hub/models/service_cost.py b/src/fin_hub/models/service_cost.py index e06d53b..c206bab 100644 --- a/src/fin_hub/models/service_cost.py +++ b/src/fin_hub/models/service_cost.py @@ -5,15 +5,25 @@ from __future__ import annotations import uuid from datetime import date -from sqlalchemy import Date, Float, String +from sqlalchemy import CheckConstraint, Date, Float, String, event from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from hub_core.models.base import Base, TimestampMixin +from fin_hub.attribution import optional_attribution class ServiceCost(Base, TimestampMixin): __tablename__ = "fin_service_costs" + __table_args__ = ( + CheckConstraint( + "(client_id IS NULL AND application_id IS NULL AND app_instance_id IS NULL " + "AND cost_attribution_key IS NULL) OR " + "(client_id IS NOT NULL AND application_id IS NOT NULL " + "AND app_instance_id IS NOT NULL AND cost_attribution_key IS NOT NULL)", + name="ck_fin_service_costs_complete_attribution", + ), + ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) service_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) @@ -23,4 +33,20 @@ class ServiceCost(Base, TimestampMixin): currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR") source: Mapped[str] = mapped_column(String(32), nullable=False) incurred_on: Mapped[date | None] = mapped_column(Date, nullable=True) - notes: Mapped[str | None] = mapped_column(String(512), nullable=True) \ No newline at end of file + notes: Mapped[str | None] = mapped_column(String(512), nullable=True) + client_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + application_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + app_instance_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + cost_attribution_key: Mapped[str | None] = mapped_column(String(423), nullable=True, index=True) + + +@event.listens_for(ServiceCost, "before_insert") +@event.listens_for(ServiceCost, "before_update") +def _normalize_service_cost_attribution(_mapper, _connection, target: ServiceCost) -> None: + attribution = optional_attribution( + target.client_id, target.application_id, target.app_instance_id + ) + expected_key = attribution.key if attribution else None + if target.cost_attribution_key not in (None, expected_key): + raise ValueError("cost_attribution_key does not match its attribution dimensions") + target.cost_attribution_key = expected_key diff --git a/src/fin_hub/services/ledger.py b/src/fin_hub/services/ledger.py index 2010458..ab2ef7a 100644 --- a/src/fin_hub/services/ledger.py +++ b/src/fin_hub/services/ledger.py @@ -25,6 +25,10 @@ class LedgerEntry: period_month: str incurred_on: date | None source_path: str + client_id: str | None = None + application_id: str | None = None + app_instance_id: str | None = None + cost_attribution_key: str | None = None @dataclass(frozen=True) @@ -92,6 +96,15 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: ); """ ) + columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")} + for name in ( + "client_id", + "application_id", + "app_instance_id", + "cost_attribution_key", + ): + if name not in columns: + conn.execute(f"ALTER TABLE ledger_entries ADD COLUMN {name} TEXT") def _utc_now() -> str: @@ -171,6 +184,10 @@ def _entries_from_hosteurope(path: Path) -> list[LedgerEntry]: period_month=row.period_month, incurred_on=row.incurred_on, source_path=resolved, + client_id=row.client_id, + application_id=row.application_id, + app_instance_id=row.app_instance_id, + cost_attribution_key=row.cost_attribution_key, ) for row in parse_hosteurope_csv(path) ] @@ -217,8 +234,9 @@ def import_csv( """ INSERT INTO ledger_entries ( source_type, category, label, amount, currency, - period_month, incurred_on, source_path, imported_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + period_month, incurred_on, source_path, imported_at, + client_id, application_id, app_instance_id, cost_attribution_key + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( entry.source_type, @@ -230,6 +248,10 @@ def import_csv( entry.incurred_on.isoformat() if entry.incurred_on else None, entry.source_path, imported_at, + entry.client_id, + entry.application_id, + entry.app_instance_id, + entry.cost_attribution_key, ), ) conn.execute( @@ -300,4 +322,4 @@ def ledger_stats(*, ledger_path: Path | None = None) -> dict: def ledger_stats_json(*, ledger_path: Path | None = None) -> str: - return json.dumps(ledger_stats(ledger_path=ledger_path), indent=2) \ No newline at end of file + return json.dumps(ledger_stats(ledger_path=ledger_path), indent=2) diff --git a/tests/test_attribution.py b/tests/test_attribution.py new file mode 100644 index 0000000..26b7020 --- /dev/null +++ b/tests/test_attribution.py @@ -0,0 +1,19 @@ +import pytest + +from fin_hub.attribution import ClientAttribution, optional_attribution + + +def test_client_attribution_key_is_stable(): + attribution = ClientAttribution(" acme ", "portal", "prod-01") + assert attribution.key == "client:acme|app:portal|instance:prod-01" + + +def test_empty_attribution_is_explicitly_unattributed(): + assert optional_attribution(None, None, None) is None + assert optional_attribution("", "", "") is None + + +@pytest.mark.parametrize("invalid", ["has space", "has/slash", "", "x" * 129]) +def test_client_attribution_rejects_unsafe_parts(invalid: str): + with pytest.raises(ValueError): + ClientAttribution(invalid, "portal", "prod-01") diff --git a/tests/test_coupling.py b/tests/test_coupling.py index 4585247..625f1df 100644 --- a/tests/test_coupling.py +++ b/tests/test_coupling.py @@ -21,4 +21,59 @@ def test_build_service_cost_report_groups_by_service(): report = build_service_cost_report(lines) assert report["signal"] == "service_cost_attribution" assert len(report["services"]) == 2 - assert report["totals_by_month"]["2026-06"] == pytest.approx(99.8) \ No newline at end of file + assert report["totals_by_month"]["2026-06"] == pytest.approx(99.8) + assert report["attributions"][0]["cost_attribution_key"] is None + + +def test_build_service_cost_report_groups_by_client_attribution(): + lines = [ + ServiceCostLine( + service_id="cluster", + environment="production", + period_month="2026-07", + amount=42.0, + currency="EUR", + source="fixture", + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + cost_attribution_key="client:acme|app:portal|instance:prod-01", + ) + ] + + report = build_service_cost_report(lines) + + assert report["attributions"] == [ + { + "cost_attribution_key": "client:acme|app:portal|instance:prod-01", + "client_id": "acme", + "application_id": "portal", + "app_instance_id": "prod-01", + "currency": "EUR", + "months": {"2026-07": 42.0}, + "total": 42.0, + } + ] + + +def test_client_attribution_totals_do_not_mix_currencies(): + common = { + "service_id": "cluster", + "environment": "production", + "period_month": "2026-07", + "source": "fixture", + "client_id": "acme", + "application_id": "portal", + "app_instance_id": "prod-01", + } + report = build_service_cost_report( + [ + ServiceCostLine(amount=42.0, currency="EUR", **common), + ServiceCostLine(amount=50.0, currency="USD", **common), + ] + ) + + assert {(row["currency"], row["total"]) for row in report["attributions"]} == { + ("EUR", 42.0), + ("USD", 50.0), + } diff --git a/tests/test_ingest.py b/tests/test_ingest.py index a8c4b1d..bc17712 100644 --- a/tests/test_ingest.py +++ b/tests/test_ingest.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + 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 @@ -25,4 +27,31 @@ def test_parse_hosteurope_csv(): rows = parse_hosteurope_csv(FIXTURES / "hosteurope.csv") assert len(rows) == 2 assert rows[0].service_id == "dedicated-server-m" - assert rows[0].period_month == "2026-06" \ No newline at end of file + assert rows[0].period_month == "2026-06" + + +def test_parse_hosteurope_client_attribution(tmp_path: Path): + source = tmp_path / "attributed.csv" + source.write_text( + "product,amount,currency,invoice_date,client_id,application_id,app_instance_id\n" + "Managed cluster,42.00,EUR,2026-07-01,acme,portal,prod-01\n", + encoding="utf-8", + ) + + row = parse_hosteurope_csv(source)[0] + assert row.client_id == "acme" + assert row.application_id == "portal" + assert row.app_instance_id == "prod-01" + assert row.cost_attribution_key == "client:acme|app:portal|instance:prod-01" + + +def test_parse_hosteurope_rejects_partial_attribution(tmp_path: Path): + source = tmp_path / "partial.csv" + source.write_text( + "product,amount,currency,invoice_date,client_id\n" + "Managed cluster,42.00,EUR,2026-07-01,acme\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="must be supplied together"): + parse_hosteurope_csv(source) diff --git a/tests/test_ledger.py b/tests/test_ledger.py index b366023..2f254ab 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -1,3 +1,4 @@ +import sqlite3 from pathlib import Path import pytest @@ -36,6 +37,53 @@ def test_ledger_import_skips_unchanged_file(tmp_path: Path): assert second.rows_imported == 0 +def test_ledger_import_preserves_client_attribution(tmp_path: Path): + source = tmp_path / "attributed.csv" + source.write_text( + "product,amount,currency,invoice_date,client_id,application_id,app_instance_id\n" + "Managed cluster,42.00,EUR,2026-07-01,acme,portal,prod-01\n", + encoding="utf-8", + ) + ledger = tmp_path / "ledger.db" + + import_csv(source, "hosteurope", ledger_path=ledger) + + with sqlite3.connect(ledger) as conn: + row = conn.execute( + "SELECT client_id, application_id, app_instance_id, cost_attribution_key " + "FROM ledger_entries" + ).fetchone() + assert row == ( + "acme", + "portal", + "prod-01", + "client:acme|app:portal|instance:prod-01", + ) + + +def test_existing_ledger_schema_is_migrated_additively(tmp_path: Path): + ledger = tmp_path / "legacy.db" + with sqlite3.connect(ledger) as conn: + conn.execute( + "CREATE TABLE ledger_entries (" + "id INTEGER PRIMARY KEY, source_type TEXT NOT NULL, category TEXT NOT NULL, " + "label TEXT NOT NULL, amount REAL NOT NULL, currency TEXT NOT NULL, " + "period_month TEXT NOT NULL, incurred_on TEXT, source_path TEXT NOT NULL, " + "imported_at TEXT NOT NULL)" + ) + + monthly_summary(ledger_path=ledger) + + with sqlite3.connect(ledger) as conn: + columns = {row[1] for row in conn.execute("PRAGMA table_info(ledger_entries)")} + assert { + "client_id", + "application_id", + "app_instance_id", + "cost_attribution_key", + }.issubset(columns) + + def test_evaluate_runway_from_ledger(tmp_path: Path): ledger = tmp_path / "ledger.db" seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0) @@ -57,4 +105,4 @@ def test_build_runway_report_and_write_evidence(tmp_path: Path): opening_balance=12000.0, ) assert output.exists() - assert "runway-dogfood-" in output.name \ No newline at end of file + assert "runway-dogfood-" in output.name diff --git a/tests/test_models.py b/tests/test_models.py index fece89d..15465cc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -20,4 +20,12 @@ def test_model_classes_importable(): assert BurnRate.__tablename__ == "fin_burn_rates" assert RunwayProjection.__tablename__ == "fin_runway_projections" assert TokenSpend.__tablename__ == "fin_token_spends" - assert ServiceCost.__tablename__ == "fin_service_costs" \ No newline at end of file + assert ServiceCost.__tablename__ == "fin_service_costs" + + +def test_service_cost_has_external_attribution_seam(): + columns = ServiceCost.__table__.columns + assert columns["client_id"].nullable is True + assert columns["application_id"].nullable is True + assert columns["app_instance_id"].nullable is True + assert columns["cost_attribution_key"].nullable is True 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 7de8e8c..0818edf 100644 --- a/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md +++ b/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md @@ -68,7 +68,7 @@ authority. ```task id: FIN-WP-0002-T01 -status: todo +status: done priority: high state_hub_task_id: "904e9edc-a434-41e6-a4c7-ebb407469514" ``` @@ -82,11 +82,19 @@ Cover unknown/unattributed values, uniqueness and period semantics, corrections, migrations, validation, and backward compatibility for existing service-level records. +Completed 2026-08-10: added a canonical validated external attribution key, +optional client/application/instance columns to `ServiceCost`, HostEurope CSV +ingestion and the SQLite ledger, additive migration for existing ledgers, and +currency-safe attributed reporting alongside the backward-compatible service +view. Partial or mismatched attribution is rejected; legacy rows remain +explicitly unattributed. Covered by the full test suite and documented in +`README.md`. + ## Add engagement revenue and margin reporting ```task id: FIN-WP-0002-T02 -status: wait +status: todo priority: high state_hub_task_id: "f917bb0b-f44e-4642-9130-c1a004185180" ```