feat: book monthly AI-plan invoices as facts and commitments

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.
This commit is contained in:
tegwick 2026-08-15 19:09:14 +02:00
parent 38d0bf21c9
commit ce4f791a63
11 changed files with 511 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()),
}

View file

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

View file

@ -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,
*,

3
tests/fixtures/ai-plans.csv vendored Normal file
View file

@ -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
1 provider provider_account plan charge_kind amount currency period_month ongoing
2 anthropic claude-max subscription 200.00 EUR 2026-08 true
3 openai org-demo api-prepaid usage_topup 40.00 EUR 2026-08 false

147
tests/test_ai_plan.py Normal file
View file

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

View file

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