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:
Dmitry
2026-09-18 13:43:49 +03:00
parent 295438914d
commit 3fc7a954b9
40 changed files with 5261 additions and 0 deletions
+134
View File
@@ -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]
+51
View File
@@ -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"
+74
View File
@@ -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")
+31
View File
@@ -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)
+101
View File
@@ -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
+52
View File
@@ -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()
+32
View File
@@ -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)