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

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

View file

@ -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",
]
]

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