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-cloud tests/fixtures/cloud-costs.csv
|
||||||
uv run finhub import-anthropic tests/fixtures/anthropic-billing.csv
|
uv run finhub import-anthropic tests/fixtures/anthropic-billing.csv
|
||||||
uv run finhub import-hosteurope tests/fixtures/hosteurope.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)
|
# CLI — ledger (SQLite at .fin-hub/ledger.db)
|
||||||
uv run finhub ledger set-balance 12000
|
uv run finhub ledger set-balance 12000
|
||||||
uv run finhub ledger import cloud tests/fixtures/cloud-costs.csv
|
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
|
uv run finhub ledger summary
|
||||||
|
|
||||||
# CLI — scheduled evaluation (cron/systemd friendly)
|
# CLI — scheduled evaluation (cron/systemd friendly)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ uv sync
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run finhub runway --balance 12000 --monthly-burn 2100,2200,2000
|
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 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 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 margins
|
||||||
uv run finhub ledger allocations
|
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.canon import emit_viability_alert
|
||||||
from fin_hub.coupling.dev_hub import emit_resource_pressure
|
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.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.anthropic import parse_anthropic_billing_csv
|
||||||
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
||||||
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
||||||
|
|
@ -23,6 +24,7 @@ from fin_hub.services.ledger import (
|
||||||
default_ledger_path,
|
default_ledger_path,
|
||||||
import_csv,
|
import_csv,
|
||||||
ledger_stats_json,
|
ledger_stats_json,
|
||||||
|
list_current_commitments,
|
||||||
record_engagement_price,
|
record_engagement_price,
|
||||||
set_opening_balance,
|
set_opening_balance,
|
||||||
)
|
)
|
||||||
|
|
@ -39,6 +41,12 @@ def _cmd_import_cloud(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def _cmd_import_anthropic(args: argparse.Namespace) -> int:
|
||||||
rows = parse_anthropic_billing_csv(Path(args.path))
|
rows = parse_anthropic_billing_csv(Path(args.path))
|
||||||
payload = [
|
payload = [
|
||||||
|
|
@ -151,6 +159,12 @@ def _cmd_ledger_margins(args: argparse.Namespace) -> int:
|
||||||
return 0
|
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:
|
def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
|
||||||
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
|
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
|
||||||
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
|
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.add_argument("path")
|
||||||
cloud.set_defaults(func=_cmd_import_cloud)
|
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 = sub.add_parser("import-anthropic", help="Parse Anthropic billing CSV")
|
||||||
anthropic.add_argument("path")
|
anthropic.add_argument("path")
|
||||||
anthropic.set_defaults(func=_cmd_import_anthropic)
|
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_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 = 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("path")
|
||||||
ledger_import.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)")
|
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")
|
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.add_argument("--ledger", help="Ledger database path")
|
||||||
ledger_margins.set_defaults(func=_cmd_ledger_margins)
|
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(
|
ledger_allocations = ledger_sub.add_parser(
|
||||||
"allocations",
|
"allocations",
|
||||||
help="Reconcile resource-control allocation evidence to booked facts",
|
help="Reconcile resource-control allocation evidence to booked facts",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"""CSV and billing export ingestion for fin-hub."""
|
"""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.anthropic import parse_anthropic_billing_csv
|
||||||
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
||||||
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"parse_ai_plan_csv",
|
||||||
"parse_anthropic_billing_csv",
|
"parse_anthropic_billing_csv",
|
||||||
"parse_cloud_cost_csv",
|
"parse_cloud_cost_csv",
|
||||||
"parse_hosteurope_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.canon import emit_viability_alert
|
||||||
from fin_hub.coupling.dev_hub import emit_resource_pressure
|
from fin_hub.coupling.dev_hub import emit_resource_pressure
|
||||||
from fin_hub.services.alerts import evaluate_budget_alerts
|
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
|
from fin_hub.services.runway import compute_runway
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,6 +34,13 @@ def evaluate_runway(
|
||||||
|
|
||||||
effective_currency = currency or stored_currency
|
effective_currency = currency or stored_currency
|
||||||
burns = monthly_burn_series(ledger_path=ledger_path, currency=effective_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(
|
runway = compute_runway(
|
||||||
current_balance=balance,
|
current_balance=balance,
|
||||||
monthly_burns=burns,
|
monthly_burns=burns,
|
||||||
|
|
@ -42,6 +53,7 @@ def evaluate_runway(
|
||||||
"runway": runway.as_dict(),
|
"runway": runway.as_dict(),
|
||||||
"alerts": [alert.as_dict() for alert in alerts],
|
"alerts": [alert.as_dict() for alert in alerts],
|
||||||
"monthly_burns": burns,
|
"monthly_burns": burns,
|
||||||
|
"commitments": [item.as_dict() for item in commitments],
|
||||||
"ledger_path": str(ledger_path.resolve()),
|
"ledger_path": str(ledger_path.resolve()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,11 @@ def booked_cost_projection(
|
||||||
source_document_id=row["source_document_id"],
|
source_document_id=row["source_document_id"],
|
||||||
source_line_id=row["source_line_id"],
|
source_line_id=row["source_line_id"],
|
||||||
content_fingerprint=row["content_fingerprint"],
|
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"],
|
accounting_period=row["period_month"],
|
||||||
service_period_start=row["incurred_on"],
|
service_period_start=row["incurred_on"],
|
||||||
service_period_end=row["incurred_on"],
|
service_period_end=row["incurred_on"],
|
||||||
|
|
@ -350,7 +354,9 @@ def booked_cost_projection(
|
||||||
adjustment_amount=adjustment_amount,
|
adjustment_amount=adjustment_amount,
|
||||||
effective_amount=amount,
|
effective_amount=amount,
|
||||||
resource_id=bindings.get(row["financial_fact_id"]),
|
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"],
|
environment=row["environment"],
|
||||||
cost_attribution_key=row["cost_attribution_key"],
|
cost_attribution_key=row["cost_attribution_key"],
|
||||||
source_evidence_ref=row["correction_source"] or row["source_document_id"],
|
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 decimal import Decimal
|
||||||
from pathlib import Path
|
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.anthropic import parse_anthropic_billing_csv
|
||||||
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
from fin_hub.ingest.cloud import parse_cloud_cost_csv
|
||||||
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
|
||||||
|
|
@ -41,6 +42,9 @@ class LedgerEntry:
|
||||||
application_id: str | None = None
|
application_id: str | None = None
|
||||||
app_instance_id: str | None = None
|
app_instance_id: str | None = None
|
||||||
cost_attribution_key: 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:
|
def __post_init__(self) -> None:
|
||||||
object.__setattr__(self, "amount", money(self.amount))
|
object.__setattr__(self, "amount", money(self.amount))
|
||||||
|
|
@ -74,6 +78,26 @@ class ImportResult:
|
||||||
return asdict(self)
|
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)
|
@dataclass(frozen=True)
|
||||||
class EngagementPriceRecord:
|
class EngagementPriceRecord:
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -164,6 +188,24 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||||
recorded_at TEXT NOT NULL
|
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)")}
|
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) "
|
"ON engagement_prices (cost_attribution_key, period_month, currency) "
|
||||||
"WHERE is_current = 1"
|
"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()
|
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]:
|
def _entries_from_hosteurope(path: Path) -> list[LedgerEntry]:
|
||||||
resolved = str(path.resolve())
|
resolved = str(path.resolve())
|
||||||
return [
|
return [
|
||||||
|
|
@ -352,6 +420,7 @@ _IMPORTERS = {
|
||||||
"cloud": _entries_from_cloud,
|
"cloud": _entries_from_cloud,
|
||||||
"anthropic": _entries_from_anthropic,
|
"anthropic": _entries_from_anthropic,
|
||||||
"hosteurope": _entries_from_hosteurope,
|
"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,
|
entry.source_path if correction_of else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
_sync_ai_plan_commitment(conn, entry, financial_fact_id)
|
||||||
rows_imported += 1
|
rows_imported += 1
|
||||||
conn.execute(
|
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(
|
def reverse_financial_fact(
|
||||||
financial_fact_id: str,
|
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
|
```task
|
||||||
id: FIN-WP-0007-T02
|
id: FIN-WP-0007-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "216aac44-f712-483c-a402-7db39c5967dd"
|
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
|
corrects append-only, and appears on burn/runway like any other
|
||||||
commitment.
|
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
|
## Record plan entitlements
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue