Implement FIN-WP-0001-T06 HTTP runway read surface

Add create_runway_router() following the hub-core factory pattern,
expose GET /runway/summary and /runway/monthly-burn via create_app,
and add finhub serve for local operation.
This commit is contained in:
tegwick 2026-07-08 22:46:24 +02:00
parent 0f2436b34c
commit 29dbaf1c2d
11 changed files with 270 additions and 4 deletions

View file

@ -16,6 +16,8 @@ fin-specific models and services live here.
| `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 |
| `routers/runway.py` | `create_runway_router()` read factory (hub-core pattern) |
| `app.py` | FastAPI app factory (`create_app`) |
| `cli.py` | Operator CLI (`finhub` entrypoint) |
### Data flow
@ -24,8 +26,8 @@ fin-specific models and services live here.
CSV exports → ingest parsers → cost models → runway/alerts → coupling emit → State Hub progress
```
v0.1 uses manual CSV import into a local SQLite ledger. HTTP read surface
remains deferred (`FIN-WP-0001-T06`).
v0.1 uses manual CSV import into a local SQLite ledger. HTTP read surface:
`GET /runway/summary` and `GET /runway/monthly-burn` via `finhub serve`.
## Quick Reference

View file

@ -32,6 +32,11 @@ uv run finhub evaluate --emit
# CLI — dogfood evidence artefact
uv run finhub evidence --seed-fixtures
# HTTP read API
uv run finhub serve
# GET http://127.0.0.1:8080/runway/summary
# GET http://127.0.0.1:8080/runway/monthly-burn
# CLI — emit cross-hub signals (requires STATE_HUB_API)
uv run finhub runway --balance 12000 --monthly-burn 2100 --emit
```

View file

@ -24,6 +24,7 @@ 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 serve
uv run finhub import-cloud tests/fixtures/cloud-costs.csv
uv run finhub ops-costs tests/fixtures/hosteurope.csv
```

33
src/fin_hub/app.py Normal file
View file

@ -0,0 +1,33 @@
"""Fin-hub FastAPI application factory."""
from __future__ import annotations
from fastapi import FastAPI
from pydantic import BaseModel
from fin_hub import __version__
from fin_hub.routers import create_runway_router
class HealthResponse(BaseModel):
service: str
status: str
version: str
def create_app() -> FastAPI:
app = FastAPI(
title="Fin Hub API",
version=__version__,
description="Read surfaces for runway projection and monthly burn.",
)
@app.get("/healthz", response_model=HealthResponse, tags=["system"])
def healthz() -> HealthResponse:
return HealthResponse(service="fin-hub", status="ok", version=__version__)
app.include_router(create_runway_router())
return app
app = create_app()

View file

@ -136,6 +136,19 @@ def _cmd_evaluate(args: argparse.Namespace) -> int:
return 0
def _cmd_serve(args: argparse.Namespace) -> int:
import uvicorn
uvicorn.run(
"fin_hub.app:create_app",
factory=True,
host=args.host,
port=args.port,
reload=args.reload,
)
return 0
def _cmd_evidence(args: argparse.Namespace) -> int:
output = write_runway_evidence(
ledger_path=_ledger_path(args),
@ -227,6 +240,12 @@ def build_parser() -> argparse.ArgumentParser:
)
evidence.set_defaults(func=_cmd_evidence)
serve = sub.add_parser("serve", help="Run the HTTP read API")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8080)
serve.add_argument("--reload", action="store_true")
serve.set_defaults(func=_cmd_serve)
return parser

View file

@ -0,0 +1,3 @@
from fin_hub.routers.runway import create_runway_router
__all__ = ["create_runway_router"]

View file

@ -0,0 +1,100 @@
"""Runway read router factory (hub-core pattern)."""
from __future__ import annotations
import os
from collections.abc import Callable
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fin_hub.schemas.runway import MonthlyBurnRead, RunwayAlertRead, RunwayProjectionRead, RunwaySummaryRead
from fin_hub.services.evaluate import evaluate_runway
from fin_hub.services.ledger import default_ledger_path, get_opening_balance, monthly_summary
LedgerPathResolver = Callable[[str | None], Path]
def _default_ledger_path_resolver(ledger: str | None) -> Path:
if ledger:
return Path(ledger)
env_path = os.environ.get("FIN_HUB_LEDGER")
if env_path:
return Path(env_path)
return default_ledger_path()
def _projection_from_dict(runway: dict) -> RunwayProjectionRead:
months = runway["months_remaining"]
if months == float("inf"):
months_remaining: float | None = None
label = "unlimited"
else:
months_remaining = float(months)
label = f"{months_remaining:.1f}"
return RunwayProjectionRead(
current_balance=float(runway["current_balance"]),
monthly_burn=float(runway["monthly_burn"]),
months_remaining=months_remaining,
months_remaining_label=label,
alert_threshold_months=float(runway["alert_threshold_months"]),
below_threshold=bool(runway["below_threshold"]),
currency=str(runway["currency"]),
computed_at=str(runway["computed_at"]),
)
def create_runway_router(
*,
prefix: str = "/runway",
ledger_path_resolver: LedgerPathResolver | None = None,
) -> APIRouter:
resolve_ledger = ledger_path_resolver or _default_ledger_path_resolver
router = APIRouter(prefix=prefix, tags=["runway"])
@router.get("/monthly-burn", response_model=list[MonthlyBurnRead])
def get_monthly_burn(
ledger: str | None = Query(default=None, description="Ledger database path override"),
currency: str | None = Query(default=None, description="Filter rollups by currency"),
) -> list[MonthlyBurnRead]:
ledger_path = resolve_ledger(ledger)
if not ledger_path.exists():
raise HTTPException(status_code=404, detail=f"Ledger not found: {ledger_path}")
rollups = monthly_summary(ledger_path=ledger_path)
if currency:
rollups = [rollup for rollup in rollups if rollup.currency == currency.upper()]
return [MonthlyBurnRead(**rollup.as_dict()) for rollup in rollups]
@router.get("/summary", response_model=RunwaySummaryRead)
def get_runway_summary(
ledger: str | None = Query(default=None, description="Ledger database path override"),
balance: float | None = Query(default=None, description="Override stored opening balance"),
threshold: float = Query(default=3.0, ge=0.0),
currency: str = Query(default="EUR", min_length=3, max_length=3),
allocated: float | None = Query(default=None, ge=0.0),
spent: float | None = Query(default=None, ge=0.0),
) -> RunwaySummaryRead:
ledger_path = resolve_ledger(ledger)
if not ledger_path.exists():
raise HTTPException(status_code=404, detail=f"Ledger not found: {ledger_path}")
try:
report = evaluate_runway(
ledger_path=ledger_path,
opening_balance=balance,
alert_threshold_months=threshold,
currency=currency.upper(),
allocated=allocated,
spent=spent,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
stored_balance, stored_currency = get_opening_balance(ledger_path)
return RunwaySummaryRead(
runway=_projection_from_dict(report["runway"]),
alerts=[RunwayAlertRead(**alert) for alert in report["alerts"]],
opening_balance=balance if balance is not None else stored_balance,
opening_balance_currency=currency.upper() if balance is not None else stored_currency,
ledger_path=str(ledger_path.resolve()),
)
return router

View file

@ -0,0 +1,5 @@
"""Pydantic schemas for fin-hub HTTP surfaces."""
from fin_hub.schemas.runway import MonthlyBurnRead, RunwayAlertRead, RunwaySummaryRead
__all__ = ["MonthlyBurnRead", "RunwayAlertRead", "RunwaySummaryRead"]

View file

@ -0,0 +1,40 @@
"""Read schemas for runway and monthly burn HTTP surfaces."""
from __future__ import annotations
from pydantic import BaseModel, Field
class MonthlyBurnRead(BaseModel):
period_month: str
currency: str
total: float
entry_count: int
class RunwayProjectionRead(BaseModel):
current_balance: float
monthly_burn: float
months_remaining: float | None = Field(
description="Projected runway in months; null when burn is zero and balance is positive.",
)
months_remaining_label: str
alert_threshold_months: float
below_threshold: bool
currency: str
computed_at: str
class RunwayAlertRead(BaseModel):
code: str
severity: str
summary: str
detail: dict
class RunwaySummaryRead(BaseModel):
runway: RunwayProjectionRead
alerts: list[RunwayAlertRead]
opening_balance: float | None
opening_balance_currency: str
ledger_path: str

58
tests/test_api.py Normal file
View file

@ -0,0 +1,58 @@
from pathlib import Path
from fastapi.testclient import TestClient
from fin_hub.app import create_app
from fin_hub.routers.runway import create_runway_router
from fin_hub.services.evidence import seed_fixture_ledger
def _client_for_ledger(ledger: Path) -> TestClient:
app = create_app()
app.include_router(
create_runway_router(ledger_path_resolver=lambda _: ledger),
prefix="/test",
)
return TestClient(app)
def test_runway_summary_and_monthly_burn_endpoints(tmp_path: Path):
ledger = tmp_path / "ledger.db"
seed_fixture_ledger(ledger_path=ledger, opening_balance=12000.0)
client = _client_for_ledger(ledger)
burn_response = client.get("/test/runway/monthly-burn")
assert burn_response.status_code == 200
burns = burn_response.json()
assert any(row["currency"] == "EUR" and row["period_month"] == "2026-06" for row in burns)
summary_response = client.get("/test/runway/summary")
assert summary_response.status_code == 200
summary = summary_response.json()
assert summary["opening_balance"] == 12000.0
assert summary["runway"]["currency"] == "EUR"
assert summary["runway"]["monthly_burn"] > 0
def test_runway_summary_requires_balance_when_unset(tmp_path: Path):
from fin_hub.services.ledger import monthly_summary
ledger = tmp_path / "empty.db"
monthly_summary(ledger_path=ledger)
client = _client_for_ledger(ledger)
response = client.get("/test/runway/summary")
assert response.status_code == 422
def test_runway_summary_404_when_ledger_missing(tmp_path: Path):
ledger = tmp_path / "missing.db"
client = _client_for_ledger(ledger)
response = client.get("/test/runway/summary?balance=12000")
assert response.status_code == 404
def test_create_app_healthz():
client = TestClient(create_app())
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["service"] == "fin-hub"

View file

@ -4,7 +4,7 @@ type: workplan
title: "Runway Operations and Federation Evidence"
domain: infotech
repo: fin-hub
status: active
status: finished
owner: fin-hub
topic_slug: infotech
created: "2026-07-08"
@ -91,7 +91,7 @@ CSVs plus live ledger data. Reference from `docs/raas-mvp-packaging.md`.
```task
id: FIN-WP-0001-T06
status: wait
status: done
priority: low
state_hub_task_id: "40e46ee1-49ee-4c3f-ab6b-9471ea1ab3d9"
```