feat(api): FastAPI — auth, RFC 7807 и роуты фазы 1
Префикс /api/v1, operationId = "<tag>_<name>", чтобы Dart-клиент получил методы вроде accountsList, а не list_accounts_api_v1_accounts_get. Ошибки — application/problem+json. Auth: access-JWT на час + refresh на 30 дней, который хранится хэшем и ротируется, логин ограничен по частоте в памяти. Деньги в JSON — всегда позиционные строки (api/schemas/common.py): float в проводе потерял бы копейки, которые NUMERIC(24,10) бережёт. Тестовый harness поднимает свой Postgres через pg_ctl (pytest-postgresql), мигрирует его один раз на сессию и усекает таблицы после каждого теста.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""FastAPI application factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from fintracker import __version__
|
||||
from fintracker.api.errors import install_error_handlers
|
||||
from fintracker.api.routers import (
|
||||
accounts,
|
||||
auth,
|
||||
cashflow,
|
||||
categories,
|
||||
health,
|
||||
metrics,
|
||||
networth,
|
||||
rules,
|
||||
sync,
|
||||
transactions,
|
||||
)
|
||||
from fintracker.api.web import mount_web
|
||||
from fintracker.config import get_settings
|
||||
from fintracker.db import reset_engine
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
|
||||
def _operation_id(route: APIRoute) -> str:
|
||||
# "auth_login" instead of "login_api_v1_auth_login_post": readable Dart method names
|
||||
tag = route.tags[0] if route.tags else "default"
|
||||
return f"{tag}_{route.name}"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
if settings.is_dev_secret:
|
||||
log.warning("JWT_SECRET is the insecure development default")
|
||||
yield
|
||||
await reset_engine()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title="fin-tracker",
|
||||
version=__version__,
|
||||
lifespan=_lifespan,
|
||||
generate_unique_id_function=_operation_id,
|
||||
docs_url=f"{API_PREFIX}/docs",
|
||||
openapi_url=f"{API_PREFIX}/openapi.json",
|
||||
redoc_url=None,
|
||||
)
|
||||
if settings.cors_origin_list:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
install_error_handlers(app)
|
||||
_install_openapi(app)
|
||||
app.include_router(health.router, prefix=API_PREFIX)
|
||||
app.include_router(auth.router, prefix=API_PREFIX)
|
||||
app.include_router(sync.router, prefix=API_PREFIX)
|
||||
app.include_router(accounts.router, prefix=API_PREFIX)
|
||||
app.include_router(categories.router, prefix=API_PREFIX)
|
||||
app.include_router(transactions.router, prefix=API_PREFIX)
|
||||
app.include_router(rules.router, prefix=API_PREFIX)
|
||||
app.include_router(networth.router, prefix=API_PREFIX)
|
||||
app.include_router(cashflow.router, prefix=API_PREFIX)
|
||||
app.include_router(metrics.router, prefix=API_PREFIX)
|
||||
if settings.web_dir is not None:
|
||||
mount_web(app, settings.web_dir, API_PREFIX)
|
||||
return app
|
||||
|
||||
|
||||
PROBLEM_SCHEMA = {
|
||||
"title": "Problem",
|
||||
"type": "object",
|
||||
"description": "RFC 7807 error body (application/problem+json)",
|
||||
"required": ["status", "title"],
|
||||
"properties": {
|
||||
"status": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"detail": {"type": "string"},
|
||||
"errors": {"type": "array", "items": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _install_openapi(app: FastAPI) -> None:
|
||||
"""Post-process the schema: every error is a Problem, never FastAPI's anyOf-heavy
|
||||
HTTPValidationError (which client generators cannot represent)."""
|
||||
|
||||
def custom_openapi() -> dict[str, Any]:
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
routes=app.routes,
|
||||
servers=[{"url": "/"}],
|
||||
)
|
||||
components = schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
components.pop("HTTPValidationError", None)
|
||||
components.pop("ValidationError", None)
|
||||
components["Problem"] = PROBLEM_SCHEMA
|
||||
problem_ref = {
|
||||
"description": "Error",
|
||||
"content": {
|
||||
"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}
|
||||
},
|
||||
}
|
||||
for path_item in schema.get("paths", {}).values():
|
||||
for op in path_item.values():
|
||||
responses = op.get("responses", {})
|
||||
responses.pop("422", None)
|
||||
responses["default"] = problem_ref
|
||||
app.openapi_schema = schema
|
||||
return schema
|
||||
|
||||
app.openapi = custom_openapi # type: ignore[method-assign]
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.security import decode_access_token
|
||||
from fintracker.config import Settings, get_settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import AppUser
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with get_sessionmaker()() as session:
|
||||
yield session
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
session: SessionDep,
|
||||
settings: SettingsDep,
|
||||
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
) -> AppUser:
|
||||
if creds is None:
|
||||
raise Problem(401, "Unauthorized", "Missing bearer token")
|
||||
user_id = decode_access_token(creds.credentials, settings)
|
||||
if user_id is None:
|
||||
raise Problem(401, "Unauthorized", "Invalid or expired token")
|
||||
user = await session.get(AppUser, user_id)
|
||||
if user is None:
|
||||
raise Problem(401, "Unauthorized", "Unknown user")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[AppUser, Depends(get_current_user)]
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
fwd = request.headers.get("x-forwarded-for")
|
||||
if fwd:
|
||||
return fwd.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""RFC 7807 problem+json errors for every failure the API returns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
PROBLEM_TYPE = "application/problem+json"
|
||||
|
||||
|
||||
class Problem(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
title: str,
|
||||
detail: str | None = None,
|
||||
*,
|
||||
extra: dict[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self.status_code = status_code
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
self.extra = extra or {}
|
||||
self.headers = headers
|
||||
|
||||
|
||||
def _response(
|
||||
status_code: int,
|
||||
title: str,
|
||||
detail: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
) -> JSONResponse:
|
||||
body: dict[str, Any] = {"status": status_code, "title": title}
|
||||
if detail:
|
||||
body["detail"] = detail
|
||||
if extra:
|
||||
body.update(extra)
|
||||
return JSONResponse(body, status_code=status_code, media_type=PROBLEM_TYPE, headers=headers)
|
||||
|
||||
|
||||
def install_error_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(Problem)
|
||||
async def _problem(_: Request, exc: Problem) -> JSONResponse:
|
||||
return _response(exc.status_code, exc.title, exc.detail, exc.extra, exc.headers)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _http(_: Request, exc: HTTPException) -> JSONResponse:
|
||||
detail = exc.detail if isinstance(exc.detail, str) else None
|
||||
return _response(exc.status_code, _title_for(exc.status_code), detail, headers=exc.headers)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return _response(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"Validation error",
|
||||
extra={"errors": exc.errors()},
|
||||
)
|
||||
|
||||
|
||||
def _title_for(code: int) -> str:
|
||||
return {
|
||||
400: "Bad request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not found",
|
||||
409: "Conflict",
|
||||
429: "Too many requests",
|
||||
}.get(code, "Error")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Tiny in-memory sliding-window limiter for the login endpoint.
|
||||
|
||||
Single-process API, single user: no need for Redis. Caddy adds nothing here by
|
||||
default, so this is the only brute-force protection — keep it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
def __init__(self, limit: int, window_seconds: float = 60.0) -> None:
|
||||
self.limit = limit
|
||||
self.window = window_seconds
|
||||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def hit(self, key: str) -> float | None:
|
||||
"""Register an attempt. Returns seconds to wait if over the limit, else None."""
|
||||
now = time.monotonic()
|
||||
q = self._hits[key]
|
||||
while q and now - q[0] > self.window:
|
||||
q.popleft()
|
||||
if len(q) >= self.limit:
|
||||
return self.window - (now - q[0])
|
||||
q.append(now)
|
||||
return None
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
self._hits.pop(key, None)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.accounts import AccountOut, AccountPatch
|
||||
from fintracker.models import Account
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.get("", name="list")
|
||||
async def list_accounts(session: SessionDep, _: CurrentUser) -> list[AccountOut]:
|
||||
"""Every account, archived ones included — the client decides what to show."""
|
||||
rows = (
|
||||
await session.execute(select(Account).order_by(Account.archived, Account.name, Account.id))
|
||||
).scalars()
|
||||
return [AccountOut.model_validate(a, from_attributes=True) for a in rows]
|
||||
|
||||
|
||||
@router.patch("/{account_id}", name="patch")
|
||||
async def patch_account(
|
||||
account_id: int, body: AccountPatch, session: SessionDep, _: CurrentUser
|
||||
) -> AccountOut:
|
||||
"""Update the user-owned fields. Everything else is overwritten by the next sync."""
|
||||
account = await session.get(Account, account_id)
|
||||
if account is None:
|
||||
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"No account {account_id}")
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if "name" in changes and not (changes["name"] or "").strip():
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "name must not be empty")
|
||||
if "include_in_net_worth" in changes and changes["include_in_net_worth"] is None:
|
||||
raise Problem(
|
||||
status.HTTP_400_BAD_REQUEST, "Bad request", "include_in_net_worth must not be null"
|
||||
)
|
||||
if "role" in changes and changes["role"] is None:
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "role must not be null")
|
||||
mirror = changes.get("mirror_of_account_id")
|
||||
if mirror is not None:
|
||||
if mirror == account_id:
|
||||
raise Problem(
|
||||
status.HTTP_400_BAD_REQUEST, "Bad request", "an account cannot mirror itself"
|
||||
)
|
||||
if await session.get(Account, mirror) is None:
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", f"No account {mirror}")
|
||||
|
||||
for field, value in changes.items():
|
||||
setattr(account, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(account)
|
||||
return AccountOut.model_validate(account, from_attributes=True)
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep, SettingsDep, client_ip
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.ratelimit import SlidingWindowLimiter
|
||||
from fintracker.api.schemas.auth import LoginRequest, RefreshRequest, TokenPair, UserOut
|
||||
from fintracker.api.security import (
|
||||
create_access_token,
|
||||
hash_refresh_token,
|
||||
new_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
from fintracker.config import Settings
|
||||
from fintracker.models import AppUser, RefreshToken
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
_login_limiter: SlidingWindowLimiter | None = None
|
||||
|
||||
|
||||
def _limiter(settings: Settings) -> SlidingWindowLimiter:
|
||||
global _login_limiter
|
||||
if _login_limiter is None:
|
||||
_login_limiter = SlidingWindowLimiter(settings.login_rate_limit_per_minute)
|
||||
return _login_limiter
|
||||
|
||||
|
||||
async def _issue_pair(session: AsyncSession, user: AppUser, settings: Settings) -> TokenPair:
|
||||
access, ttl = create_access_token(user.id, settings)
|
||||
raw = new_refresh_token()
|
||||
session.add(
|
||||
RefreshToken(
|
||||
user_id=user.id,
|
||||
token_hash=hash_refresh_token(raw),
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=settings.refresh_token_ttl_seconds),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return TokenPair(access_token=access, refresh_token=raw, expires_in=ttl)
|
||||
|
||||
|
||||
@router.post("/login", name="login")
|
||||
async def login(
|
||||
body: LoginRequest, request: Request, session: SessionDep, settings: SettingsDep
|
||||
) -> TokenPair:
|
||||
limiter = _limiter(settings)
|
||||
key = client_ip(request)
|
||||
wait = limiter.hit(key)
|
||||
if wait is not None:
|
||||
raise Problem(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"Too many requests",
|
||||
"Too many login attempts, try again later",
|
||||
headers={"Retry-After": str(int(wait) + 1)},
|
||||
)
|
||||
user = (
|
||||
await session.execute(select(AppUser).where(AppUser.email == body.email.lower()))
|
||||
).scalar_one_or_none()
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
raise Problem(status.HTTP_401_UNAUTHORIZED, "Unauthorized", "Invalid email or password")
|
||||
limiter.reset(key)
|
||||
return await _issue_pair(session, user, settings)
|
||||
|
||||
|
||||
async def _find_refresh(session: AsyncSession, raw: str) -> RefreshToken | None:
|
||||
return (
|
||||
await session.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == hash_refresh_token(raw))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
@router.post("/refresh", name="refresh")
|
||||
async def refresh(body: RefreshRequest, session: SessionDep, settings: SettingsDep) -> TokenPair:
|
||||
row = await _find_refresh(session, body.refresh_token)
|
||||
if row is None or row.revoked_at is not None or row.expires_at <= datetime.now(UTC):
|
||||
raise Problem(status.HTTP_401_UNAUTHORIZED, "Unauthorized", "Invalid refresh token")
|
||||
row.revoked_at = datetime.now(UTC) # rotation: the old token dies with this call
|
||||
user = await session.get(AppUser, row.user_id)
|
||||
if user is None:
|
||||
raise Problem(status.HTTP_401_UNAUTHORIZED, "Unauthorized", "Unknown user")
|
||||
return await _issue_pair(session, user, settings)
|
||||
|
||||
|
||||
@router.post("/logout", name="logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(body: RefreshRequest, session: SessionDep) -> None:
|
||||
row = await _find_refresh(session, body.refresh_token)
|
||||
if row is not None and row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.get("/me", name="me")
|
||||
async def me(user: CurrentUser) -> UserOut:
|
||||
return UserOut(id=user.id, email=user.email)
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.cashflow import CashFlowMonth, RunwayOut, SpendingRow
|
||||
from fintracker.models import (
|
||||
Category,
|
||||
MetricCashFlowMonthly,
|
||||
MetricRunway,
|
||||
MetricSpendingByCategory,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["cashflow"])
|
||||
|
||||
MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
|
||||
|
||||
|
||||
@router.get("/cashflow/monthly", name="monthly")
|
||||
async def cash_flow_monthly(
|
||||
session: SessionDep,
|
||||
_: CurrentUser,
|
||||
months: Annotated[int, Query(ge=1, le=120)] = 12,
|
||||
) -> list[CashFlowMonth]:
|
||||
"""The last `months` months, oldest first."""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(MetricCashFlowMonthly).order_by(MetricCashFlowMonthly.month.desc()).limit(months)
|
||||
)
|
||||
).scalars()
|
||||
out = [CashFlowMonth.model_validate(r, from_attributes=True) for r in rows]
|
||||
return sorted(out, key=lambda r: r.month)
|
||||
|
||||
|
||||
@router.get("/spending/categories", name="spending")
|
||||
async def spending_by_category(
|
||||
session: SessionDep,
|
||||
_: CurrentUser,
|
||||
month: Annotated[str | None, Query(description="YYYY-MM; defaults to the latest month")] = None,
|
||||
) -> list[SpendingRow]:
|
||||
"""Expenses of one month by category, largest first."""
|
||||
target: date | None
|
||||
if month is None:
|
||||
target = (
|
||||
await session.execute(
|
||||
select(MetricSpendingByCategory.month)
|
||||
.order_by(MetricSpendingByCategory.month.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target is None:
|
||||
return []
|
||||
else:
|
||||
m = MONTH_RE.match(month)
|
||||
if m is None:
|
||||
raise Problem(
|
||||
status.HTTP_400_BAD_REQUEST, "Bad request", "month must look like YYYY-MM"
|
||||
)
|
||||
year, mon = int(m.group(1)), int(m.group(2))
|
||||
if not 1 <= mon <= 12:
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "month must be 01..12")
|
||||
target = date(year, mon, 1)
|
||||
|
||||
names = {c.id: c.name for c in (await session.execute(select(Category))).scalars()}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(MetricSpendingByCategory)
|
||||
.where(MetricSpendingByCategory.month == target)
|
||||
.order_by(MetricSpendingByCategory.amount_rub.desc())
|
||||
)
|
||||
).scalars()
|
||||
return [
|
||||
SpendingRow(
|
||||
month=r.month,
|
||||
category_id=r.category_id,
|
||||
category_name=names.get(r.category_id) if r.category_id else None,
|
||||
root_category_id=r.root_category_id,
|
||||
root_category_name=names.get(r.root_category_id) if r.root_category_id else None,
|
||||
amount_rub=r.amount_rub,
|
||||
txn_count=r.txn_count,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runway", name="runway")
|
||||
async def runway(session: SessionDep, _: CurrentUser) -> RunwayOut | None:
|
||||
"""How many months the liquid reserve covers; null before the first refresh."""
|
||||
row = (
|
||||
await session.execute(select(MetricRunway).order_by(MetricRunway.as_of.desc()).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return RunwayOut.model_validate(row, from_attributes=True)
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.schemas.categories import CategoryOut
|
||||
from fintracker.models import Category
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
|
||||
@router.get("", name="list")
|
||||
async def list_categories(session: SessionDep, _: CurrentUser) -> list[CategoryOut]:
|
||||
"""Flat list of the ZenMoney tag tree; the client nests it by `parent_id`."""
|
||||
rows = (await session.execute(select(Category).order_by(Category.name, Category.id))).scalars()
|
||||
return [CategoryOut.model_validate(c, from_attributes=True) for c in rows]
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from fintracker import __version__
|
||||
from fintracker.api.deps import SessionDep
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
database: str
|
||||
|
||||
|
||||
@router.get("/health", name="check")
|
||||
async def check(session: SessionDep) -> Health:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
db = "ok"
|
||||
except Exception as exc:
|
||||
db = f"error: {type(exc).__name__}"
|
||||
return Health(status="ok" if db == "ok" else "degraded", version=__version__, database=db)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.schemas.metrics import DataQualityRow, RefreshLogOut
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
from fintracker.models import MetricDataQuality, MetricRefreshLog
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
_SEVERITY_ORDER = {"error": 0, "warn": 1, "info": 2}
|
||||
|
||||
|
||||
@router.get("/data-quality", name="data_quality")
|
||||
async def data_quality(session: SessionDep, _: CurrentUser) -> list[DataQualityRow]:
|
||||
"""Findings of the latest refresh, most serious first."""
|
||||
rows = (await session.execute(select(MetricDataQuality))).scalars().all()
|
||||
out = [DataQualityRow.model_validate(r, from_attributes=True) for r in rows]
|
||||
return sorted(out, key=lambda r: (_SEVERITY_ORDER.get(r.severity, 9), r.check_name, -r.count))
|
||||
|
||||
|
||||
@router.get("/metrics/status", name="status")
|
||||
async def metrics_status(session: SessionDep, _: CurrentUser) -> RefreshLogOut | None:
|
||||
"""When the metric tables were last rebuilt, and whether it failed."""
|
||||
row = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MetricRefreshLog).order_by(MetricRefreshLog.started_at.desc()).limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return RefreshLogOut.model_validate(row, from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/metrics/refresh", name="refresh", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def metrics_refresh(session: SessionDep, _: CurrentUser) -> RefreshLogOut:
|
||||
"""Rebuild every metric_* table inline (seconds at personal volumes)."""
|
||||
entry = await refresh_all(session, trigger="manual")
|
||||
return RefreshLogOut.model_validate(entry, from_attributes=True)
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.schemas.networth import AccountBalance, NetWorthBreakdown, NetWorthDay
|
||||
from fintracker.models import Account, MetricNetWorthDaily
|
||||
from fintracker.pricing.fx import FxTable
|
||||
|
||||
router = APIRouter(prefix="/networth", tags=["networth"])
|
||||
|
||||
DEFAULT_WINDOW_DAYS = 365
|
||||
ZERO = Decimal(0)
|
||||
|
||||
|
||||
@router.get("/series", name="series")
|
||||
async def net_worth_series(
|
||||
session: SessionDep,
|
||||
_: CurrentUser,
|
||||
date_from: Annotated[date | None, Query(alias="from")] = None,
|
||||
date_to: Annotated[date | None, Query(alias="to")] = None,
|
||||
) -> list[NetWorthDay]:
|
||||
"""Daily net worth; defaults to the last 365 days."""
|
||||
end = date_to or today_local()
|
||||
start = date_from or end - timedelta(days=DEFAULT_WINDOW_DAYS)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(MetricNetWorthDaily)
|
||||
.where(MetricNetWorthDaily.d >= start, MetricNetWorthDaily.d <= end)
|
||||
.order_by(MetricNetWorthDaily.d)
|
||||
)
|
||||
).scalars()
|
||||
return [_day(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/breakdown", name="breakdown")
|
||||
async def net_worth_breakdown(session: SessionDep, _: CurrentUser) -> NetWorthBreakdown:
|
||||
"""The latest day's buckets, plus every account's current balance native and in RUB."""
|
||||
latest = (
|
||||
await session.execute(
|
||||
select(MetricNetWorthDaily).order_by(MetricNetWorthDaily.d.desc()).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
accounts = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Account)
|
||||
.where(
|
||||
Account.include_in_net_worth.is_(True),
|
||||
Account.archived.is_(False),
|
||||
Account.mirror_of_account_id.is_(None),
|
||||
Account.balance.is_not(None),
|
||||
)
|
||||
.order_by(Account.role, Account.name, Account.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
fx = await FxTable.load(session)
|
||||
as_of = latest.d if latest is not None else today_local()
|
||||
balances = [
|
||||
AccountBalance(
|
||||
account_id=a.id,
|
||||
name=a.name,
|
||||
currency=a.currency,
|
||||
role=a.role,
|
||||
balance=a.balance if a.balance is not None else ZERO,
|
||||
balance_rub=fx.to_rub(a.balance, a.currency, as_of),
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
day = _day(latest) if latest is not None else None
|
||||
return NetWorthBreakdown(
|
||||
d=day.d if day else None,
|
||||
total_rub=day.total_rub if day else ZERO,
|
||||
liquid_rub=day.liquid_rub if day else ZERO,
|
||||
savings_rub=day.savings_rub if day else ZERO,
|
||||
investment_rub=day.investment_rub if day else ZERO,
|
||||
debt_rub=day.debt_rub if day else ZERO,
|
||||
by_currency=day.by_currency if day else {},
|
||||
missing_fx_count=day.missing_fx_count if day else 0,
|
||||
accounts=balances,
|
||||
)
|
||||
|
||||
|
||||
def _day(row: MetricNetWorthDaily) -> NetWorthDay:
|
||||
return NetWorthDay(
|
||||
d=row.d,
|
||||
total_rub=row.total_rub,
|
||||
liquid_rub=row.liquid_rub,
|
||||
savings_rub=row.savings_rub,
|
||||
investment_rub=row.investment_rub,
|
||||
debt_rub=row.debt_rub,
|
||||
by_currency={k: str(v) for k, v in (row.by_currency or {}).items()},
|
||||
missing_fx_count=row.missing_fx_count,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.metrics import RefreshLogOut
|
||||
from fintracker.api.schemas.rules import RuleCreate, RuleOut, RulePatch
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
from fintracker.models import Rule
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
|
||||
|
||||
@router.get("", name="list")
|
||||
async def list_rules(session: SessionDep, _: CurrentUser) -> list[RuleOut]:
|
||||
rows = (await session.execute(select(Rule).order_by(Rule.priority, Rule.id))).scalars()
|
||||
return [RuleOut.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@router.get("/stale", name="stale")
|
||||
async def stale_rules(session: SessionDep, _: CurrentUser) -> list[RuleOut]:
|
||||
"""Enabled rules that matched nothing in the latest refresh: the payee was renamed,
|
||||
or the transaction was re-categorised in ZenMoney."""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Rule)
|
||||
.where(Rule.enabled.is_(True), Rule.match_count == 0)
|
||||
.order_by(Rule.priority, Rule.id)
|
||||
)
|
||||
).scalars()
|
||||
return [RuleOut.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
|
||||
async def create_rule(body: RuleCreate, session: SessionDep, _: CurrentUser) -> RuleOut:
|
||||
rule = Rule(**body.model_dump())
|
||||
session.add(rule)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
return RuleOut.model_validate(rule, from_attributes=True)
|
||||
|
||||
|
||||
@router.patch("/{rule_id}", name="patch")
|
||||
async def patch_rule(rule_id: int, body: RulePatch, session: SessionDep, _: CurrentUser) -> RuleOut:
|
||||
rule = await session.get(Rule, rule_id)
|
||||
if rule is None:
|
||||
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"No rule {rule_id}")
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
for field in ("kind", "match_type", "pattern", "enabled", "priority"):
|
||||
if field in changes and changes[field] is None:
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", f"{field} must not be null")
|
||||
for field, value in changes.items():
|
||||
setattr(rule, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
return RuleOut.model_validate(rule, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_rule(rule_id: int, session: SessionDep, _: CurrentUser) -> Response:
|
||||
rule = await session.get(Rule, rule_id)
|
||||
if rule is None:
|
||||
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"No rule {rule_id}")
|
||||
await session.delete(rule)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/apply", name="apply", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def apply_rules(session: SessionDep, _: CurrentUser) -> RefreshLogOut:
|
||||
"""Re-run the whole metric refresh so edited rules take effect everywhere at once."""
|
||||
entry = await refresh_all(session, trigger="rules")
|
||||
return RefreshLogOut.model_validate(entry, from_attributes=True)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.sync import SourceStatus, SyncJobOut, SyncRunOut
|
||||
from fintracker.models import JobStatus, SyncJob, SyncRun, SyncState
|
||||
from fintracker.sources import registry
|
||||
|
||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||
|
||||
|
||||
@router.get("/status", name="status")
|
||||
async def sync_status(session: SessionDep, _: CurrentUser) -> list[SourceStatus]:
|
||||
states = {s.source: s for s in (await session.execute(select(SyncState))).scalars()}
|
||||
active = (
|
||||
await session.execute(
|
||||
select(SyncJob.source).where(SyncJob.status.in_([JobStatus.queued, JobStatus.running]))
|
||||
)
|
||||
).scalars()
|
||||
queued = set(active)
|
||||
out: list[SourceStatus] = []
|
||||
for name in registry.names():
|
||||
st = states.get(name)
|
||||
last = (
|
||||
await session.execute(
|
||||
select(SyncRun)
|
||||
.where(SyncRun.source == name)
|
||||
.order_by(SyncRun.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
out.append(
|
||||
SourceStatus(
|
||||
source=name,
|
||||
cursor=st.cursor if st else None,
|
||||
last_run_at=st.last_run_at if st else None,
|
||||
last_success_at=st.last_success_at if st else None,
|
||||
last_run_status=last.status if last else None,
|
||||
queued=name in queued,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/runs", name="runs")
|
||||
async def sync_runs(
|
||||
session: SessionDep,
|
||||
_: CurrentUser,
|
||||
source: str | None = None,
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
) -> list[SyncRunOut]:
|
||||
q = select(SyncRun).order_by(SyncRun.started_at.desc()).limit(limit)
|
||||
if source:
|
||||
q = q.where(SyncRun.source == source)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return [SyncRunOut.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{source}", name="trigger", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def trigger(source: str, session: SessionDep, _: CurrentUser) -> SyncJobOut:
|
||||
if source not in registry.names():
|
||||
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Unknown source: {source}")
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(SyncJob).where(
|
||||
SyncJob.source == source, SyncJob.status.in_([JobStatus.queued, JobStatus.running])
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return SyncJobOut.model_validate(existing, from_attributes=True)
|
||||
job = SyncJob(source=source, status=JobStatus.queued)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
await session.refresh(job)
|
||||
return SyncJobOut.model_validate(job, from_attributes=True)
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.schemas.transactions import TransactionOut, TransactionPage
|
||||
from fintracker.models import CashTxn, CashTxnTag, FlowType
|
||||
from fintracker.pricing.fx import FxTable
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
@router.get("", name="list")
|
||||
async def list_transactions(
|
||||
session: SessionDep,
|
||||
_: CurrentUser,
|
||||
date_from: Annotated[date | None, Query(alias="from")] = None,
|
||||
date_to: Annotated[date | None, Query(alias="to")] = None,
|
||||
account_id: int | None = None,
|
||||
category_id: int | None = None,
|
||||
flow_type: FlowType | None = None,
|
||||
q: Annotated[str | None, Query(description="substring of payee or comment")] = None,
|
||||
include_deleted: bool = False,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=500)] = 50,
|
||||
) -> TransactionPage:
|
||||
"""One page of transactions, newest first, with RUB amounts at each own date's rate."""
|
||||
conditions = []
|
||||
if not include_deleted:
|
||||
conditions.append(CashTxn.deleted.is_(False))
|
||||
if date_from is not None:
|
||||
conditions.append(CashTxn.date >= date_from)
|
||||
if date_to is not None:
|
||||
conditions.append(CashTxn.date <= date_to)
|
||||
if account_id is not None:
|
||||
conditions.append(
|
||||
or_(
|
||||
CashTxn.income_account_id == account_id,
|
||||
CashTxn.outcome_account_id == account_id,
|
||||
)
|
||||
)
|
||||
if category_id is not None:
|
||||
conditions.append(CashTxn.category_id == category_id)
|
||||
if flow_type is not None:
|
||||
conditions.append(CashTxn.flow_type == flow_type)
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
conditions.append(or_(CashTxn.payee.ilike(pattern), CashTxn.comment.ilike(pattern)))
|
||||
|
||||
total = (
|
||||
await session.execute(select(func.count()).select_from(CashTxn).where(*conditions))
|
||||
).scalar_one()
|
||||
rows = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(CashTxn)
|
||||
.where(*conditions)
|
||||
.order_by(CashTxn.date.desc(), CashTxn.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
tags: dict[int, list[int]] = defaultdict(list)
|
||||
if rows:
|
||||
tag_rows = (
|
||||
await session.execute(
|
||||
select(CashTxnTag.txn_id, CashTxnTag.category_id)
|
||||
.where(CashTxnTag.txn_id.in_([r.id for r in rows]))
|
||||
.order_by(CashTxnTag.txn_id, CashTxnTag.ord)
|
||||
)
|
||||
).all()
|
||||
for t in tag_rows:
|
||||
tags[t.txn_id].append(t.category_id)
|
||||
|
||||
fx = await FxTable.load(session)
|
||||
items = [
|
||||
TransactionOut(
|
||||
id=r.id,
|
||||
source_id=r.source_id,
|
||||
ts=r.ts,
|
||||
date=r.date,
|
||||
income=r.income,
|
||||
income_currency=r.income_currency,
|
||||
income_account_id=r.income_account_id,
|
||||
income_rub=fx.to_rub(r.income, r.income_currency, r.date) if r.income else None,
|
||||
outcome=r.outcome,
|
||||
outcome_currency=r.outcome_currency,
|
||||
outcome_account_id=r.outcome_account_id,
|
||||
outcome_rub=fx.to_rub(r.outcome, r.outcome_currency, r.date) if r.outcome else None,
|
||||
payee=r.payee,
|
||||
payee_canonical=r.payee_canonical,
|
||||
comment=r.comment,
|
||||
mcc=r.mcc,
|
||||
hold=r.hold,
|
||||
deleted=r.deleted,
|
||||
flow_type=r.flow_type,
|
||||
category_id=r.category_id,
|
||||
is_one_off=r.is_one_off,
|
||||
trip_id=r.trip_id,
|
||||
tags=tags.get(r.id, []),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return TransactionPage(items=items, total=total, page=page, page_size=page_size)
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from fintracker.api.schemas.common import MoneyOpt
|
||||
from fintracker.models import AccountKind, AccountRole, Broker, EventSource
|
||||
|
||||
|
||||
class AccountOut(BaseModel):
|
||||
id: int
|
||||
kind: AccountKind
|
||||
source: str
|
||||
source_id: str
|
||||
broker: Broker | None
|
||||
name: str
|
||||
currency: str
|
||||
role: AccountRole
|
||||
include_in_net_worth: bool
|
||||
mirror_of_account_id: int | None
|
||||
primary_event_source: EventSource | None
|
||||
archived: bool
|
||||
opened_at: date | None
|
||||
balance: MoneyOpt
|
||||
"""Native currency, as the source reported it; JSON string."""
|
||||
balance_as_of: datetime | None
|
||||
start_balance: MoneyOpt
|
||||
credit_limit: MoneyOpt
|
||||
|
||||
|
||||
class AccountPatch(BaseModel):
|
||||
"""Only the fields the user owns: everything else comes from the source on each sync.
|
||||
|
||||
Unset fields are left alone; an explicit `null` clears the column.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = None
|
||||
include_in_net_worth: bool | None = None
|
||||
role: AccountRole | None = None
|
||||
mirror_of_account_id: int | None = None
|
||||
primary_event_source: EventSource | None = None
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fintracker.api.schemas.common import Money, MoneyOpt
|
||||
|
||||
|
||||
class CashFlowMonth(BaseModel):
|
||||
month: date
|
||||
"""First day of the month."""
|
||||
income_rub: Money
|
||||
expense_rub: Money
|
||||
baseline_rub: Money
|
||||
one_off_rub: Money
|
||||
savings_transfer_rub: Money
|
||||
savings_rate: MoneyOpt
|
||||
txn_count: int
|
||||
|
||||
|
||||
class SpendingRow(BaseModel):
|
||||
month: date
|
||||
category_id: int | None
|
||||
category_name: str | None
|
||||
"""Null for the uncategorised row."""
|
||||
root_category_id: int | None
|
||||
root_category_name: str | None
|
||||
amount_rub: Money
|
||||
txn_count: int
|
||||
|
||||
|
||||
class RunwayOut(BaseModel):
|
||||
as_of: date
|
||||
liquid_reserve_rub: Money
|
||||
avg_baseline_3m_rub: Money
|
||||
runway_months: MoneyOpt
|
||||
"""Null when there is no complete month of history to average."""
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CategoryOut(BaseModel):
|
||||
"""Flat list; the client builds the tree from `parent_id` (ZenMoney nests one level)."""
|
||||
|
||||
id: int
|
||||
parent_id: int | None
|
||||
name: str
|
||||
icon: str | None
|
||||
color: int | None
|
||||
show_income: bool
|
||||
show_outcome: bool
|
||||
archived: bool
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Shared field types for the API schemas.
|
||||
|
||||
Money is `Decimal` everywhere in Python and a plain decimal STRING on the wire (AGENTS.md:
|
||||
no floats in the protocol). Pydantic would already emit a string for `Decimal`, but it uses
|
||||
`str()`, which prints `Decimal("0E-10")` as `"0E-10"` — legal JSON, and a needless trap for a
|
||||
generated client. `format(value, "f")` always gives positional notation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import PlainSerializer, WithJsonSchema
|
||||
|
||||
_STRING = WithJsonSchema({"type": "string", "description": "decimal as string"})
|
||||
_NULLABLE_STRING = WithJsonSchema(
|
||||
{
|
||||
"anyOf": [{"type": "string", "description": "decimal as string"}, {"type": "null"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _fixed(value: Decimal) -> str:
|
||||
return format(value, "f")
|
||||
|
||||
|
||||
def _fixed_opt(value: Decimal | None) -> str | None:
|
||||
return None if value is None else format(value, "f")
|
||||
|
||||
|
||||
Money = Annotated[
|
||||
Decimal,
|
||||
PlainSerializer(_fixed, return_type=str, when_used="json"),
|
||||
_STRING,
|
||||
]
|
||||
|
||||
MoneyOpt = Annotated[
|
||||
Decimal | None,
|
||||
PlainSerializer(_fixed_opt, return_type=str | None, when_used="json"),
|
||||
_NULLABLE_STRING,
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DataQualityRow(BaseModel):
|
||||
id: int
|
||||
check_name: str
|
||||
severity: str
|
||||
"""info | warn | error"""
|
||||
detail: str
|
||||
count: int
|
||||
ref: dict[str, Any] | None
|
||||
computed_at: datetime
|
||||
|
||||
|
||||
class RefreshLogOut(BaseModel):
|
||||
id: int
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
trigger: str
|
||||
error: str | None
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fintracker.api.schemas.common import Money, MoneyOpt
|
||||
from fintracker.models import AccountRole
|
||||
|
||||
|
||||
class NetWorthDay(BaseModel):
|
||||
d: date
|
||||
total_rub: Money
|
||||
liquid_rub: Money
|
||||
savings_rub: Money
|
||||
investment_rub: Money
|
||||
debt_rub: Money
|
||||
by_currency: dict[str, str]
|
||||
"""{ccy: native total}, before conversion."""
|
||||
missing_fx_count: int
|
||||
|
||||
|
||||
class AccountBalance(BaseModel):
|
||||
account_id: int
|
||||
name: str
|
||||
currency: str
|
||||
role: AccountRole
|
||||
balance: Money
|
||||
balance_rub: MoneyOpt
|
||||
"""Null when the currency has no rate on the latest day (see /data-quality)."""
|
||||
|
||||
|
||||
class NetWorthBreakdown(BaseModel):
|
||||
d: date | None
|
||||
"""Null when no metrics have been built yet."""
|
||||
total_rub: Money
|
||||
liquid_rub: Money
|
||||
savings_rub: Money
|
||||
investment_rub: Money
|
||||
debt_rub: Money
|
||||
by_currency: dict[str, str]
|
||||
missing_fx_count: int
|
||||
accounts: list[AccountBalance]
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from fintracker.models import RuleKind, RuleMatchType
|
||||
|
||||
|
||||
class RuleOut(BaseModel):
|
||||
id: int
|
||||
kind: RuleKind
|
||||
match_type: RuleMatchType
|
||||
pattern: str
|
||||
value: str | None
|
||||
note: str | None
|
||||
enabled: bool
|
||||
priority: int
|
||||
last_matched_at: datetime | None
|
||||
match_count: int
|
||||
"""Matches in the latest refresh — 0 means the rule has rotted (see /rules/stale)."""
|
||||
|
||||
|
||||
class RuleCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: RuleKind
|
||||
match_type: RuleMatchType
|
||||
pattern: str = Field(min_length=1, max_length=512)
|
||||
value: str | None = Field(default=None, max_length=512)
|
||||
note: str | None = None
|
||||
enabled: bool = True
|
||||
priority: int = 100
|
||||
|
||||
|
||||
class RulePatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: RuleKind | None = None
|
||||
match_type: RuleMatchType | None = None
|
||||
pattern: str | None = Field(default=None, min_length=1, max_length=512)
|
||||
value: str | None = Field(default=None, max_length=512)
|
||||
note: str | None = None
|
||||
enabled: bool | None = None
|
||||
priority: int | None = None
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fintracker.models import JobStatus, RunStatus
|
||||
|
||||
|
||||
class SourceStatus(BaseModel):
|
||||
source: str
|
||||
cursor: str | None
|
||||
last_run_at: datetime | None
|
||||
last_success_at: datetime | None
|
||||
last_run_status: RunStatus | None
|
||||
queued: bool
|
||||
|
||||
|
||||
class SyncRunOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
source: str
|
||||
status: RunStatus
|
||||
triggered_by: str
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
cursor_before: str | None
|
||||
cursor_after: str | None
|
||||
counts: dict[str, Any] | None
|
||||
warnings: list[Any] | None
|
||||
error: str | None
|
||||
|
||||
|
||||
class SyncJobOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
source: str
|
||||
status: JobStatus
|
||||
requested_at: datetime
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fintracker.api.schemas.common import Money, MoneyOpt
|
||||
from fintracker.models import FlowType
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
id: int
|
||||
source_id: str
|
||||
ts: datetime
|
||||
date: date
|
||||
income: Money
|
||||
income_currency: str | None
|
||||
income_account_id: int | None
|
||||
income_rub: MoneyOpt
|
||||
"""Converted at the rate of THIS transaction's date; null when that day has no rate."""
|
||||
outcome: Money
|
||||
outcome_currency: str | None
|
||||
outcome_account_id: int | None
|
||||
outcome_rub: MoneyOpt
|
||||
payee: str | None
|
||||
payee_canonical: str | None
|
||||
comment: str | None
|
||||
mcc: int | None
|
||||
hold: bool
|
||||
deleted: bool
|
||||
flow_type: FlowType
|
||||
category_id: int | None
|
||||
is_one_off: bool
|
||||
trip_id: int | None
|
||||
tags: list[int]
|
||||
"""Category ids in ZenMoney order; `tags[0]` is the primary tag."""
|
||||
|
||||
|
||||
class TransactionPage(BaseModel):
|
||||
items: list[TransactionOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Password hashing, access JWTs and opaque refresh tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
from fintracker.config import Settings
|
||||
|
||||
_hasher = PasswordHash.recommended()
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return _hasher.verify(password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(user_id: int, settings: Settings) -> tuple[str, int]:
|
||||
now = datetime.now(UTC)
|
||||
ttl = settings.access_token_ttl_seconds
|
||||
exp = now + timedelta(seconds=ttl)
|
||||
payload = {"sub": str(user_id), "iat": now, "exp": exp, "typ": "access"}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM), ttl
|
||||
|
||||
|
||||
def decode_access_token(token: str, settings: Settings) -> int | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if payload.get("typ") != "access":
|
||||
return None
|
||||
try:
|
||||
return int(payload["sub"])
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def new_refresh_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_refresh_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Serve the Flutter web build from the API process (dev / single-container setups).
|
||||
|
||||
Caddy does this in production; here it exists so `just api` with WEB_DIR set gives a
|
||||
same-origin app without CORS. Any path that is not a file falls back to index.html so
|
||||
go_router deep links survive a reload. API paths are never shadowed: routers are
|
||||
registered first and the catch-all only handles GET.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from fintracker.api.errors import Problem
|
||||
|
||||
|
||||
def mount_web(app: FastAPI, web_dir: Path, api_prefix: str) -> None:
|
||||
root = web_dir.resolve()
|
||||
index = root / "index.html"
|
||||
if not index.is_file():
|
||||
raise RuntimeError(f"WEB_DIR {root} has no index.html")
|
||||
|
||||
@app.get("/{path:path}", include_in_schema=False, name="web_spa")
|
||||
async def spa(path: str) -> FileResponse:
|
||||
if path.startswith(api_prefix.lstrip("/")):
|
||||
raise Problem(404, "Not found")
|
||||
candidate = (root / path).resolve() if path else index
|
||||
if candidate.is_file() and candidate.is_relative_to(root):
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(index)
|
||||
Reference in New Issue
Block a user