Implement client cost attribution
This commit is contained in:
parent
d2b9bc4b32
commit
33883e0977
15 changed files with 362 additions and 21 deletions
|
|
@ -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)
|
||||
|
|
|
|||
16
README.md
16
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:<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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
59
src/fin_hub/attribution.py
Normal file
59
src/fin_hub/attribution.py
Normal 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 "")
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
19
tests/test_attribution.py
Normal 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")
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue