Add engagement margin reporting

This commit is contained in:
tegwick 2026-08-10 20:43:05 +02:00
parent 33883e0977
commit 8338c501ed
10 changed files with 397 additions and 8 deletions

View file

@ -2,19 +2,18 @@
# Custodian Brief — fin-hub # Custodian Brief — fin-hub
**Domain:** financials **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)* **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: 2/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9` Progress: 3/6 done | workplan_id: `ebc1d2de-ae11-4cde-b860-047922fc74b9`
**Open tasks:** **Open tasks:**
- ! 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 engagement revenue and margin reporting `f917bb0b`
--- ---
## MCP Orientation (when available) ## MCP Orientation (when available)

View file

@ -22,6 +22,8 @@ uv sync
uv run pytest uv run pytest
uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000 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 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 evaluate
uv run finhub evidence --seed-fixtures uv run finhub evidence --seed-fixtures
uv run finhub serve 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 an `attributions` view separated by currency. Client and application identity
remain authoritative outside fin-hub. 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 ## Related Workplans
- `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella - `the-custodian/workplans/CUST-WP-0025-fos-hub-bootstrap.md` — umbrella

View file

@ -25,7 +25,7 @@
| 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 | 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-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 |

View file

@ -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.evaluate import evaluate_runway
from fin_hub.services.evidence import write_runway_evidence from fin_hub.services.evidence import write_runway_evidence
from fin_hub.services.ledger import ( from fin_hub.services.ledger import (
client_margin_report,
default_ledger_path, default_ledger_path,
import_csv, import_csv,
ledger_stats_json, ledger_stats_json,
record_engagement_price,
set_opening_balance, set_opening_balance,
) )
from fin_hub.services.runway import compute_runway from fin_hub.services.runway import compute_runway
@ -125,6 +127,28 @@ def _cmd_ledger_set_balance(args: argparse.Namespace) -> int:
return 0 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: def _cmd_evaluate(args: argparse.Namespace) -> int:
report = evaluate_runway( report = evaluate_runway(
ledger_path=_ledger_path(args), 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.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
ledger_balance.set_defaults(func=_cmd_ledger_set_balance) 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 = sub.add_parser(
"evaluate", "evaluate",
help="Evaluate runway from ledger burns (cron/systemd friendly)", help="Evaluate runway from ledger burns (cron/systemd friendly)",

View file

@ -2,6 +2,7 @@
from fin_hub.models.budget import Budget, BurnRate, Commitment, RunwayProjection, TokenSpend from fin_hub.models.budget import Budget, BurnRate, Commitment, RunwayProjection, TokenSpend
from fin_hub.models.service_cost import ServiceCost from fin_hub.models.service_cost import ServiceCost
from fin_hub.models.engagement_price import EngagementPrice
__all__ = [ __all__ = [
"Budget", "Budget",
@ -9,5 +10,6 @@ __all__ = [
"Commitment", "Commitment",
"RunwayProjection", "RunwayProjection",
"ServiceCost", "ServiceCost",
"EngagementPrice",
"TokenSpend", "TokenSpend",
] ]

View file

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

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
import uuid
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
@ -53,6 +54,38 @@ class ImportResult:
return asdict(self) 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: def default_ledger_path() -> Path:
return DEFAULT_LEDGER_PATH return DEFAULT_LEDGER_PATH
@ -94,6 +127,23 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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)")} columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")}
@ -304,6 +354,134 @@ def monthly_burn_series(
return burns 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: def ledger_stats(*, ledger_path: Path | None = None) -> dict:
ledger = ledger_path or default_ledger_path() ledger = ledger_path or default_ledger_path()
balance, balance_currency = get_opening_balance(ledger) balance, balance_currency = get_opening_balance(ledger)

View file

@ -5,7 +5,14 @@ import pytest
from fin_hub.services.evaluate import evaluate_runway 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.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" FIXTURES = Path(__file__).parent / "fixtures"
@ -84,6 +91,107 @@ def test_existing_ledger_schema_is_migrated_additively(tmp_path: Path):
}.issubset(columns) }.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): 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)

View file

@ -1,6 +1,14 @@
"""Smoke tests for fin-hub model registration.""" """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 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_runway_projections" in tables
assert "fin_token_spends" in tables assert "fin_token_spends" in tables
assert "fin_service_costs" in tables assert "fin_service_costs" in tables
assert "fin_engagement_prices" in tables
def test_model_classes_importable(): def test_model_classes_importable():
@ -21,6 +30,7 @@ def test_model_classes_importable():
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"
assert EngagementPrice.__tablename__ == "fin_engagement_prices"
def test_service_cost_has_external_attribution_seam(): def test_service_cost_has_external_attribution_seam():

View file

@ -94,7 +94,7 @@ explicitly unattributed. Covered by the full test suite and documented in
```task ```task
id: FIN-WP-0002-T02 id: FIN-WP-0002-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "f917bb0b-f44e-4642-9130-c1a004185180" 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 revisions; do not treat a price record as an issued invoice or received
payment. 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 ## Define shared-infrastructure allocation
```task ```task