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
|
# Custodian Brief — fin-hub
|
||||||
|
|
||||||
**Domain:** financials
|
**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)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Active Workstreams
|
## Active Workstreams
|
||||||
|
|
||||||
### Client attribution and billing basis
|
### 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:**
|
**Open tasks:**
|
||||||
- ! Add engagement revenue and margin reporting `f917bb0b`
|
|
||||||
- ! Define shared-infrastructure allocation `899a5c29`
|
- ! Define shared-infrastructure allocation `899a5c29`
|
||||||
- ! Export a per-client billing basis `b5886131`
|
- ! Export a per-client billing basis `b5886131`
|
||||||
- ! Select the external invoicing system `d30b606f`
|
- ! Select the external invoicing system `d30b606f`
|
||||||
- · Add client attribution `904e9edc`
|
- · Add engagement revenue and margin reporting `f917bb0b`
|
||||||
|
|
||||||
---
|
---
|
||||||
## MCP Orientation (when available)
|
## 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
|
Cross-hub coupling (`--emit`) posts non-secret progress events to dev-hub when
|
||||||
`STATE_HUB_API` is reachable.
|
`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
|
## Related Workplans
|
||||||
|
|
||||||
- `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella
|
- `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella
|
||||||
- `canon/constitution/bootstrap-protocol_v0.1.md` — funding and roles
|
- `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-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-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-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-T01 | done | — | 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-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-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-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 |
|
| 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,
|
amount=row.amount,
|
||||||
currency=row.currency,
|
currency=row.currency,
|
||||||
source=row.source,
|
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))
|
for row in parse_hosteurope_csv(Path(args.path))
|
||||||
]
|
]
|
||||||
|
|
@ -256,4 +260,4 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
from fin_hub.attribution import optional_attribution
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ServiceCostLine:
|
class ServiceCostLine:
|
||||||
|
|
@ -15,6 +17,19 @@ class ServiceCostLine:
|
||||||
amount: float
|
amount: float
|
||||||
currency: str
|
currency: str
|
||||||
source: 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(
|
def build_service_cost_report(
|
||||||
|
|
@ -22,6 +37,7 @@ def build_service_cost_report(
|
||||||
) -> dict:
|
) -> dict:
|
||||||
by_service: dict[str, dict] = {}
|
by_service: dict[str, dict] = {}
|
||||||
totals_by_month: dict[str, float] = defaultdict(float)
|
totals_by_month: dict[str, float] = defaultdict(float)
|
||||||
|
by_attribution: dict[tuple[str, str], dict] = {}
|
||||||
for line in lines:
|
for line in lines:
|
||||||
bucket = by_service.setdefault(
|
bucket = by_service.setdefault(
|
||||||
line.service_id,
|
line.service_id,
|
||||||
|
|
@ -37,11 +53,31 @@ def build_service_cost_report(
|
||||||
bucket["months"][line.period_month] = month_total
|
bucket["months"][line.period_month] = month_total
|
||||||
bucket["total"] += line.amount
|
bucket["total"] += line.amount
|
||||||
totals_by_month[line.period_month] += 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)
|
services = sorted(by_service.values(), key=lambda item: item["total"], reverse=True)
|
||||||
return {
|
return {
|
||||||
"source_hub": "fin-hub",
|
"source_hub": "fin-hub",
|
||||||
"target_hub": "ops-hub",
|
"target_hub": "ops-hub",
|
||||||
"signal": "service_cost_attribution",
|
"signal": "service_cost_attribution",
|
||||||
"services": services,
|
"services": services,
|
||||||
|
"attributions": sorted(
|
||||||
|
by_attribution.values(), key=lambda item: item["total"], reverse=True
|
||||||
|
),
|
||||||
"totals_by_month": dict(sorted(totals_by_month.items())),
|
"totals_by_month": dict(sorted(totals_by_month.items())),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fin_hub.attribution import optional_attribution
|
||||||
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
|
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,6 +20,10 @@ class HostEuropeCostRow:
|
||||||
incurred_on: date | None
|
incurred_on: date | None
|
||||||
environment: str = "production"
|
environment: str = "production"
|
||||||
source: str = "hosteurope"
|
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]:
|
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
|
continue
|
||||||
currency = pick(row, "currency") or default_currency
|
currency = pick(row, "currency") or default_currency
|
||||||
environment = pick(row, "environment", "env") or "production"
|
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(
|
rows.append(
|
||||||
HostEuropeCostRow(
|
HostEuropeCostRow(
|
||||||
service_id=service_id,
|
service_id=service_id,
|
||||||
|
|
@ -47,6 +57,10 @@ def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[H
|
||||||
period_month=period[:7],
|
period_month=period[:7],
|
||||||
incurred_on=incurred_on,
|
incurred_on=incurred_on,
|
||||||
environment=environment,
|
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
|
import uuid
|
||||||
from datetime import date
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from hub_core.models.base import Base, TimestampMixin
|
from hub_core.models.base import Base, TimestampMixin
|
||||||
|
from fin_hub.attribution import optional_attribution
|
||||||
|
|
||||||
|
|
||||||
class ServiceCost(Base, TimestampMixin):
|
class ServiceCost(Base, TimestampMixin):
|
||||||
__tablename__ = "fin_service_costs"
|
__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)
|
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)
|
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")
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||||
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
incurred_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
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
|
period_month: str
|
||||||
incurred_on: date | None
|
incurred_on: date | None
|
||||||
source_path: str
|
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)
|
@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:
|
def _utc_now() -> str:
|
||||||
|
|
@ -171,6 +184,10 @@ def _entries_from_hosteurope(path: Path) -> list[LedgerEntry]:
|
||||||
period_month=row.period_month,
|
period_month=row.period_month,
|
||||||
incurred_on=row.incurred_on,
|
incurred_on=row.incurred_on,
|
||||||
source_path=resolved,
|
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)
|
for row in parse_hosteurope_csv(path)
|
||||||
]
|
]
|
||||||
|
|
@ -217,8 +234,9 @@ def import_csv(
|
||||||
"""
|
"""
|
||||||
INSERT INTO ledger_entries (
|
INSERT INTO ledger_entries (
|
||||||
source_type, category, label, amount, currency,
|
source_type, category, label, amount, currency,
|
||||||
period_month, incurred_on, source_path, imported_at
|
period_month, incurred_on, source_path, imported_at,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
client_id, application_id, app_instance_id, cost_attribution_key
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
entry.source_type,
|
entry.source_type,
|
||||||
|
|
@ -230,6 +248,10 @@ def import_csv(
|
||||||
entry.incurred_on.isoformat() if entry.incurred_on else None,
|
entry.incurred_on.isoformat() if entry.incurred_on else None,
|
||||||
entry.source_path,
|
entry.source_path,
|
||||||
imported_at,
|
imported_at,
|
||||||
|
entry.client_id,
|
||||||
|
entry.application_id,
|
||||||
|
entry.app_instance_id,
|
||||||
|
entry.cost_attribution_key,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.execute(
|
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:
|
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)
|
report = build_service_cost_report(lines)
|
||||||
assert report["signal"] == "service_cost_attribution"
|
assert report["signal"] == "service_cost_attribution"
|
||||||
assert len(report["services"]) == 2
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
|
from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
|
||||||
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
||||||
from fin_hub.ingest.hosteurope import parse_hosteurope_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")
|
rows = parse_hosteurope_csv(FIXTURES / "hosteurope.csv")
|
||||||
assert len(rows) == 2
|
assert len(rows) == 2
|
||||||
assert rows[0].service_id == "dedicated-server-m"
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -36,6 +37,53 @@ def test_ledger_import_skips_unchanged_file(tmp_path: Path):
|
||||||
assert second.rows_imported == 0
|
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):
|
def test_evaluate_runway_from_ledger(tmp_path: Path):
|
||||||
ledger = tmp_path / "ledger.db"
|
ledger = tmp_path / "ledger.db"
|
||||||
seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0)
|
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,
|
opening_balance=12000.0,
|
||||||
)
|
)
|
||||||
assert output.exists()
|
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 BurnRate.__tablename__ == "fin_burn_rates"
|
||||||
assert RunwayProjection.__tablename__ == "fin_runway_projections"
|
assert RunwayProjection.__tablename__ == "fin_runway_projections"
|
||||||
assert TokenSpend.__tablename__ == "fin_token_spends"
|
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
|
```task
|
||||||
id: FIN-WP-0002-T01
|
id: FIN-WP-0002-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "904e9edc-a434-41e6-a4c7-ebb407469514"
|
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
|
corrections, migrations, validation, and backward compatibility for existing
|
||||||
service-level records.
|
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
|
## Add engagement revenue and margin reporting
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: FIN-WP-0002-T02
|
id: FIN-WP-0002-T02
|
||||||
status: wait
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f917bb0b-f44e-4642-9130-c1a004185180"
|
state_hub_task_id: "f917bb0b-f44e-4642-9130-c1a004185180"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue