From ce4f791a63a1d4268fe4bc547faef6f6d985f774 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 15 Aug 2026 19:09:14 +0200 Subject: [PATCH] feat: book monthly AI-plan invoices as facts and commitments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ai-plan ingest that stores one current booked fact per provider-account × plan × period. Subscriptions become recurring commitments; usage top-ups do not. Session-level rows are rejected. --- .claude/rules/stack-and-commands.md | 3 + README.md | 2 + src/fin_hub/cli.py | 29 ++- src/fin_hub/ingest/__init__.py | 2 + src/fin_hub/ingest/ai_plan.py | 102 +++++++++ src/fin_hub/services/evaluate.py | 14 +- src/fin_hub/services/exchange.py | 10 +- src/fin_hub/services/ledger.py | 194 ++++++++++++++++++ tests/fixtures/ai-plans.csv | 3 + tests/test_ai_plan.py | 147 +++++++++++++ ...FIN-WP-0007-ai-plan-token-effectiveness.md | 10 +- 11 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 src/fin_hub/ingest/ai_plan.py create mode 100644 tests/fixtures/ai-plans.csv create mode 100644 tests/test_ai_plan.py diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index 68c8cdf..d5de547 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -20,10 +20,13 @@ uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000 uv run finhub import-cloud tests/fixtures/cloud-costs.csv uv run finhub import-anthropic tests/fixtures/anthropic-billing.csv uv run finhub import-hosteurope tests/fixtures/hosteurope.csv +uv run finhub import-ai-plan tests/fixtures/ai-plans.csv # CLI — ledger (SQLite at .fin-hub/ledger.db) 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 summary # CLI — scheduled evaluation (cron/systemd friendly) diff --git a/README.md b/README.md index b38f2fe..dd7319f 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 import ai-plan tests/fixtures/ai-plans.csv +uv run finhub ledger commitments 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 1853605..2e3e446 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -10,6 +10,7 @@ from pathlib import Path from fin_hub.coupling.canon import emit_viability_alert from fin_hub.coupling.dev_hub import emit_resource_pressure from fin_hub.coupling.ops_hub import ServiceCostLine, build_service_cost_report +from fin_hub.ingest.ai_plan import parse_ai_plan_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.hosteurope import parse_hosteurope_csv @@ -23,6 +24,7 @@ from fin_hub.services.ledger import ( default_ledger_path, import_csv, ledger_stats_json, + list_current_commitments, record_engagement_price, set_opening_balance, ) @@ -39,6 +41,12 @@ def _cmd_import_cloud(args: argparse.Namespace) -> int: return 0 +def _cmd_import_ai_plan(args: argparse.Namespace) -> int: + rows = parse_ai_plan_csv(Path(args.path)) + print(json.dumps([row.__dict__ for row in rows], indent=2, default=str)) + return 0 + + def _cmd_import_anthropic(args: argparse.Namespace) -> int: rows = parse_anthropic_billing_csv(Path(args.path)) payload = [ @@ -151,6 +159,12 @@ def _cmd_ledger_margins(args: argparse.Namespace) -> int: return 0 +def _cmd_ledger_commitments(args: argparse.Namespace) -> int: + rows = list_current_commitments(ledger_path=_ledger_path(args)) + 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)) @@ -212,6 +226,10 @@ def build_parser() -> argparse.ArgumentParser: cloud.add_argument("path") cloud.set_defaults(func=_cmd_import_cloud) + ai_plan = sub.add_parser("import-ai-plan", help="Parse monthly AI-plan invoice CSV") + ai_plan.add_argument("path") + ai_plan.set_defaults(func=_cmd_import_ai_plan) + anthropic = sub.add_parser("import-anthropic", help="Parse Anthropic billing CSV") anthropic.add_argument("path") anthropic.set_defaults(func=_cmd_import_anthropic) @@ -239,7 +257,9 @@ def build_parser() -> argparse.ArgumentParser: ledger_sub = ledger.add_subparsers(dest="ledger_command", required=True) ledger_import = ledger_sub.add_parser("import", help="Import a cost CSV into the ledger") - ledger_import.add_argument("type", choices=["cloud", "anthropic", "hosteurope"]) + ledger_import.add_argument( + "type", choices=["cloud", "anthropic", "hosteurope", "ai-plan"] + ) ledger_import.add_argument("path") ledger_import.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)") ledger_import.add_argument("--force", action="store_true", help="Re-import even if file unchanged") @@ -275,6 +295,13 @@ def build_parser() -> argparse.ArgumentParser: ledger_margins.add_argument("--ledger", help="Ledger database path") ledger_margins.set_defaults(func=_cmd_ledger_margins) + ledger_commitments = ledger_sub.add_parser( + "commitments", + help="List current recurring AI-plan and other ledger commitments", + ) + ledger_commitments.add_argument("--ledger", help="Ledger database path") + ledger_commitments.set_defaults(func=_cmd_ledger_commitments) + ledger_allocations = ledger_sub.add_parser( "allocations", help="Reconcile resource-control allocation evidence to booked facts", diff --git a/src/fin_hub/ingest/__init__.py b/src/fin_hub/ingest/__init__.py index 2082a61..eb84589 100644 --- a/src/fin_hub/ingest/__init__.py +++ b/src/fin_hub/ingest/__init__.py @@ -1,10 +1,12 @@ """CSV and billing export ingestion for fin-hub.""" +from fin_hub.ingest.ai_plan import parse_ai_plan_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.hosteurope import parse_hosteurope_csv __all__ = [ + "parse_ai_plan_csv", "parse_anthropic_billing_csv", "parse_cloud_cost_csv", "parse_hosteurope_csv", diff --git a/src/fin_hub/ingest/ai_plan.py b/src/fin_hub/ingest/ai_plan.py new file mode 100644 index 0000000..a640cb3 --- /dev/null +++ b/src/fin_hub/ingest/ai_plan.py @@ -0,0 +1,102 @@ +"""Monthly AI-plan / subscription invoice ingestion.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Literal + +from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows +from fin_hub.money import currency_code, reporting_month + +ChargeKind = Literal["subscription", "usage_topup"] + +_SESSION_COLUMNS = ( + "session_id", + "request_id", + "input_tokens", + "output_tokens", + "tokens_in", + "tokens_out", + "prompt_tokens", + "completion_tokens", +) + +_TRUE = {"true", "yes", "1"} +_FALSE = {"false", "no", "0"} + + +@dataclass(frozen=True) +class AiPlanInvoiceRow: + provider: str + provider_account: str | None + plan: str + charge_kind: ChargeKind + amount: float + currency: str + period_month: str + incurred_on: date + ongoing: bool + + +def plan_category(provider: str, provider_account: str | None) -> str: + return f"{provider}|{provider_account}" if provider_account else provider + + +def _parse_bool(value: str, *, default: bool) -> bool: + if not value: + return default + normalized = value.strip().lower() + if normalized in _TRUE: + return True + if normalized in _FALSE: + return False + raise ValueError(f"ongoing must be true or false, got {value!r}") + + +def _period_start(period_month: str) -> date: + return date.fromisoformat(f"{reporting_month(period_month)}-01") + + +def parse_ai_plan_csv(path: Path, *, default_currency: str = "EUR") -> list[AiPlanInvoiceRow]: + rows: list[AiPlanInvoiceRow] = [] + for row in read_csv_rows(path): + session_hits = [name for name in _SESSION_COLUMNS if pick(row, name)] + if session_hits: + raise ValueError( + "AI-plan invoices are monthly booked facts; " + f"session-level columns are not allowed: {', '.join(session_hits)}" + ) + provider = pick(row, "provider", "vendor") + plan = pick(row, "plan", "plan_name", "product") + amount_raw = pick(row, "amount", "cost", "total", "spend") + period = pick(row, "period_month", "month", "accounting_period", "billing_period") + if not provider or not plan or not amount_raw or not period: + continue + kind_raw = pick(row, "charge_kind", "kind", "charge_type").lower() + if kind_raw not in {"subscription", "usage_topup"}: + raise ValueError( + "charge_kind must be subscription or usage_topup, " + f"got {kind_raw or 'missing'!r}" + ) + account = pick(row, "provider_account", "account", "account_ref") or None + currency = currency_code(pick(row, "currency") or default_currency) + ongoing = _parse_bool( + pick(row, "ongoing"), + default=kind_raw == "subscription", + ) + rows.append( + AiPlanInvoiceRow( + provider=provider, + provider_account=account, + plan=plan, + charge_kind=kind_raw, # type: ignore[arg-type] + amount=parse_amount(amount_raw), + currency=currency, + period_month=reporting_month(period[:7]), + incurred_on=_period_start(period[:7]), + ongoing=ongoing, + ) + ) + return rows diff --git a/src/fin_hub/services/evaluate.py b/src/fin_hub/services/evaluate.py index 0aecab5..957ea64 100644 --- a/src/fin_hub/services/evaluate.py +++ b/src/fin_hub/services/evaluate.py @@ -8,7 +8,11 @@ from pathlib import Path from fin_hub.coupling.canon import emit_viability_alert from fin_hub.coupling.dev_hub import emit_resource_pressure from fin_hub.services.alerts import evaluate_budget_alerts -from fin_hub.services.ledger import get_opening_balance, monthly_burn_series +from fin_hub.services.ledger import ( + get_opening_balance, + list_current_commitments, + monthly_burn_series, +) from fin_hub.services.runway import compute_runway @@ -30,6 +34,13 @@ def evaluate_runway( effective_currency = currency or stored_currency burns = monthly_burn_series(ledger_path=ledger_path, currency=effective_currency) + commitments = list_current_commitments( + ledger_path=ledger_path, currency=effective_currency + ) + if not burns: + committed = sum((float(item.amount) for item in commitments), 0.0) + if committed > 0: + burns = [committed] runway = compute_runway( current_balance=balance, monthly_burns=burns, @@ -42,6 +53,7 @@ def evaluate_runway( "runway": runway.as_dict(), "alerts": [alert.as_dict() for alert in alerts], "monthly_burns": burns, + "commitments": [item.as_dict() for item in commitments], "ledger_path": str(ledger_path.resolve()), } diff --git a/src/fin_hub/services/exchange.py b/src/fin_hub/services/exchange.py index f90cb0c..1219a6f 100644 --- a/src/fin_hub/services/exchange.py +++ b/src/fin_hub/services/exchange.py @@ -337,7 +337,11 @@ def booked_cost_projection( source_document_id=row["source_document_id"], source_line_id=row["source_line_id"], content_fingerprint=row["content_fingerprint"], - provider=row["source_type"], + provider=( + row["category"].split("|", 1)[0] + if row["source_type"] == "ai-plan" + else row["source_type"] + ), accounting_period=row["period_month"], service_period_start=row["incurred_on"], service_period_end=row["incurred_on"], @@ -350,7 +354,9 @@ def booked_cost_projection( adjustment_amount=adjustment_amount, effective_amount=amount, resource_id=bindings.get(row["financial_fact_id"]), - service_id=row["category"], + service_id=( + row["label"] if row["source_type"] == "ai-plan" else row["category"] + ), environment=row["environment"], cost_attribution_key=row["cost_attribution_key"], source_evidence_ref=row["correction_source"] or row["source_document_id"], diff --git a/src/fin_hub/services/ledger.py b/src/fin_hub/services/ledger.py index a7c2504..03693b8 100644 --- a/src/fin_hub/services/ledger.py +++ b/src/fin_hub/services/ledger.py @@ -11,6 +11,7 @@ from datetime import date, datetime, timezone from decimal import Decimal from pathlib import Path +from fin_hub.ingest.ai_plan import parse_ai_plan_csv, plan_category from fin_hub.ingest.anthropic import parse_anthropic_billing_csv from fin_hub.ingest.cloud import parse_cloud_cost_csv from fin_hub.ingest.hosteurope import parse_hosteurope_csv @@ -41,6 +42,9 @@ class LedgerEntry: application_id: str | None = None app_instance_id: str | None = None cost_attribution_key: str | None = None + charge_kind: str | None = None + provider_account: str | None = None + ongoing: bool = False def __post_init__(self) -> None: object.__setattr__(self, "amount", money(self.amount)) @@ -74,6 +78,26 @@ class ImportResult: return asdict(self) +@dataclass(frozen=True) +class CommitmentRecord: + id: str + provider: str + provider_account: str | None + plan: str + amount: Decimal + currency: str + cadence: str + start_month: str + end_month: str | None + source: str + financial_fact_id: str | None + revision_of: str | None + recorded_at: str + + def as_dict(self) -> dict: + return asdict(self) + + @dataclass(frozen=True) class EngagementPriceRecord: id: str @@ -164,6 +188,24 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: recorded_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS ledger_commitments ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_account TEXT, + plan TEXT NOT NULL, + amount REAL NOT NULL, + amount_minor INTEGER NOT NULL, + currency TEXT NOT NULL, + cadence TEXT NOT NULL DEFAULT 'monthly', + start_month TEXT NOT NULL, + end_month TEXT, + source TEXT NOT NULL, + financial_fact_id TEXT, + 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)")} @@ -236,6 +278,12 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: "ON engagement_prices (cost_attribution_key, period_month, currency) " "WHERE is_current = 1" ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_ledger_commitments_current " + "ON ledger_commitments (" + "provider, IFNULL(provider_account, ''), plan, currency" + ") WHERE is_current = 1" + ) conn.commit() @@ -326,6 +374,26 @@ def _entries_from_anthropic(path: Path) -> list[LedgerEntry]: ] +def _entries_from_ai_plan(path: Path) -> list[LedgerEntry]: + resolved = str(path.resolve()) + return [ + LedgerEntry( + source_type="ai-plan", + category=plan_category(row.provider, row.provider_account), + label=row.plan, + amount=row.amount, + currency=row.currency, + period_month=row.period_month, + incurred_on=row.incurred_on, + source_path=resolved, + charge_kind=row.charge_kind, + provider_account=row.provider_account, + ongoing=row.ongoing, + ) + for row in parse_ai_plan_csv(path) + ] + + def _entries_from_hosteurope(path: Path) -> list[LedgerEntry]: resolved = str(path.resolve()) return [ @@ -352,6 +420,7 @@ _IMPORTERS = { "cloud": _entries_from_cloud, "anthropic": _entries_from_anthropic, "hosteurope": _entries_from_hosteurope, + "ai-plan": _entries_from_ai_plan, } @@ -448,6 +517,7 @@ def import_csv( entry.source_path if correction_of else None, ), ) + _sync_ai_plan_commitment(conn, entry, financial_fact_id) rows_imported += 1 conn.execute( """ @@ -467,6 +537,130 @@ def import_csv( ) +def _commitment_basis(entry: LedgerEntry) -> tuple[str, str | None, str, str]: + provider = entry.category.split("|", 1)[0] + return provider, entry.provider_account, entry.label, entry.currency + + +def _sync_ai_plan_commitment( + conn: sqlite3.Connection, + entry: LedgerEntry, + financial_fact_id: str, +) -> None: + if entry.source_type != "ai-plan" or entry.charge_kind != "subscription": + return + provider, account, plan, currency = _commitment_basis(entry) + recorded_at = _utc_now() + current = conn.execute( + "SELECT id, amount_minor, start_month FROM ledger_commitments " + "WHERE provider = ? AND IFNULL(provider_account, '') = ? " + "AND plan = ? AND currency = ? AND is_current = 1", + (provider, account or "", plan, currency), + ).fetchone() + if not entry.ongoing: + if current is None: + return + conn.execute( + "UPDATE ledger_commitments SET is_current = 0 WHERE id = ?", + (current["id"],), + ) + conn.execute( + """ + INSERT INTO ledger_commitments ( + id, provider, provider_account, plan, amount, amount_minor, + currency, cadence, start_month, end_month, source, + financial_fact_id, revision_of, is_current, recorded_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'monthly', ?, ?, ?, ?, ?, 0, ?) + """, + ( + str(uuid.uuid4()), + provider, + account, + plan, + float(entry.amount), + money_minor(entry.amount), + currency, + current["start_month"], + entry.period_month, + entry.source_path, + financial_fact_id, + current["id"], + recorded_at, + ), + ) + return + if current is not None and int(current["amount_minor"]) == money_minor(entry.amount): + conn.execute( + "UPDATE ledger_commitments SET financial_fact_id = ? WHERE id = ?", + (financial_fact_id, current["id"]), + ) + return + if current is not None: + conn.execute( + "UPDATE ledger_commitments SET is_current = 0 WHERE id = ?", + (current["id"],), + ) + conn.execute( + """ + INSERT INTO ledger_commitments ( + id, provider, provider_account, plan, amount, amount_minor, + currency, cadence, start_month, end_month, source, + financial_fact_id, revision_of, is_current, recorded_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'monthly', ?, NULL, ?, ?, ?, 1, ?) + """, + ( + str(uuid.uuid4()), + provider, + account, + plan, + float(entry.amount), + money_minor(entry.amount), + currency, + current["start_month"] if current is not None else entry.period_month, + entry.source_path, + financial_fact_id, + current["id"] if current is not None else None, + recorded_at, + ), + ) + + +def list_current_commitments( + *, + ledger_path: Path | None = None, + currency: str | None = None, +) -> list[CommitmentRecord]: + ledger = ledger_path or default_ledger_path() + query = ( + "SELECT * FROM ledger_commitments WHERE is_current = 1" + ) + params: list[object] = [] + if currency is not None: + query += " AND currency = ?" + params.append(currency_code(currency)) + query += " ORDER BY provider, IFNULL(provider_account, ''), plan" + with _connect(ledger) as conn: + rows = conn.execute(query, params).fetchall() + return [ + CommitmentRecord( + id=row["id"], + provider=row["provider"], + provider_account=row["provider_account"], + plan=row["plan"], + amount=minor_money(int(row["amount_minor"])), + currency=row["currency"], + cadence=row["cadence"], + start_month=row["start_month"], + end_month=row["end_month"], + source=row["source"], + financial_fact_id=row["financial_fact_id"], + revision_of=row["revision_of"], + recorded_at=row["recorded_at"], + ) + for row in rows + ] + + def reverse_financial_fact( financial_fact_id: str, *, diff --git a/tests/fixtures/ai-plans.csv b/tests/fixtures/ai-plans.csv new file mode 100644 index 0000000..96bacbd --- /dev/null +++ b/tests/fixtures/ai-plans.csv @@ -0,0 +1,3 @@ +provider,provider_account,plan,charge_kind,amount,currency,period_month,ongoing +anthropic,,claude-max,subscription,200.00,EUR,2026-08,true +openai,org-demo,api-prepaid,usage_topup,40.00,EUR,2026-08,false diff --git a/tests/test_ai_plan.py b/tests/test_ai_plan.py new file mode 100644 index 0000000..9f878f0 --- /dev/null +++ b/tests/test_ai_plan.py @@ -0,0 +1,147 @@ +from pathlib import Path + +import pytest + +from fin_hub.ingest.ai_plan import parse_ai_plan_csv +from fin_hub.services.evaluate import evaluate_runway +from fin_hub.services.exchange import booked_cost_projection +from fin_hub.services.ledger import ( + import_csv, + list_current_commitments, + monthly_summary, + set_opening_balance, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_parse_ai_plan_fixture_keeps_subscription_and_topup_separate(): + rows = parse_ai_plan_csv(FIXTURES / "ai-plans.csv") + assert len(rows) == 2 + assert rows[0].provider == "anthropic" + assert rows[0].charge_kind == "subscription" + assert rows[0].ongoing is True + assert rows[0].provider_account is None + assert rows[1].provider == "openai" + assert rows[1].charge_kind == "usage_topup" + assert rows[1].ongoing is False + assert rows[1].provider_account == "org-demo" + assert rows[0].incurred_on.isoformat() == "2026-08-01" + + +def test_parse_ai_plan_rejects_session_level_rows(tmp_path: Path): + source = tmp_path / "sessions.csv" + source.write_text( + "provider,plan,charge_kind,amount,currency,period_month,session_id\n" + "anthropic,claude-max,subscription,200.00,EUR,2026-08,sess-1\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="session-level"): + parse_ai_plan_csv(source) + + +def test_import_ai_plans_books_once_and_commits_only_subscriptions(tmp_path: Path): + ledger = tmp_path / "ledger.db" + first = import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger) + second = import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger) + assert first.rows_imported == 2 + assert second.skipped is True + + rollup = monthly_summary(ledger_path=ledger)[0] + assert rollup.currency == "EUR" + assert rollup.period_month == "2026-08" + assert rollup.total == pytest.approx(240.0) + assert rollup.entry_count == 2 + + commitments = list_current_commitments(ledger_path=ledger) + assert len(commitments) == 1 + assert commitments[0].provider == "anthropic" + assert commitments[0].plan == "claude-max" + assert commitments[0].amount == 200 + assert commitments[0].financial_fact_id is not None + + +def test_ai_plan_correction_is_append_only(tmp_path: Path): + ledger = tmp_path / "ledger.db" + source = tmp_path / "plans.csv" + source.write_text( + "provider,plan,charge_kind,amount,currency,period_month,ongoing\n" + "anthropic,claude-max,subscription,200.00,EUR,2026-08,true\n", + encoding="utf-8", + ) + import_csv(source, "ai-plan", ledger_path=ledger) + source.write_text( + "provider,plan,charge_kind,amount,currency,period_month,ongoing\n" + "anthropic,claude-max,subscription,220.00,EUR,2026-08,true\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="explicit correction"): + import_csv(source, "ai-plan", ledger_path=ledger) + corrected = import_csv(source, "ai-plan", ledger_path=ledger, force=True) + assert corrected.rows_imported == 1 + assert monthly_summary(ledger_path=ledger)[0].total == pytest.approx(220.0) + commitments = list_current_commitments(ledger_path=ledger) + assert len(commitments) == 1 + assert commitments[0].amount == 220 + assert commitments[0].revision_of is not None + + +def test_ending_a_subscription_closes_the_commitment(tmp_path: Path): + ledger = tmp_path / "ledger.db" + source = tmp_path / "plans.csv" + source.write_text( + "provider,plan,charge_kind,amount,currency,period_month,ongoing\n" + "anthropic,claude-max,subscription,200.00,EUR,2026-08,true\n", + encoding="utf-8", + ) + import_csv(source, "ai-plan", ledger_path=ledger) + source.write_text( + "provider,plan,charge_kind,amount,currency,period_month,ongoing\n" + "anthropic,claude-max,subscription,200.00,EUR,2026-09,false\n", + encoding="utf-8", + ) + import_csv(source, "ai-plan", ledger_path=ledger) + assert list_current_commitments(ledger_path=ledger) == [] + months = {row.period_month: row.total for row in monthly_summary(ledger_path=ledger)} + assert months["2026-08"] == pytest.approx(200.0) + assert months["2026-09"] == pytest.approx(200.0) + + +def test_ai_plan_facts_project_without_invented_resource_id(tmp_path: Path): + ledger = tmp_path / "ledger.db" + import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger) + facts = booked_cost_projection(ledger_path=ledger) + by_provider = {fact.provider: fact for fact in facts} + assert set(by_provider) == {"anthropic", "openai"} + assert by_provider["anthropic"].service_id == "claude-max" + assert by_provider["anthropic"].resource_id is None + assert by_provider["anthropic"].tax_status == "unknown" + assert by_provider["openai"].service_id == "api-prepaid" + assert by_provider["openai"].effective_amount == 40 + + +def test_ai_plan_commitment_appears_on_runway_when_no_other_burns(tmp_path: Path): + ledger = tmp_path / "ledger.db" + set_opening_balance(ledger, 12000.0, currency="EUR") + source = tmp_path / "future-only.csv" + source.write_text( + "provider,plan,charge_kind,amount,currency,period_month,ongoing\n" + "xai,grok-heavy,subscription,300.00,USD,2026-09,true\n", + encoding="utf-8", + ) + import_csv(source, "ai-plan", ledger_path=ledger) + empty_eur = evaluate_runway(ledger_path=ledger, currency="EUR") + assert empty_eur["commitments"] == [] + usd = evaluate_runway(ledger_path=ledger, currency="USD") + assert usd["runway"]["monthly_burn"] == pytest.approx(300.0) + assert usd["commitments"][0]["plan"] == "grok-heavy" + + +def test_booked_ai_plan_invoices_drive_runway_without_double_counting(tmp_path: Path): + ledger = tmp_path / "ledger.db" + set_opening_balance(ledger, 12000.0) + import_csv(FIXTURES / "ai-plans.csv", "ai-plan", ledger_path=ledger) + report = evaluate_runway(ledger_path=ledger) + assert report["runway"]["monthly_burn"] == pytest.approx(240.0) + assert len(report["commitments"]) == 1 + assert report["commitments"][0]["amount"] == 200 diff --git a/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md b/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md index 55461a2..5814522 100644 --- a/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md +++ b/workplans/FIN-WP-0007-ai-plan-token-effectiveness.md @@ -175,7 +175,7 @@ row still books the amount and does not invent token quantities. ```task id: FIN-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "216aac44-f712-483c-a402-7db39c5967dd" ``` @@ -195,6 +195,14 @@ subscription and a usage-top-up) books exactly once per period, corrects append-only, and appears on burn/runway like any other commitment. +Completed 2026-08-15: `import-ai-plan` / `ledger import ai-plan` books +one current fact per provider-account × plan × period. Session-level +columns are rejected. Subscriptions upsert a monthly commitment; +usage top-ups do not. Corrections stay append-only. `booked_cost` +projection keeps `resource_id` null and tax unknown. Booked invoices +drive burn; a commitment-only month can still produce a runway +figure without double-counting the same euros. + ## Record plan entitlements ```task