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

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