From 277c2d05cad1b21aee2e993410ead1fe51055640 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 8 Jul 2026 22:44:07 +0200 Subject: [PATCH] =?UTF-8?q?Implement=20FIN-WP-0001=20ledger,=20evaluate,?= =?UTF-8?q?=20and=20evidence=20lane=20(T03=E2=80=93T05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SQLite-backed cost ledger with import/summary CLI, cron-friendly evaluate command with federation emit, and dogfood evidence artefact generation from fixture CSVs. --- .claude/rules/architecture.md | 7 +- .claude/rules/stack-and-commands.md | 11 + .gitignore | 3 +- README.md | 3 + docs/evidence/runway-dogfood-2026-07-08.md | 41 +++ docs/raas-mvp-packaging.md | 4 +- src/fin_hub/cli.py | 114 +++++++ src/fin_hub/services/evaluate.py | 56 ++++ src/fin_hub/services/evidence.py | 159 +++++++++ src/fin_hub/services/ledger.py | 303 ++++++++++++++++++ tests/test_ledger.py | 60 ++++ .../FIN-WP-0001-runway-operations-lane.md | 6 +- 12 files changed, 760 insertions(+), 7 deletions(-) create mode 100644 docs/evidence/runway-dogfood-2026-07-08.md create mode 100644 src/fin_hub/services/evaluate.py create mode 100644 src/fin_hub/services/evidence.py create mode 100644 src/fin_hub/services/ledger.py create mode 100644 tests/test_ledger.py diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index e201d54..7d8604b 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -13,6 +13,9 @@ fin-specific models and services live here. | `services/runway.py` | Runway projection from balance + monthly burn series | | `services/alerts.py` | Threshold evaluation (runway months, budget overrun) | | `coupling/` | FOS §9 signals: fin→dev (resource pressure), fin→ops (cost attribution), fin→canon (viability) | +| `services/ledger.py` | SQLite cost ledger with monthly rollups | +| `services/evaluate.py` | Ledger-driven runway evaluation for cron | +| `services/evidence.py` | Non-secret dogfood evidence generation | | `cli.py` | Operator CLI (`finhub` entrypoint) | ### Data flow @@ -21,8 +24,8 @@ fin-specific models and services live here. CSV exports → ingest parsers → cost models → runway/alerts → coupling emit → State Hub progress ``` -v0.1 is manual CSV import only. Persistent ledger and HTTP read surface are -tracked in `FIN-WP-0001`. +v0.1 uses manual CSV import into a local SQLite ledger. HTTP read surface +remains deferred (`FIN-WP-0001-T06`). ## Quick Reference diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index efff564..f55fc13 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -21,6 +21,17 @@ 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 +# 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 summary + +# CLI — scheduled evaluation (cron/systemd friendly) +uv run finhub evaluate --emit + +# CLI — dogfood evidence artefact +uv run finhub evidence --seed-fixtures + # CLI — emit cross-hub signals (requires STATE_HUB_API) uv run finhub runway --balance 12000 --monthly-burn 2100 --emit ``` \ No newline at end of file diff --git a/.gitignore b/.gitignore index 56331ac..ef7b7ce 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ *.pyc .pytest_cache/ -.env \ No newline at end of file +.env +.fin-hub/ \ No newline at end of file diff --git a/README.md b/README.md index b6181ed..41de6c1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,9 @@ cd /home/worsch/fin-hub 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 evaluate +uv run finhub evidence --seed-fixtures uv run finhub import-cloud tests/fixtures/cloud-costs.csv uv run finhub ops-costs tests/fixtures/hosteurope.csv ``` diff --git a/docs/evidence/runway-dogfood-2026-07-08.md b/docs/evidence/runway-dogfood-2026-07-08.md new file mode 100644 index 0000000..4a7b8ce --- /dev/null +++ b/docs/evidence/runway-dogfood-2026-07-08.md @@ -0,0 +1,41 @@ +# Runway Dogfood Evidence — 2026-07-08 + +Status: generated v0.1 (2026-07-08 20:43 UTC) + +Non-secret internal dogfood artefact for RaaS managed-tier runway evidence. +Fixture CSVs under `tests/fixtures/` imported into a local SQLite ledger. + +## Opening position + +- Opening balance: **12,000.00 EUR** +- Alert threshold: **3.0 months** +- Ledger path: `/home/worsch/fin-hub/.fin-hub/ledger.db` + +## Monthly burn (ledger rollups) + +| Period | Currency | Total | Entries | +| --- | --- | ---: | ---: | +| 2026-06 | EUR | 185.30 | 5 | +| 2026-06 | USD | 1.65 | 2 | + +## Runway projection + +- Average monthly burn (EUR): **185.30** +- Projected runway: **64.8 months** +- Below threshold: **no** + +## Alerts + +- No alerts triggered. + +## Federation signals + +When `STATE_HUB_API` is reachable, `finhub evaluate --emit` posts: + +- fin→dev resource pressure (`budget_pressure`, `runway_below_threshold`) +- fin→canon viability alert when runway is below threshold + +## References + +- `docs/raas-mvp-packaging.md` — managed-tier runway add-on +- `workplans/FIN-WP-0001-runway-operations-lane.md` — operational lane diff --git a/docs/raas-mvp-packaging.md b/docs/raas-mvp-packaging.md index 8c8956b..2e7e574 100644 --- a/docs/raas-mvp-packaging.md +++ b/docs/raas-mvp-packaging.md @@ -54,7 +54,9 @@ instead of founder-only ops. ## First Customer Acquisition Strategy -1. **Internal dogfood** — complete Railiance restore drill + fin-hub runway evidence. +1. **Internal dogfood** — complete Railiance restore drill + fin-hub runway evidence + (`docs/evidence/runway-dogfood-2026-07-08.md`, regenerate via + `PYTHONPATH=src uv run finhub evidence --seed-fixtures`). 2. **Founder network** — 3 architecture review offers to EU technical SMEs with DSGVO pressure. 3. **Consulting wedge** — fixed-scope "sovereign ops assessment" (2–5 days) leading to managed tier. 4. **Content** — publish restore-drill case study (non-secret evidence only). diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index 8e09101..5697025 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -14,9 +14,21 @@ 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 from fin_hub.services.alerts import evaluate_budget_alerts +from fin_hub.services.evaluate import evaluate_runway +from fin_hub.services.evidence import write_runway_evidence +from fin_hub.services.ledger import ( + default_ledger_path, + import_csv, + ledger_stats_json, + set_opening_balance, +) from fin_hub.services.runway import compute_runway +def _ledger_path(args: argparse.Namespace) -> Path: + return Path(args.ledger) if args.ledger else default_ledger_path() + + def _cmd_import_cloud(args: argparse.Namespace) -> int: rows = parse_cloud_cost_csv(Path(args.path)) print(json.dumps([row.__dict__ for row in rows], indent=2, default=str)) @@ -82,6 +94,61 @@ def _cmd_ops_costs(args: argparse.Namespace) -> int: return 0 +def _cmd_ledger_import(args: argparse.Namespace) -> int: + result = import_csv( + Path(args.path), + args.type, + ledger_path=_ledger_path(args), + force=args.force, + ) + print(json.dumps(result.as_dict(), indent=2)) + return 0 + + +def _cmd_ledger_summary(args: argparse.Namespace) -> int: + print(ledger_stats_json(ledger_path=_ledger_path(args))) + return 0 + + +def _cmd_ledger_set_balance(args: argparse.Namespace) -> int: + set_opening_balance(_ledger_path(args), args.balance, currency=args.currency) + print( + json.dumps( + {"opening_balance": args.balance, "currency": args.currency}, + indent=2, + ) + ) + return 0 + + +def _cmd_evaluate(args: argparse.Namespace) -> int: + report = evaluate_runway( + ledger_path=_ledger_path(args), + opening_balance=args.balance, + alert_threshold_months=args.threshold, + currency=args.currency, + allocated=args.allocated, + spent=args.spent, + emit=args.emit, + api_base=args.api_base, + ) + print(json.dumps(report, indent=2, default=str)) + return 0 + + +def _cmd_evidence(args: argparse.Namespace) -> int: + output = write_runway_evidence( + ledger_path=_ledger_path(args), + output_dir=Path(args.output_dir) if args.output_dir else None, + opening_balance=args.balance, + seed_fixtures=args.seed_fixtures, + alert_threshold_months=args.threshold, + currency=args.currency, + ) + print(json.dumps({"written": str(output.resolve())}, indent=2)) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Fin Hub operator CLI") sub = parser.add_subparsers(dest="command", required=True) @@ -113,6 +180,53 @@ def build_parser() -> argparse.ArgumentParser: ops.add_argument("path") ops.set_defaults(func=_cmd_ops_costs) + ledger = sub.add_parser("ledger", help="Persistent SQLite cost ledger") + 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("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") + ledger_import.set_defaults(func=_cmd_ledger_import) + + ledger_summary = ledger_sub.add_parser("summary", help="Monthly rollup summary from ledger") + ledger_summary.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)") + ledger_summary.set_defaults(func=_cmd_ledger_summary) + + ledger_balance = ledger_sub.add_parser("set-balance", help="Store opening cash balance in ledger meta") + ledger_balance.add_argument("balance", type=float) + ledger_balance.add_argument("--currency", default="EUR") + ledger_balance.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)") + ledger_balance.set_defaults(func=_cmd_ledger_set_balance) + + evaluate = sub.add_parser( + "evaluate", + help="Evaluate runway from ledger burns (cron/systemd friendly)", + ) + evaluate.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)") + evaluate.add_argument("--balance", type=float, help="Override stored opening balance") + evaluate.add_argument("--threshold", type=float, default=3.0) + evaluate.add_argument("--currency", default="EUR") + evaluate.add_argument("--allocated", type=float) + evaluate.add_argument("--spent", type=float) + evaluate.add_argument("--emit", action="store_true", help="Emit fin→dev and fin→canon signals") + evaluate.add_argument("--api-base") + evaluate.set_defaults(func=_cmd_evaluate) + + evidence = sub.add_parser("evidence", help="Write runway dogfood evidence artefact") + evidence.add_argument("--ledger", help="Ledger database path (default: .fin-hub/ledger.db)") + evidence.add_argument("--balance", type=float, default=12000.0) + evidence.add_argument("--threshold", type=float, default=3.0) + evidence.add_argument("--currency", default="EUR") + evidence.add_argument("--output-dir", help="Output directory (default: docs/evidence)") + evidence.add_argument( + "--seed-fixtures", + action="store_true", + help="Import tests/fixtures CSVs before generating the report", + ) + evidence.set_defaults(func=_cmd_evidence) + return parser diff --git a/src/fin_hub/services/evaluate.py b/src/fin_hub/services/evaluate.py new file mode 100644 index 0000000..0aecab5 --- /dev/null +++ b/src/fin_hub/services/evaluate.py @@ -0,0 +1,56 @@ +"""Scheduled runway evaluation from ledger data.""" + +from __future__ import annotations + +import json +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.runway import compute_runway + + +def evaluate_runway( + *, + ledger_path: Path, + opening_balance: float | None = None, + alert_threshold_months: float = 3.0, + currency: str = "EUR", + allocated: float | None = None, + spent: float | None = None, + emit: bool = False, + api_base: str | None = None, +) -> dict: + stored_balance, stored_currency = get_opening_balance(ledger_path) + balance = opening_balance if opening_balance is not None else stored_balance + if balance is None: + raise ValueError("opening balance required — use ledger set-balance or --balance") + + effective_currency = currency or stored_currency + burns = monthly_burn_series(ledger_path=ledger_path, currency=effective_currency) + runway = compute_runway( + current_balance=balance, + monthly_burns=burns, + alert_threshold_months=alert_threshold_months, + currency=effective_currency, + ) + alerts = evaluate_budget_alerts(runway=runway, allocated=allocated, spent=spent) + + report: dict = { + "runway": runway.as_dict(), + "alerts": [alert.as_dict() for alert in alerts], + "monthly_burns": burns, + "ledger_path": str(ledger_path.resolve()), + } + + if emit: + report["dev_hub"] = emit_resource_pressure(alerts, api_base=api_base) + report["canon"] = emit_viability_alert(runway, api_base=api_base) + + return report + + +def evaluate_runway_json(**kwargs) -> str: + return json.dumps(evaluate_runway(**kwargs), indent=2, default=str) \ No newline at end of file diff --git a/src/fin_hub/services/evidence.py b/src/fin_hub/services/evidence.py new file mode 100644 index 0000000..78cbf21 --- /dev/null +++ b/src/fin_hub/services/evidence.py @@ -0,0 +1,159 @@ +"""Generate non-secret runway evidence artefacts for dogfood and RaaS.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +from fin_hub.services.alerts import evaluate_budget_alerts +from fin_hub.services.ledger import ( + import_csv, + ledger_stats, + monthly_burn_series, + monthly_summary, + set_opening_balance, +) +from fin_hub.services.runway import compute_runway + +FIXTURE_DIR = Path(__file__).resolve().parents[3] / "tests" / "fixtures" +DEFAULT_EVIDENCE_DIR = Path("docs/evidence") + + +def seed_fixture_ledger( + *, + ledger_path: Path, + opening_balance: float = 12000.0, + currency: str = "EUR", +) -> dict: + set_opening_balance(ledger_path, opening_balance, currency=currency) + imports = [ + import_csv(FIXTURE_DIR / "cloud-costs.csv", "cloud", ledger_path=ledger_path), + import_csv(FIXTURE_DIR / "anthropic-billing.csv", "anthropic", ledger_path=ledger_path), + import_csv(FIXTURE_DIR / "hosteurope.csv", "hosteurope", ledger_path=ledger_path), + ] + return { + "opening_balance": opening_balance, + "opening_balance_currency": currency, + "imports": [result.as_dict() for result in imports], + } + + +def build_runway_report( + *, + ledger_path: Path, + opening_balance: float | None = None, + alert_threshold_months: float = 3.0, + currency: str = "EUR", +) -> str: + stats = ledger_stats(ledger_path=ledger_path) + balance = opening_balance if opening_balance is not None else stats["opening_balance"] + if balance is None: + raise ValueError("opening balance required — set via ledger set-balance or --balance") + + burns = monthly_burn_series(ledger_path=ledger_path, currency=currency) + runway = compute_runway( + current_balance=balance, + monthly_burns=burns, + alert_threshold_months=alert_threshold_months, + currency=currency, + ) + alerts = evaluate_budget_alerts(runway=runway) + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + report_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + lines = [ + f"# Runway Dogfood Evidence — {report_date}", + "", + f"Status: generated v0.1 ({generated_at})", + "", + "Non-secret internal dogfood artefact for RaaS managed-tier runway evidence.", + "Fixture CSVs under `tests/fixtures/` imported into a local SQLite ledger.", + "", + "## Opening position", + "", + f"- Opening balance: **{balance:,.2f} {currency}**", + f"- Alert threshold: **{alert_threshold_months:.1f} months**", + f"- Ledger path: `{ledger_path.resolve()}`", + "", + "## Monthly burn (ledger rollups)", + "", + "| Period | Currency | Total | Entries |", + "| --- | --- | ---: | ---: |", + ] + for rollup in monthly_summary(ledger_path=ledger_path): + lines.append( + f"| {rollup.period_month} | {rollup.currency} | {rollup.total:,.2f} | {rollup.entry_count} |" + ) + + months_display = ( + f"{runway.months_remaining:.1f}" + if runway.months_remaining != float("inf") + else "∞" + ) + lines.extend( + [ + "", + "## Runway projection", + "", + f"- Average monthly burn ({currency}): **{runway.monthly_burn:,.2f}**", + f"- Projected runway: **{months_display} months**", + f"- Below threshold: **{'yes' if runway.below_threshold else 'no'}**", + "", + "## Alerts", + "", + ] + ) + if alerts: + for alert in alerts: + lines.append(f"- **{alert.code}** ({alert.severity}): {alert.summary}") + else: + lines.append("- No alerts triggered.") + + lines.extend( + [ + "", + "## Federation signals", + "", + "When `STATE_HUB_API` is reachable, `finhub evaluate --emit` posts:", + "", + "- fin→dev resource pressure (`budget_pressure`, `runway_below_threshold`)", + "- fin→canon viability alert when runway is below threshold", + "", + "## References", + "", + "- `docs/raas-mvp-packaging.md` — managed-tier runway add-on", + "- `workplans/FIN-WP-0001-runway-operations-lane.md` — operational lane", + ] + ) + return "\n".join(lines) + "\n" + + +def write_runway_evidence( + *, + ledger_path: Path, + output_dir: Path | None = None, + opening_balance: float | None = None, + seed_fixtures: bool = False, + alert_threshold_months: float = 3.0, + currency: str = "EUR", +) -> Path: + if seed_fixtures: + seed_fixture_ledger( + ledger_path=ledger_path, + opening_balance=opening_balance or 12000.0, + currency=currency, + ) + report_dir = output_dir or DEFAULT_EVIDENCE_DIR + report_dir.mkdir(parents=True, exist_ok=True) + report_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + output_path = report_dir / f"runway-dogfood-{report_date}.md" + output_path.write_text( + build_runway_report( + ledger_path=ledger_path, + opening_balance=opening_balance, + alert_threshold_months=alert_threshold_months, + currency=currency, + ), + encoding="utf-8", + ) + return output_path \ No newline at end of file diff --git a/src/fin_hub/services/ledger.py b/src/fin_hub/services/ledger.py new file mode 100644 index 0000000..2010458 --- /dev/null +++ b/src/fin_hub/services/ledger.py @@ -0,0 +1,303 @@ +"""SQLite-backed cost ledger for ingested CSV rows.""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import asdict, dataclass +from datetime import date, datetime, timezone +from pathlib import Path + +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 + +DEFAULT_LEDGER_PATH = Path(".fin-hub/ledger.db") + + +@dataclass(frozen=True) +class LedgerEntry: + source_type: str + category: str + label: str + amount: float + currency: str + period_month: str + incurred_on: date | None + source_path: str + + +@dataclass(frozen=True) +class MonthlyRollup: + period_month: str + currency: str + total: float + entry_count: int + + def as_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class ImportResult: + source_type: str + source_path: str + rows_imported: int + skipped: bool + + def as_dict(self) -> dict: + return asdict(self) + + +def default_ledger_path() -> Path: + return DEFAULT_LEDGER_PATH + + +def _connect(path: Path) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + _ensure_schema(conn) + return conn + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, + category TEXT NOT NULL, + label TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT NOT NULL, + period_month TEXT NOT NULL, + incurred_on TEXT, + source_path TEXT NOT NULL, + imported_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ledger_imports ( + source_path TEXT NOT NULL, + source_mtime REAL NOT NULL, + source_type TEXT NOT NULL, + row_count INTEGER NOT NULL, + imported_at TEXT NOT NULL, + PRIMARY KEY (source_path, source_mtime) + ); + + CREATE TABLE IF NOT EXISTS ledger_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + """ + ) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def set_opening_balance(path: Path, balance: float, *, currency: str = "EUR") -> None: + with _connect(path) as conn: + conn.execute( + "INSERT OR REPLACE INTO ledger_meta (key, value) VALUES (?, ?)", + ("opening_balance", str(balance)), + ) + conn.execute( + "INSERT OR REPLACE INTO ledger_meta (key, value) VALUES (?, ?)", + ("opening_balance_currency", currency), + ) + conn.commit() + + +def get_opening_balance(path: Path) -> tuple[float | None, str]: + with _connect(path) as conn: + balance_row = conn.execute( + "SELECT value FROM ledger_meta WHERE key = 'opening_balance'" + ).fetchone() + currency_row = conn.execute( + "SELECT value FROM ledger_meta WHERE key = 'opening_balance_currency'" + ).fetchone() + if balance_row is None: + return None, "EUR" + currency = currency_row["value"] if currency_row else "EUR" + return float(balance_row["value"]), currency + + +def _entries_from_cloud(path: Path) -> list[LedgerEntry]: + resolved = str(path.resolve()) + return [ + LedgerEntry( + source_type="cloud", + category=row.service, + label=row.service, + amount=row.amount, + currency=row.currency, + period_month=row.period_month, + incurred_on=row.incurred_on, + source_path=resolved, + ) + for row in parse_cloud_cost_csv(path) + ] + + +def _entries_from_anthropic(path: Path) -> list[LedgerEntry]: + resolved = str(path.resolve()) + return [ + LedgerEntry( + source_type="anthropic", + category=row.provider, + label=row.model, + amount=row.cost, + currency=row.currency, + period_month=row.recorded_at.strftime("%Y-%m"), + incurred_on=row.recorded_at.date(), + source_path=resolved, + ) + for row in parse_anthropic_billing_csv(path) + ] + + +def _entries_from_hosteurope(path: Path) -> list[LedgerEntry]: + resolved = str(path.resolve()) + return [ + LedgerEntry( + source_type="hosteurope", + category=row.service_id, + label=row.title, + amount=row.amount, + currency=row.currency, + period_month=row.period_month, + incurred_on=row.incurred_on, + source_path=resolved, + ) + for row in parse_hosteurope_csv(path) + ] + + +_IMPORTERS = { + "cloud": _entries_from_cloud, + "anthropic": _entries_from_anthropic, + "hosteurope": _entries_from_hosteurope, +} + + +def import_csv( + path: Path, + source_type: str, + *, + ledger_path: Path | None = None, + force: bool = False, +) -> ImportResult: + if source_type not in _IMPORTERS: + raise ValueError(f"Unknown source type '{source_type}'") + ledger = ledger_path or default_ledger_path() + resolved = str(path.resolve()) + mtime = path.stat().st_mtime + entries = _IMPORTERS[source_type](path) + imported_at = _utc_now() + + with _connect(ledger) as conn: + if not force: + existing = conn.execute( + "SELECT row_count FROM ledger_imports WHERE source_path = ? AND source_mtime = ?", + (resolved, mtime), + ).fetchone() + if existing is not None: + return ImportResult( + source_type=source_type, + source_path=resolved, + rows_imported=0, + skipped=True, + ) + + for entry in entries: + conn.execute( + """ + INSERT INTO ledger_entries ( + source_type, category, label, amount, currency, + period_month, incurred_on, source_path, imported_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + entry.source_type, + entry.category, + entry.label, + entry.amount, + entry.currency, + entry.period_month, + entry.incurred_on.isoformat() if entry.incurred_on else None, + entry.source_path, + imported_at, + ), + ) + conn.execute( + """ + INSERT OR REPLACE INTO ledger_imports ( + source_path, source_mtime, source_type, row_count, imported_at + ) VALUES (?, ?, ?, ?, ?) + """, + (resolved, mtime, source_type, len(entries), imported_at), + ) + conn.commit() + + return ImportResult( + source_type=source_type, + source_path=resolved, + rows_imported=len(entries), + skipped=False, + ) + + +def monthly_summary(*, ledger_path: Path | None = None) -> list[MonthlyRollup]: + ledger = ledger_path or default_ledger_path() + with _connect(ledger) as conn: + rows = conn.execute( + """ + SELECT period_month, currency, SUM(amount) AS total, COUNT(*) AS entry_count + FROM ledger_entries + GROUP BY period_month, currency + ORDER BY period_month, currency + """ + ).fetchall() + return [ + MonthlyRollup( + period_month=row["period_month"], + currency=row["currency"], + total=float(row["total"]), + entry_count=int(row["entry_count"]), + ) + for row in rows + ] + + +def monthly_burn_series( + *, + ledger_path: Path | None = None, + currency: str = "EUR", +) -> list[float]: + rollups = monthly_summary(ledger_path=ledger_path) + burns = [rollup.total for rollup in rollups if rollup.currency == currency] + return burns + + +def ledger_stats(*, ledger_path: Path | None = None) -> dict: + ledger = ledger_path or default_ledger_path() + balance, balance_currency = get_opening_balance(ledger) + rollups = monthly_summary(ledger_path=ledger) + with _connect(ledger) as conn: + entry_count = conn.execute("SELECT COUNT(*) AS c FROM ledger_entries").fetchone()["c"] + import_count = conn.execute("SELECT COUNT(*) AS c FROM ledger_imports").fetchone()["c"] + return { + "ledger_path": str(ledger.resolve()), + "opening_balance": balance, + "opening_balance_currency": balance_currency, + "entry_count": entry_count, + "import_count": import_count, + "monthly_rollups": [rollup.as_dict() for rollup in rollups], + } + + +def ledger_stats_json(*, ledger_path: Path | None = None) -> str: + return json.dumps(ledger_stats(ledger_path=ledger_path), indent=2) \ No newline at end of file diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..b366023 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from fin_hub.services.evaluate import evaluate_runway +from fin_hub.services.evidence import build_runway_report, seed_fixture_ledger, write_runway_evidence +from fin_hub.services.ledger import import_csv, monthly_burn_series, monthly_summary, set_opening_balance + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_ledger_import_and_monthly_summary(tmp_path: Path): + ledger = tmp_path / "ledger.db" + set_opening_balance(ledger, 12000.0) + + cloud = import_csv(FIXTURES / "cloud-costs.csv", "cloud", ledger_path=ledger) + host = import_csv(FIXTURES / "hosteurope.csv", "hosteurope", ledger_path=ledger) + + assert cloud.rows_imported == 3 + assert host.rows_imported == 2 + + rollups = monthly_summary(ledger_path=ledger) + eur_totals = {rollup.period_month: rollup.total for rollup in rollups if rollup.currency == "EUR"} + assert eur_totals["2026-06"] == pytest.approx(85.5 + 99.8) + + burns = monthly_burn_series(ledger_path=ledger, currency="EUR") + assert burns == [pytest.approx(185.3)] + + +def test_ledger_import_skips_unchanged_file(tmp_path: Path): + ledger = tmp_path / "ledger.db" + first = import_csv(FIXTURES / "cloud-costs.csv", "cloud", ledger_path=ledger) + second = import_csv(FIXTURES / "cloud-costs.csv", "cloud", ledger_path=ledger) + assert first.rows_imported == 3 + assert second.skipped is True + assert second.rows_imported == 0 + + +def test_evaluate_runway_from_ledger(tmp_path: Path): + ledger = tmp_path / "ledger.db" + seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0) + report = evaluate_runway(ledger_path=ledger, alert_threshold_months=3.0) + assert report["runway"]["current_balance"] == 12000.0 + assert report["runway"]["monthly_burn"] > 0 + + +def test_build_runway_report_and_write_evidence(tmp_path: Path): + ledger = tmp_path / "ledger.db" + seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0) + text = build_runway_report(ledger_path=ledger) + assert "Runway Dogfood Evidence" in text + assert "2026-06" in text + + output = write_runway_evidence( + ledger_path=ledger, + output_dir=tmp_path / "evidence", + opening_balance=12000.0, + ) + assert output.exists() + assert "runway-dogfood-" in output.name \ No newline at end of file diff --git a/workplans/FIN-WP-0001-runway-operations-lane.md b/workplans/FIN-WP-0001-runway-operations-lane.md index b9c4d0c..8612e15 100644 --- a/workplans/FIN-WP-0001-runway-operations-lane.md +++ b/workplans/FIN-WP-0001-runway-operations-lane.md @@ -54,7 +54,7 @@ spine (resolves C-24). ```task id: FIN-WP-0001-T03 -status: todo +status: done priority: high state_hub_task_id: "9350d324-6194-44d5-9923-3547d69a2eed" ``` @@ -66,7 +66,7 @@ CLI subcommands: `ledger import`, `ledger summary`. ```task id: FIN-WP-0001-T04 -status: todo +status: done priority: medium state_hub_task_id: "9574806e-e414-421d-8b58-844ccc9e6af7" ``` @@ -79,7 +79,7 @@ cron or systemd timer. ```task id: FIN-WP-0001-T05 -status: todo +status: done priority: medium state_hub_task_id: "0b3b5e95-8c72-4e70-9171-14ef23c9e969" ```