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:
parent
38d0bf21c9
commit
ce4f791a63
11 changed files with 511 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
102
src/fin_hub/ingest/ai_plan.py
Normal file
102
src/fin_hub/ingest/ai_plan.py
Normal 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
|
||||
|
|
@ -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()),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
3
tests/fixtures/ai-plans.csv
vendored
Normal 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
|
||||
|
147
tests/test_ai_plan.py
Normal file
147
tests/test_ai_plan.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue