Implement client cost attribution

This commit is contained in:
tegwick 2026-08-10 20:32:09 +02:00
parent d2b9bc4b32
commit 33883e0977
15 changed files with 362 additions and 21 deletions

View file

@ -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)

View file

@ -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:<client_id>|app:<application_id>|instance:<app_instance_id>`; 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
- `canon/projects/railiance/business-model-canvas_v0.1.md` — monetization path

View file

@ -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 |

View file

@ -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 "")

View file

@ -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())
sys.exit(main())

View file

@ -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())),
}
}

View file

@ -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
return rows

View file

@ -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)
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

View file

@ -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)
return json.dumps(ledger_stats(ledger_path=ledger_path), indent=2)

19
tests/test_attribution.py Normal file
View file

@ -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")

View file

@ -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)
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),
}

View file

@ -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"
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)

View file

@ -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
assert "runway-dogfood-" in output.name

View file

@ -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"
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

View file

@ -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"
```