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

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