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

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