diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index d5de547..2f25d11 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -27,6 +27,8 @@ uv run finhub ledger set-balance 12000 uv run finhub ledger import cloud tests/fixtures/cloud-costs.csv uv run finhub ledger import ai-plan tests/fixtures/ai-plans.csv uv run finhub ledger commitments +uv run finhub ledger set-entitlement --provider anthropic --plan claude-max --period 2026-08 --unit plan --plan-label "Max 20x" --source vendor-plan +uv run finhub ledger plan-month --period 2026-08 uv run finhub ledger summary # CLI — scheduled evaluation (cron/systemd friendly) diff --git a/README.md b/README.md index dd7319f..8a70347 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ 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 ai-plan tests/fixtures/ai-plans.csv uv run finhub ledger commitments +uv run finhub ledger set-entitlement --provider anthropic --plan claude-max --period 2026-08 --unit plan --plan-label "Max 20x" --source vendor-plan +uv run finhub ledger plan-month --period 2026-08 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 ledger allocations diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index 2e3e446..44d48ab 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -25,7 +25,10 @@ from fin_hub.services.ledger import ( import_csv, ledger_stats_json, list_current_commitments, + list_current_plan_entitlements, + plan_month_report, record_engagement_price, + record_plan_entitlement, set_opening_balance, ) from fin_hub.services.runway import compute_runway @@ -165,6 +168,42 @@ def _cmd_ledger_commitments(args: argparse.Namespace) -> int: return 0 +def _cmd_ledger_set_entitlement(args: argparse.Namespace) -> int: + record = record_plan_entitlement( + provider=args.provider, + provider_account=args.account, + plan=args.plan, + period_month=args.period, + unit=args.unit, + quantity=args.quantity, + plan_label=args.plan_label, + currency=args.currency, + source=args.source, + revision_of=args.revision_of, + ledger_path=_ledger_path(args), + ) + print(json.dumps(record.as_dict(), indent=2, default=str)) + return 0 + + +def _cmd_ledger_entitlements(args: argparse.Namespace) -> int: + rows = list_current_plan_entitlements( + ledger_path=_ledger_path(args), + period_month=args.period, + ) + print(json.dumps([row.as_dict() for row in rows], indent=2, default=str)) + return 0 + + +def _cmd_ledger_plan_month(args: argparse.Namespace) -> int: + rows = plan_month_report( + ledger_path=_ledger_path(args), + period_month=args.period, + ) + print(json.dumps([row.as_dict() for row in rows], indent=2, default=str)) + return 0 + + def _cmd_ledger_allocations(args: argparse.Namespace) -> int: reports = shared_cost_allocations(ledger_path=_ledger_path(args)) print(json.dumps([report.as_dict() for report in reports], indent=2, default=str)) @@ -302,6 +341,45 @@ def build_parser() -> argparse.ArgumentParser: ledger_commitments.add_argument("--ledger", help="Ledger database path") ledger_commitments.set_defaults(func=_cmd_ledger_commitments) + ledger_entitlement = ledger_sub.add_parser( + "set-entitlement", + help="Record or revise entitled AI-plan capacity (not an invoice)", + ) + ledger_entitlement.add_argument("--provider", required=True) + ledger_entitlement.add_argument("--account") + ledger_entitlement.add_argument("--plan", required=True) + ledger_entitlement.add_argument("--period", required=True, help="Reporting month in YYYY-MM") + ledger_entitlement.add_argument( + "--unit", required=True, choices=["token", "multiplier", "plan"] + ) + ledger_entitlement.add_argument( + "--quantity", + help="Entitled quantity, or 'unknown'. Required for multiplier; omit for unknown tokens.", + ) + ledger_entitlement.add_argument( + "--plan-label", help="Declared plan name such as 'Max 20x' when quantity is unknown" + ) + ledger_entitlement.add_argument("--currency", help="Optional ISO currency for join safety") + ledger_entitlement.add_argument("--source", required=True) + ledger_entitlement.add_argument("--revision-of") + ledger_entitlement.add_argument("--ledger", help="Ledger database path") + ledger_entitlement.set_defaults(func=_cmd_ledger_set_entitlement) + + ledger_entitlements = ledger_sub.add_parser( + "entitlements", help="List current AI-plan entitlements" + ) + ledger_entitlements.add_argument("--period", help="Reporting month in YYYY-MM") + ledger_entitlements.add_argument("--ledger", help="Ledger database path") + ledger_entitlements.set_defaults(func=_cmd_ledger_entitlements) + + ledger_plan_month = ledger_sub.add_parser( + "plan-month", + help="Report booked AI-plan cost plus entitled capacity for a month", + ) + ledger_plan_month.add_argument("--period", help="Reporting month in YYYY-MM") + ledger_plan_month.add_argument("--ledger", help="Ledger database path") + ledger_plan_month.set_defaults(func=_cmd_ledger_plan_month) + ledger_allocations = ledger_sub.add_parser( "allocations", help="Reconcile resource-control allocation evidence to booked facts", diff --git a/src/fin_hub/services/ledger.py b/src/fin_hub/services/ledger.py index 03693b8..9057517 100644 --- a/src/fin_hub/services/ledger.py +++ b/src/fin_hub/services/ledger.py @@ -98,6 +98,58 @@ class CommitmentRecord: return asdict(self) +ENTITLEMENT_UNITS = frozenset({"token", "multiplier", "plan"}) + + +@dataclass(frozen=True) +class PlanEntitlementRecord: + id: str + provider: str + provider_account: str | None + plan: str + period_month: str + quantity: Decimal | None + quantity_unknown: bool + unit: str + plan_label: str | None + currency: str | None + source: str + revision_of: str | None + recorded_at: str + + def as_dict(self) -> dict: + payload = asdict(self) + payload["quantity"] = None if self.quantity is None else str(self.quantity) + return payload + + +@dataclass(frozen=True) +class PlanMonthRow: + period_month: str + provider: str + provider_account: str | None + plan: str + booked_amount: Decimal | None + booked_currency: str | None + financial_fact_id: str | None + entitled_quantity: Decimal | None + quantity_unknown: bool + unit: str | None + plan_label: str | None + entitlement_id: str | None + join_status: str + + def as_dict(self) -> dict: + payload = asdict(self) + payload["booked_amount"] = ( + None if self.booked_amount is None else str(self.booked_amount) + ) + payload["entitled_quantity"] = ( + None if self.entitled_quantity is None else str(self.entitled_quantity) + ) + return payload + + @dataclass(frozen=True) class EngagementPriceRecord: id: str @@ -206,6 +258,23 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: recorded_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS plan_entitlements ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_account TEXT, + plan TEXT NOT NULL, + period_month TEXT NOT NULL, + quantity TEXT, + quantity_unknown INTEGER NOT NULL DEFAULT 0, + unit TEXT NOT NULL, + plan_label TEXT, + currency TEXT, + source TEXT NOT NULL, + revision_of TEXT, + is_current INTEGER NOT NULL DEFAULT 1, + recorded_at TEXT NOT NULL + ); + """ ) columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")} @@ -284,6 +353,12 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: "provider, IFNULL(provider_account, ''), plan, currency" ") WHERE is_current = 1" ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_plan_entitlements_current " + "ON plan_entitlements (" + "provider, IFNULL(provider_account, ''), plan, period_month" + ") WHERE is_current = 1" + ) conn.commit() @@ -661,6 +736,267 @@ def list_current_commitments( ] +def _normalize_entitlement_quantity( + quantity: Decimal | str | int | float | None, + *, + unit: str, + plan_label: str | None, +) -> tuple[Decimal | None, bool]: + if quantity is None or (isinstance(quantity, str) and quantity.strip().lower() in {"", "unknown"}): + if not plan_label: + raise ValueError("unknown entitlement requires plan_label") + if unit == "multiplier": + raise ValueError("multiplier entitlements require a quantity") + return None, True + try: + value = Decimal(str(quantity)) + except Exception as error: + raise ValueError("entitlement quantity must be a finite decimal") from error + if not value.is_finite() or value < 0: + raise ValueError("entitlement quantity must be a finite non-negative decimal") + if unit == "multiplier" and value == 0: + raise ValueError("multiplier entitlements require a positive quantity") + if unit == "plan": + raise ValueError("plan-unit entitlements cannot carry a quantity") + return value, False + + +def record_plan_entitlement( + *, + provider: str, + plan: str, + period_month: str, + unit: str, + source: str, + quantity: Decimal | str | int | float | None = None, + plan_label: str | None = None, + provider_account: str | None = None, + currency: str | None = None, + revision_of: str | None = None, + entitlement_id: str | None = None, + ledger_path: Path | None = None, +) -> PlanEntitlementRecord: + """Record entitled plan capacity. This is not an invoice or booked cost.""" + + normalized_provider = provider.strip() + normalized_plan = plan.strip() + normalized_source = source.strip() + normalized_label = plan_label.strip() if plan_label and plan_label.strip() else None + normalized_account = ( + provider_account.strip() if provider_account and provider_account.strip() else None + ) + normalized_unit = unit.strip().lower() + if not normalized_provider or not normalized_plan: + raise ValueError("provider and plan are required") + if not normalized_source: + raise ValueError("source is required") + if normalized_unit not in ENTITLEMENT_UNITS: + raise ValueError("unit must be token, multiplier, or plan") + period = reporting_month(period_month) + quantity_value, unknown = _normalize_entitlement_quantity( + quantity, unit=normalized_unit, plan_label=normalized_label + ) + if normalized_unit == "plan" and not normalized_label: + raise ValueError("plan-unit entitlements require plan_label") + normalized_currency = currency_code(currency) if currency else None + identifier = entitlement_id or str(uuid.uuid4()) + recorded_at = _utc_now() + ledger = ledger_path or default_ledger_path() + with _connect(ledger) as conn: + conn.execute("BEGIN IMMEDIATE") + current = conn.execute( + "SELECT id FROM plan_entitlements WHERE provider = ? " + "AND IFNULL(provider_account, '') = ? AND plan = ? " + "AND period_month = ? AND is_current = 1", + (normalized_provider, normalized_account or "", normalized_plan, period), + ).fetchone() + if current is not None and revision_of != current["id"]: + raise ValueError( + f"revision_of must reference current entitlement {current['id']}" + ) + if current is None and revision_of is not None: + raise ValueError("revision_of cannot be used without an existing entitlement") + if current is not None: + conn.execute( + "UPDATE plan_entitlements SET is_current = 0 WHERE id = ?", + (current["id"],), + ) + conn.execute( + """ + INSERT INTO plan_entitlements ( + id, provider, provider_account, plan, period_month, quantity, + quantity_unknown, unit, plan_label, currency, source, + revision_of, is_current, recorded_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + """, + ( + identifier, + normalized_provider, + normalized_account, + normalized_plan, + period, + None if quantity_value is None else format(quantity_value, "f"), + int(unknown), + normalized_unit, + normalized_label, + normalized_currency, + normalized_source, + revision_of, + recorded_at, + ), + ) + conn.commit() + return PlanEntitlementRecord( + id=identifier, + provider=normalized_provider, + provider_account=normalized_account, + plan=normalized_plan, + period_month=period, + quantity=quantity_value, + quantity_unknown=unknown, + unit=normalized_unit, + plan_label=normalized_label, + currency=normalized_currency, + source=normalized_source, + revision_of=revision_of, + recorded_at=recorded_at, + ) + + +def _entitlement_from_row(row: sqlite3.Row) -> PlanEntitlementRecord: + unknown = bool(row["quantity_unknown"]) + raw_quantity = row["quantity"] + return PlanEntitlementRecord( + id=row["id"], + provider=row["provider"], + provider_account=row["provider_account"], + plan=row["plan"], + period_month=row["period_month"], + quantity=None if unknown or raw_quantity is None else Decimal(raw_quantity), + quantity_unknown=unknown, + unit=row["unit"], + plan_label=row["plan_label"], + currency=row["currency"], + source=row["source"], + revision_of=row["revision_of"], + recorded_at=row["recorded_at"], + ) + + +def list_current_plan_entitlements( + *, + ledger_path: Path | None = None, + period_month: str | None = None, +) -> list[PlanEntitlementRecord]: + ledger = ledger_path or default_ledger_path() + query = "SELECT * FROM plan_entitlements WHERE is_current = 1" + params: list[object] = [] + if period_month is not None: + query += " AND period_month = ?" + params.append(reporting_month(period_month)) + query += " ORDER BY period_month, provider, IFNULL(provider_account, ''), plan" + with _connect(ledger) as conn: + rows = conn.execute(query, params).fetchall() + return [_entitlement_from_row(row) for row in rows] + + +def _split_plan_category(category: str) -> tuple[str, str | None]: + if "|" in category: + provider, account = category.split("|", 1) + return provider, account or None + return category, None + + +def plan_month_report( + *, + ledger_path: Path | None = None, + period_month: str | None = None, +) -> list[PlanMonthRow]: + """Join booked AI-plan facts to entitlements without reading session logs.""" + + ledger = ledger_path or default_ledger_path() + period = reporting_month(period_month) if period_month else None + fact_sql = ( + "SELECT * FROM ledger_entries WHERE source_type = 'ai-plan' AND is_current = 1" + ) + fact_params: list[object] = [] + if period is not None: + fact_sql += " AND period_month = ?" + fact_params.append(period) + with _connect(ledger) as conn: + facts = conn.execute(fact_sql, fact_params).fetchall() + entitlements = list_current_plan_entitlements( + ledger_path=ledger, period_month=period + ) + entitlement_by_key = { + (row.provider, row.provider_account or "", row.plan, row.period_month): row + for row in entitlements + } + used_keys: set[tuple[str, str, str, str]] = set() + report: list[PlanMonthRow] = [] + for fact in facts: + provider, account = _split_plan_category(fact["category"]) + key = (provider, account or "", fact["label"], fact["period_month"]) + entitlement = entitlement_by_key.get(key) + booked_currency = fact["currency"] + join_status = "booked_only" + if entitlement is not None: + used_keys.add(key) + if ( + entitlement.currency is not None + and entitlement.currency != booked_currency + ): + join_status = "currency_mismatch" + else: + join_status = "matched" + report.append( + PlanMonthRow( + period_month=fact["period_month"], + provider=provider, + provider_account=account, + plan=fact["label"], + booked_amount=minor_money(int(fact["amount_minor"])), + booked_currency=booked_currency, + financial_fact_id=fact["financial_fact_id"], + entitled_quantity=None if entitlement is None else entitlement.quantity, + quantity_unknown=False if entitlement is None else entitlement.quantity_unknown, + unit=None if entitlement is None else entitlement.unit, + plan_label=None if entitlement is None else entitlement.plan_label, + entitlement_id=None if entitlement is None else entitlement.id, + join_status=join_status, + ) + ) + for key, entitlement in entitlement_by_key.items(): + if key in used_keys: + continue + report.append( + PlanMonthRow( + period_month=entitlement.period_month, + provider=entitlement.provider, + provider_account=entitlement.provider_account, + plan=entitlement.plan, + booked_amount=None, + booked_currency=None, + financial_fact_id=None, + entitled_quantity=entitlement.quantity, + quantity_unknown=entitlement.quantity_unknown, + unit=entitlement.unit, + plan_label=entitlement.plan_label, + entitlement_id=entitlement.id, + join_status="entitlement_only", + ) + ) + report.sort( + key=lambda row: ( + row.period_month, + row.provider, + row.provider_account or "", + row.plan, + ) + ) + return report + + def reverse_financial_fact( financial_fact_id: str, *, diff --git a/tests/test_plan_entitlement.py b/tests/test_plan_entitlement.py new file mode 100644 index 0000000..b9825e4 --- /dev/null +++ b/tests/test_plan_entitlement.py @@ -0,0 +1,163 @@ +from pathlib import Path + +import pytest + +from fin_hub.services.ledger import ( + import_csv, + list_current_plan_entitlements, + plan_month_report, + record_plan_entitlement, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_unknown_token_entitlement_requires_plan_label(tmp_path: Path): + ledger = tmp_path / "ledger.db" + with pytest.raises(ValueError, match="plan_label"): + record_plan_entitlement( + provider="anthropic", + plan="claude-max", + period_month="2026-08", + unit="token", + quantity="unknown", + source="vendor-plan", + ledger_path=ledger, + ) + record = record_plan_entitlement( + provider="anthropic", + plan="claude-max", + period_month="2026-08", + unit="plan", + plan_label="Max 20x", + source="vendor-plan", + ledger_path=ledger, + ) + assert record.quantity is None + assert record.quantity_unknown is True + assert record.unit == "plan" + assert record.plan_label == "Max 20x" + + +def test_plan_entitlement_revision_is_append_only(tmp_path: Path): + ledger = tmp_path / "ledger.db" + original = record_plan_entitlement( + provider="openai", + provider_account="org-demo", + plan="api-prepaid", + period_month="2026-08", + unit="token", + quantity=1_000_000, + currency="EUR", + source="dashboard-v1", + ledger_path=ledger, + ) + with pytest.raises(ValueError, match=original.id): + record_plan_entitlement( + provider="openai", + provider_account="org-demo", + plan="api-prepaid", + period_month="2026-08", + unit="token", + quantity=2_000_000, + currency="EUR", + source="dashboard-v2", + ledger_path=ledger, + ) + revised = record_plan_entitlement( + provider="openai", + provider_account="org-demo", + plan="api-prepaid", + period_month="2026-08", + unit="token", + quantity=2_000_000, + currency="EUR", + source="dashboard-v2", + revision_of=original.id, + ledger_path=ledger, + ) + current = list_current_plan_entitlements(ledger_path=ledger) + assert [row.id for row in current] == [revised.id] + assert current[0].quantity == 2_000_000 + assert current[0].revision_of == original.id + assert current[0].quantity_unknown is False + + +def test_plan_month_report_joins_booked_cost_to_unknown_capacity(tmp_path: Path): + ledger = tmp_path / "ledger.db" + import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger) + record_plan_entitlement( + provider="anthropic", + plan="claude-max", + period_month="2026-08", + unit="plan", + plan_label="Max 20x", + currency="EUR", + source="vendor-plan", + ledger_path=ledger, + ) + record_plan_entitlement( + provider="openai", + provider_account="org-demo", + plan="api-prepaid", + period_month="2026-08", + unit="token", + quantity=500_000, + currency="EUR", + source="usage-dashboard", + ledger_path=ledger, + ) + + rows = {f"{row.provider}:{row.plan}": row for row in plan_month_report(ledger_path=ledger)} + claude = rows["anthropic:claude-max"] + assert claude.booked_amount == 200 + assert claude.booked_currency == "EUR" + assert claude.entitled_quantity is None + assert claude.quantity_unknown is True + assert claude.plan_label == "Max 20x" + assert claude.join_status == "matched" + openai = rows["openai:api-prepaid"] + assert openai.booked_amount == 40 + assert openai.entitled_quantity == 500_000 + assert openai.join_status == "matched" + + +def test_plan_month_does_not_silently_join_currency_mismatch(tmp_path: Path): + ledger = tmp_path / "ledger.db" + import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger) + record_plan_entitlement( + provider="anthropic", + plan="claude-max", + period_month="2026-08", + unit="token", + quantity=100, + currency="USD", + source="vendor-plan", + ledger_path=ledger, + ) + row = plan_month_report(ledger_path=ledger, period_month="2026-08")[0] + assert row.provider == "anthropic" + assert row.booked_currency == "EUR" + assert row.entitled_quantity == 100 + assert row.join_status == "currency_mismatch" + + +def test_multiplier_does_not_invent_a_token_ceiling(tmp_path: Path): + ledger = tmp_path / "ledger.db" + record = record_plan_entitlement( + provider="anthropic", + plan="claude-max", + period_month="2026-08", + unit="multiplier", + quantity=20, + plan_label="Max 20x", + source="vendor-plan", + ledger_path=ledger, + ) + assert record.unit == "multiplier" + assert record.quantity == 20 + assert record.quantity_unknown is False + report = plan_month_report(ledger_path=ledger) + assert report[0].join_status == "entitlement_only" + assert report[0].booked_amount is None + assert report[0].entitled_quantity == 20 diff --git a/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md b/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md index 5814522..999a1e7 100644 --- a/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md +++ b/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md @@ -207,7 +207,7 @@ figure without double-counting the same euros. ```task id: FIN-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "51c1b86f-c137-4b5d-9719-cd2ae60fe857" ``` @@ -226,6 +226,13 @@ invent a token ceiling. Done when a plan month can be reported as booked euros + entitled capacity (or explicit unknown) without reading session logs. +Completed 2026-08-15: `ledger set-entitlement` / `plan-month` record +append-only capacity (`token`, `multiplier`, or `plan`). Unknown +tokens require a plan label and never become zero. Revisions +supersede. Booked euros join entitlement on provider/account/plan/ +period; a currency mismatch stays visible and is not treated as a +matched series. No session logs are read. + ## Join State Hub session-token aggregates ```task