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
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue