diff --git a/.custodian-brief.md b/.custodian-brief.md index 0cbf926..7e7050a 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,19 +2,18 @@ # Custodian Brief — fin-hub **Domain:** financials -**Last synced:** 2026-08-10 18:32 UTC +**Last synced:** 2026-08-10 18:42 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Client attribution and billing basis -Progress: 2/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` +Progress: 3/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` **Open tasks:** - ! Define shared-infrastructure allocation `899a5c29` - ! Export a per-client billing basis `b5886131` - ! Select the external invoicing system `d30b606f` -- · Add engagement revenue and margin reporting `f917bb0b` --- ## MCP Orientation (when available) diff --git a/README.md b/README.md index 14df8f4..cbdaf2c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ uv sync uv run pytest uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000 uv run finhub ledger import cloud tests/fixtures/cloud-costs.csv +uv run finhub ledger set-price --client acme --application portal --instance prod-01 --period 2026-07 --amount 100 --source agreement-2026-01 +uv run finhub ledger margins uv run finhub evaluate uv run finhub evidence --seed-fixtures uv run finhub serve @@ -46,6 +48,12 @@ 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. +Engagement prices are reporting entitlements, not invoices or received +payments. A price is identified by client/application/instance, reporting +month, and currency. Corrections are append-only: pass the current record ID +to `ledger set-price --revision-of`; margin reports use the newest revision +and preserve the earlier price for auditability. + ## Related Workplans - `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 5e19105..dc51b2b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -25,7 +25,7 @@ | 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 | 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-T02 | done | — | 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/cli.py b/src/fin_hub/cli.py index d73b9fe..32028b9 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -17,9 +17,11 @@ from fin_hub.services.alerts import evaluate_budget_alerts from fin_hub.services.evaluate import evaluate_runway from fin_hub.services.evidence import write_runway_evidence from fin_hub.services.ledger import ( + client_margin_report, default_ledger_path, import_csv, ledger_stats_json, + record_engagement_price, set_opening_balance, ) from fin_hub.services.runway import compute_runway @@ -125,6 +127,28 @@ def _cmd_ledger_set_balance(args: argparse.Namespace) -> int: return 0 +def _cmd_ledger_set_price(args: argparse.Namespace) -> int: + price = record_engagement_price( + client_id=args.client, + application_id=args.application, + app_instance_id=args.instance, + period_month=args.period, + amount=args.amount, + currency=args.currency, + source=args.source, + revision_of=args.revision_of, + ledger_path=_ledger_path(args), + ) + print(json.dumps(price.__dict__, indent=2)) + return 0 + + +def _cmd_ledger_margins(args: argparse.Namespace) -> int: + rows = client_margin_report(ledger_path=_ledger_path(args)) + print(json.dumps([row.as_dict() for row in rows], indent=2)) + return 0 + + def _cmd_evaluate(args: argparse.Namespace) -> int: report = evaluate_runway( ledger_path=_ledger_path(args), @@ -217,6 +241,26 @@ def build_parser() -> argparse.ArgumentParser: ledger_balance.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)") ledger_balance.set_defaults(func=_cmd_ledger_set_balance) + ledger_price = ledger_sub.add_parser( + "set-price", help="Record or revise an engagement price for a reporting period" + ) + ledger_price.add_argument("--client", required=True) + ledger_price.add_argument("--application", required=True) + ledger_price.add_argument("--instance", required=True) + ledger_price.add_argument("--period", required=True, help="Reporting month in YYYY-MM") + ledger_price.add_argument("--amount", required=True, type=float) + ledger_price.add_argument("--currency", default="EUR") + ledger_price.add_argument("--source", required=True) + ledger_price.add_argument("--revision-of") + ledger_price.add_argument("--ledger", help="Ledger database path") + ledger_price.set_defaults(func=_cmd_ledger_set_price) + + ledger_margins = ledger_sub.add_parser( + "margins", help="Report revenue, attributed cost, and margin by engagement" + ) + ledger_margins.add_argument("--ledger", help="Ledger database path") + ledger_margins.set_defaults(func=_cmd_ledger_margins) + evaluate = sub.add_parser( "evaluate", help="Evaluate runway from ledger burns (cron/systemd friendly)", diff --git a/src/fin_hub/models/__init__.py b/src/fin_hub/models/__init__.py index 6bfd571..3c05246 100644 --- a/src/fin_hub/models/__init__.py +++ b/src/fin_hub/models/__init__.py @@ -2,6 +2,7 @@ from fin_hub.models.budget import Budget, BurnRate, Commitment, RunwayProjection, TokenSpend from fin_hub.models.service_cost import ServiceCost +from fin_hub.models.engagement_price import EngagementPrice __all__ = [ "Budget", @@ -9,5 +10,6 @@ __all__ = [ "Commitment", "RunwayProjection", "ServiceCost", + "EngagementPrice", "TokenSpend", -] \ No newline at end of file +] diff --git a/src/fin_hub/models/engagement_price.py b/src/fin_hub/models/engagement_price.py new file mode 100644 index 0000000..ce733a0 --- /dev/null +++ b/src/fin_hub/models/engagement_price.py @@ -0,0 +1,33 @@ +"""Period-effective engagement pricing for client margin reporting.""" + +from __future__ import annotations + +import uuid +from datetime import date + +from sqlalchemy import Date, Float, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from hub_core.models.base import Base, TimestampMixin + + +class EngagementPrice(Base, TimestampMixin): + """A reporting entitlement price, not an invoice or payment record.""" + + __tablename__ = "fin_engagement_prices" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + application_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + app_instance_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + cost_attribution_key: Mapped[str] = mapped_column(String(423), nullable=False, index=True) + effective_from: Mapped[date] = mapped_column(Date, nullable=False, index=True) + effective_to: Mapped[date | None] = mapped_column(Date, nullable=True) + amount: Mapped[float] = mapped_column(Float, nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR") + source: Mapped[str] = mapped_column(String(128), nullable=False) + revision_of: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("fin_engagement_prices.id"), nullable=True + ) + notes: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/src/fin_hub/services/ledger.py b/src/fin_hub/services/ledger.py index ab2ef7a..e3f004b 100644 --- a/src/fin_hub/services/ledger.py +++ b/src/fin_hub/services/ledger.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import sqlite3 +import uuid from dataclasses import asdict, dataclass from datetime import date, datetime, timezone from pathlib import Path @@ -53,6 +54,38 @@ class ImportResult: return asdict(self) +@dataclass(frozen=True) +class EngagementPriceRecord: + id: str + client_id: str + application_id: str + app_instance_id: str + cost_attribution_key: str + period_month: str + amount: float + currency: str + source: str + revision_of: str | None + recorded_at: str + + +@dataclass(frozen=True) +class ClientMargin: + cost_attribution_key: str + client_id: str + application_id: str + app_instance_id: str + period_month: str + currency: str + revenue: float + attributed_cost: float + margin: float + price_id: str + + def as_dict(self) -> dict: + return asdict(self) + + def default_ledger_path() -> Path: return DEFAULT_LEDGER_PATH @@ -94,6 +127,23 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS engagement_prices ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + application_id TEXT NOT NULL, + app_instance_id TEXT NOT NULL, + cost_attribution_key TEXT NOT NULL, + period_month TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT NOT NULL, + source TEXT NOT NULL, + revision_of TEXT REFERENCES engagement_prices(id), + recorded_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS ix_engagement_prices_basis + ON engagement_prices (cost_attribution_key, period_month, currency); """ ) columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")} @@ -304,6 +354,134 @@ def monthly_burn_series( return burns +def record_engagement_price( + *, + client_id: str, + application_id: str, + app_instance_id: str, + period_month: str, + amount: float, + currency: str, + source: str, + ledger_path: Path | None = None, + price_id: str | None = None, + revision_of: str | None = None, +) -> EngagementPriceRecord: + from fin_hub.attribution import ClientAttribution + + attribution = ClientAttribution(client_id, application_id, app_instance_id) + if len(period_month) != 7 or period_month[4] != "-": + raise ValueError("period_month must use YYYY-MM") + try: + date.fromisoformat(f"{period_month}-01") + except ValueError as exc: + raise ValueError("period_month must use YYYY-MM") from exc + normalized_currency = currency.strip().upper() + if len(normalized_currency) != 3 or not normalized_currency.isalpha(): + raise ValueError("currency must be a three-letter code") + if amount < 0: + raise ValueError("engagement price amount cannot be negative") + if not source.strip(): + raise ValueError("source is required") + + ledger = ledger_path or default_ledger_path() + identifier = price_id or str(uuid.uuid4()) + recorded_at = _utc_now() + with _connect(ledger) as conn: + basis = (attribution.key, period_month, normalized_currency) + current = conn.execute( + "SELECT id FROM engagement_prices WHERE cost_attribution_key = ? " + "AND period_month = ? AND currency = ? " + "ORDER BY recorded_at DESC, rowid DESC LIMIT 1", + basis, + ).fetchone() + if current is not None and revision_of != current["id"]: + raise ValueError(f"revision_of must reference current price {current['id']}") + if current is None and revision_of is not None: + raise ValueError("revision_of cannot be used without an existing price") + conn.execute( + "INSERT INTO engagement_prices (" + "id, client_id, application_id, app_instance_id, cost_attribution_key, " + "period_month, amount, currency, source, revision_of, recorded_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + identifier, + attribution.client_id, + attribution.application_id, + attribution.app_instance_id, + attribution.key, + period_month, + amount, + normalized_currency, + source.strip(), + revision_of, + recorded_at, + ), + ) + conn.commit() + return EngagementPriceRecord( + id=identifier, + client_id=attribution.client_id, + application_id=attribution.application_id, + app_instance_id=attribution.app_instance_id, + cost_attribution_key=attribution.key, + period_month=period_month, + amount=amount, + currency=normalized_currency, + source=source.strip(), + revision_of=revision_of, + recorded_at=recorded_at, + ) + + +def client_margin_report(*, ledger_path: Path | None = None) -> list[ClientMargin]: + ledger = ledger_path or default_ledger_path() + with _connect(ledger) as conn: + rows = conn.execute( + """ + WITH latest_prices AS ( + SELECT p.* + FROM engagement_prices p + WHERE NOT EXISTS ( + SELECT 1 FROM engagement_prices revision + WHERE revision.revision_of = p.id + ) + ), attributed_costs AS ( + SELECT cost_attribution_key, period_month, currency, + SUM(amount) AS attributed_cost + FROM ledger_entries + WHERE cost_attribution_key IS NOT NULL + GROUP BY cost_attribution_key, period_month, currency + ) + SELECT p.id AS price_id, p.client_id, p.application_id, + p.app_instance_id, p.cost_attribution_key, p.period_month, + p.currency, p.amount AS revenue, + COALESCE(c.attributed_cost, 0) AS attributed_cost + FROM latest_prices p + LEFT JOIN attributed_costs c + ON c.cost_attribution_key = p.cost_attribution_key + AND c.period_month = p.period_month + AND c.currency = p.currency + ORDER BY p.period_month, p.client_id, p.application_id, p.app_instance_id + """ + ).fetchall() + return [ + ClientMargin( + cost_attribution_key=row["cost_attribution_key"], + client_id=row["client_id"], + application_id=row["application_id"], + app_instance_id=row["app_instance_id"], + period_month=row["period_month"], + currency=row["currency"], + revenue=float(row["revenue"]), + attributed_cost=float(row["attributed_cost"]), + margin=float(row["revenue"]) - float(row["attributed_cost"]), + price_id=row["price_id"], + ) + for row in rows + ] + + def ledger_stats(*, ledger_path: Path | None = None) -> dict: ledger = ledger_path or default_ledger_path() balance, balance_currency = get_opening_balance(ledger) diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 2f254ab..c0b16e0 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -5,7 +5,14 @@ import pytest from fin_hub.services.evaluate import evaluate_runway from fin_hub.services.evidence import build_runway_report, seed_fixture_ledger, write_runway_evidence -from fin_hub.services.ledger import import_csv, monthly_burn_series, monthly_summary, set_opening_balance +from fin_hub.services.ledger import ( + client_margin_report, + import_csv, + monthly_burn_series, + monthly_summary, + record_engagement_price, + set_opening_balance, +) FIXTURES = Path(__file__).parent / "fixtures" @@ -84,6 +91,107 @@ def test_existing_ledger_schema_is_migrated_additively(tmp_path: Path): }.issubset(columns) +def test_engagement_price_and_margin_report(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) + price = record_engagement_price( + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + period_month="2026-07", + amount=100.0, + currency="eur", + source="agreement-2026-01", + ledger_path=ledger, + ) + + margins = client_margin_report(ledger_path=ledger) + + assert margins[0].price_id == price.id + assert margins[0].revenue == 100.0 + assert margins[0].attributed_cost == 42.0 + assert margins[0].margin == 58.0 + assert margins[0].currency == "EUR" + + +def test_engagement_price_revision_supersedes_prior_price(tmp_path: Path): + ledger = tmp_path / "ledger.db" + original = record_engagement_price( + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + period_month="2026-07", + amount=100.0, + currency="EUR", + source="agreement-v1", + ledger_path=ledger, + ) + revision = record_engagement_price( + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + period_month="2026-07", + amount=120.0, + currency="EUR", + source="agreement-v2", + revision_of=original.id, + ledger_path=ledger, + ) + + margins = client_margin_report(ledger_path=ledger) + + assert [(row.price_id, row.revenue) for row in margins] == [(revision.id, 120.0)] + + +def test_engagement_price_requires_explicit_revision(tmp_path: Path): + ledger = tmp_path / "ledger.db" + kwargs = { + "client_id": "acme", + "application_id": "portal", + "app_instance_id": "prod-01", + "period_month": "2026-07", + "amount": 100.0, + "currency": "EUR", + "source": "agreement", + "ledger_path": ledger, + } + original = record_engagement_price(**kwargs) + + with pytest.raises(ValueError, match=original.id): + record_engagement_price(**kwargs) + + +def test_margin_does_not_join_cost_in_another_currency(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,USD,2026-07-01,acme,portal,prod-01\n", + encoding="utf-8", + ) + ledger = tmp_path / "ledger.db" + import_csv(source, "hosteurope", ledger_path=ledger) + record_engagement_price( + client_id="acme", + application_id="portal", + app_instance_id="prod-01", + period_month="2026-07", + amount=100.0, + currency="EUR", + source="agreement", + ledger_path=ledger, + ) + + margin = client_margin_report(ledger_path=ledger)[0] + assert margin.attributed_cost == 0.0 + assert margin.margin == 100.0 + + def test_evaluate_runway_from_ledger(tmp_path: Path): ledger = tmp_path / "ledger.db" seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0) diff --git a/tests/test_models.py b/tests/test_models.py index 15465cc..c50509b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,14 @@ """Smoke tests for fin-hub model registration.""" -from fin_hub.models import Budget, BurnRate, Commitment, RunwayProjection, ServiceCost, TokenSpend +from fin_hub.models import ( + Budget, + BurnRate, + Commitment, + EngagementPrice, + RunwayProjection, + ServiceCost, + TokenSpend, +) from hub_core.models.base import Base @@ -12,6 +20,7 @@ def test_fin_models_register_on_metadata(): assert "fin_runway_projections" in tables assert "fin_token_spends" in tables assert "fin_service_costs" in tables + assert "fin_engagement_prices" in tables def test_model_classes_importable(): @@ -21,6 +30,7 @@ def test_model_classes_importable(): assert RunwayProjection.__tablename__ == "fin_runway_projections" assert TokenSpend.__tablename__ == "fin_token_spends" assert ServiceCost.__tablename__ == "fin_service_costs" + assert EngagementPrice.__tablename__ == "fin_engagement_prices" def test_service_cost_has_external_attribution_seam(): 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 0818edf..861cc8e 100644 --- a/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md +++ b/workplans/FIN-WP-0002-client-attribution-and-billing-basis.md @@ -94,7 +94,7 @@ explicitly unattributed. Covered by the full test suite and documented in ```task id: FIN-WP-0002-T02 -status: todo +status: done priority: high state_hub_task_id: "f917bb0b-f44e-4642-9130-c1a004185180" ``` @@ -105,6 +105,13 @@ and application. Preserve currency, effective periods, provenance, and price revisions; do not treat a price record as an issued invoice or received payment. +Completed 2026-08-10: added versioned engagement-price records with stable +IDs, reporting month, currency, source provenance, and explicit append-only +revision links. The SQLite ledger and CLI now report revenue, attributed cost, +and margin by client/application/instance without representing prices as +invoices or payments. Currency mismatches cannot silently join, superseded +prices remain auditable, and duplicate prices require an explicit revision. + ## Define shared-infrastructure allocation ```task