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:
parent
0f2436b34c
commit
29dbaf1c2d
11 changed files with 270 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
33
src/fin_hub/app.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
3
src/fin_hub/routers/__init__.py
Normal file
3
src/fin_hub/routers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from fin_hub.routers.runway import create_runway_router
|
||||
|
||||
__all__ = ["create_runway_router"]
|
||||
100
src/fin_hub/routers/runway.py
Normal file
100
src/fin_hub/routers/runway.py
Normal 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
|
||||
5
src/fin_hub/schemas/__init__.py
Normal file
5
src/fin_hub/schemas/__init__.py
Normal 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"]
|
||||
40
src/fin_hub/schemas/runway.py
Normal file
40
src/fin_hub/schemas/runway.py
Normal 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
58
tests/test_api.py
Normal 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"
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue