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)
View File
+102
View File
@@ -0,0 +1,102 @@
from factories import make_account
from fintracker.models import AccountRole
async def test_auth_is_required(client):
for method, url in (
("get", "/api/v1/accounts"),
("get", "/api/v1/categories"),
("get", "/api/v1/transactions"),
("get", "/api/v1/rules"),
("get", "/api/v1/networth/series"),
("get", "/api/v1/cashflow/monthly"),
("get", "/api/v1/runway"),
("get", "/api/v1/data-quality"),
("get", "/api/v1/metrics/status"),
("post", "/api/v1/metrics/refresh"),
):
r = await getattr(client, method)(url)
assert r.status_code == 401, (url, r.status_code)
assert r.headers["content-type"].startswith("application/problem+json")
async def test_list_accounts_exposes_balance_as_string(client, auth_headers):
await make_account(name="Карта", balance="1234.56")
r = await client.get("/api/v1/accounts", headers=auth_headers)
assert r.status_code == 200
(row,) = r.json()
assert row["name"] == "Карта"
assert isinstance(row["balance"], str)
assert row["balance"].startswith("1234.56")
assert row["role"] == "liquid"
assert row["include_in_net_worth"] is True
async def test_patch_account(client, auth_headers):
broker = await make_account(name="Брокер", role=AccountRole.investment)
zm = await make_account(name="Зеркало")
r = await client.patch(
f"/api/v1/accounts/{zm}",
headers=auth_headers,
json={"include_in_net_worth": False, "mirror_of_account_id": broker, "role": "investment"},
)
assert r.status_code == 200
assert r.json()["include_in_net_worth"] is False
assert r.json()["mirror_of_account_id"] == broker
assert r.json()["role"] == "investment"
# unset fields are untouched
r = await client.patch(
f"/api/v1/accounts/{zm}", headers=auth_headers, json={"name": "Зеркало+"}
)
assert r.json()["mirror_of_account_id"] == broker
assert r.json()["name"] == "Зеркало+"
async def test_patch_account_validation(client, auth_headers):
account_id = await make_account()
r = await client.patch("/api/v1/accounts/999999", headers=auth_headers, json={"name": "x"})
assert r.status_code == 404
r = await client.patch(
f"/api/v1/accounts/{account_id}",
headers=auth_headers,
json={"mirror_of_account_id": account_id},
)
assert r.status_code == 400
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"mirror_of_account_id": 4242}
)
assert r.status_code == 400
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"role": "nonsense"}
)
assert r.status_code == 422
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"currency": "USD"}
)
assert r.status_code == 422 # extra="forbid": source-owned fields are not patchable
r = await client.patch(
f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"name": " "}
)
assert r.status_code == 400
async def test_categories_are_flat_with_parent_ids(client, auth_headers):
from factories import make_category
food = await make_category("Еда")
await make_category("Продукты", parent_id=food)
r = await client.get("/api/v1/categories", headers=auth_headers)
assert r.status_code == 200
by_name = {c["name"]: c for c in r.json()}
assert by_name["Еда"]["parent_id"] is None
assert by_name["Продукты"]["parent_id"] == food
+91
View File
@@ -0,0 +1,91 @@
from datetime import timedelta
from factories import make_account, make_category, make_txn, month_back
from fintracker.analytics import today_local
from fintracker.models import AccountRole
async def test_refresh_populates_every_metric_endpoint(client, auth_headers):
card = await make_account(name="Карта", balance="200000")
await make_account(name="Вклад", balance="100000", role=AccountRole.savings)
await make_account(name="Кредитка", balance="-5000", role=AccountRole.debt)
food = await make_category("Еда")
m = month_back(1)
await make_txn(m + timedelta(days=1), income="150000", income_account_id=card)
await make_txn(
m + timedelta(days=2),
outcome="30000",
outcome_account_id=card,
primary_category_id=food,
)
await make_txn(today_local() - timedelta(days=1), outcome="500", outcome_account_id=card)
assert (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() is None
r = await client.post("/api/v1/metrics/refresh", headers=auth_headers)
assert r.status_code == 202
assert r.json()["error"] is None
assert r.json()["finished_at"] is not None
status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json()
assert status["trigger"] == "manual"
series = (await client.get("/api/v1/networth/series", headers=auth_headers)).json()
assert series
assert series[-1]["d"] == str(today_local())
assert series[-1]["total_rub"].startswith("295000")
assert isinstance(series[-1]["by_currency"]["RUB"], str)
breakdown = (await client.get("/api/v1/networth/breakdown", headers=auth_headers)).json()
assert breakdown["d"] == str(today_local())
assert breakdown["debt_rub"].startswith("-5000")
assert {a["name"] for a in breakdown["accounts"]} == {"Карта", "Вклад", "Кредитка"}
assert all(isinstance(a["balance_rub"], str) for a in breakdown["accounts"])
monthly = (await client.get("/api/v1/cashflow/monthly", headers=auth_headers)).json()
last_month = next(row for row in monthly if row["month"] == str(m))
assert last_month["income_rub"].startswith("150000")
assert last_month["expense_rub"].startswith("30000")
assert last_month["savings_rate"].startswith("0.8")
spending = (
await client.get(
"/api/v1/spending/categories",
headers=auth_headers,
params={"month": m.strftime("%Y-%m")},
)
).json()
assert spending[0]["category_name"] == "Еда"
assert spending[0]["root_category_name"] == "Еда"
assert spending[0]["amount_rub"].startswith("30000")
runway = (await client.get("/api/v1/runway", headers=auth_headers)).json()
assert runway["liquid_reserve_rub"].startswith("300000")
# 30000 baseline in the previous month, 0 in the two before it -> 10000 average
assert runway["avg_baseline_3m_rub"].startswith("10000")
assert runway["runway_months"].startswith("30")
quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json()
assert isinstance(quality, list)
async def test_spending_rejects_a_bad_month(client, auth_headers):
r = await client.get(
"/api/v1/spending/categories", headers=auth_headers, params={"month": "2026/01"}
)
assert r.status_code == 400
r = await client.get(
"/api/v1/spending/categories", headers=auth_headers, params={"month": "2026-13"}
)
assert r.status_code == 400
async def test_empty_database_reports_no_transactions(client, auth_headers):
await client.post("/api/v1/metrics/refresh", headers=auth_headers)
quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json()
assert [row["check_name"] for row in quality] == ["no_transactions"]
assert (await client.get("/api/v1/runway", headers=auth_headers)).json()[
"runway_months"
] is None
assert (await client.get("/api/v1/networth/series", headers=auth_headers)).json() == []
assert (await client.get("/api/v1/spending/categories", headers=auth_headers)).json() == []
+81
View File
@@ -0,0 +1,81 @@
from datetime import timedelta
from factories import make_account, make_txn
from fintracker.analytics import today_local
async def test_rules_crud(client, auth_headers):
r = await client.post(
"/api/v1/rules",
headers=auth_headers,
json={"kind": "savings", "match_type": "payee", "pattern": "Копилка", "priority": 10},
)
assert r.status_code == 201
rule_id = r.json()["id"]
assert r.json()["enabled"] is True
assert r.json()["match_count"] == 0
r = await client.get("/api/v1/rules", headers=auth_headers)
assert [x["id"] for x in r.json()] == [rule_id]
r = await client.patch(
f"/api/v1/rules/{rule_id}", headers=auth_headers, json={"pattern": "Копилка%"}
)
assert r.status_code == 200 and r.json()["pattern"] == "Копилка%"
r = await client.patch(f"/api/v1/rules/{rule_id}", headers=auth_headers, json={"kind": None})
assert r.status_code == 400
r = await client.post(
"/api/v1/rules",
headers=auth_headers,
json={"kind": "nonsense", "match_type": "payee", "pattern": "x"},
)
assert r.status_code == 422
r = await client.delete(f"/api/v1/rules/{rule_id}", headers=auth_headers)
assert r.status_code == 204
r = await client.delete(f"/api/v1/rules/{rule_id}", headers=auth_headers)
assert r.status_code == 404
assert (await client.get("/api/v1/rules", headers=auth_headers)).json() == []
async def test_apply_runs_the_refresh_and_reports_stale_rules(client, auth_headers):
card = await make_account(name="Карта", balance="10000")
await make_txn(
today_local() - timedelta(days=2),
outcome="5000",
outcome_account_id=card,
payee="Копилка",
)
matching = (
await client.post(
"/api/v1/rules",
headers=auth_headers,
json={"kind": "savings", "match_type": "payee", "pattern": "копилка"},
)
).json()["id"]
rotten = (
await client.post(
"/api/v1/rules",
headers=auth_headers,
json={"kind": "one_off", "match_type": "payee", "pattern": "Ничего не совпадает"},
)
).json()["id"]
r = await client.post("/api/v1/rules/apply", headers=auth_headers)
assert r.status_code == 202
assert r.json()["error"] is None
assert r.json()["trigger"] == "rules"
by_id = {x["id"]: x for x in (await client.get("/api/v1/rules", headers=auth_headers)).json()}
assert by_id[matching]["match_count"] == 1
assert by_id[matching]["last_matched_at"] is not None
assert by_id[rotten]["match_count"] == 0
r = await client.get("/api/v1/rules/stale", headers=auth_headers)
assert [x["id"] for x in r.json()] == [rotten]
quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json()
stale = [row for row in quality if row["check_name"] == "stale_rule"]
assert len(stale) == 1 and stale[0]["ref"] == {"rule_id": rotten}
+110
View File
@@ -0,0 +1,110 @@
from datetime import timedelta
from factories import make_account, make_category, make_cbr_rate, make_txn, refresh
from fintracker.analytics import today_local
async def test_pagination_filters_and_string_money(client, auth_headers):
card = await make_account(name="Карта", balance="0")
other = await make_account(name="Вклад", balance="0")
food = await make_category("Еда")
t = today_local()
await make_cbr_rate(t - timedelta(days=10), "USD", "90")
for i in range(1, 6):
await make_txn(
t - timedelta(days=i),
outcome=f"{i}00.55",
outcome_account_id=card,
payee=f"Магазин {i}",
primary_category_id=food if i == 1 else None,
)
await make_txn(
t - timedelta(days=6),
income="1000",
income_account_id=other,
payee="Зарплата",
comment="аванс",
)
await make_txn(
t - timedelta(days=7), outcome="10", outcome_currency="USD", outcome_account_id=card
)
await make_txn(t - timedelta(days=8), outcome="1", outcome_account_id=card, deleted=True)
await refresh()
r = await client.get(
"/api/v1/transactions", headers=auth_headers, params={"page_size": 3, "page": 1}
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 7 # the deleted one is excluded
assert body["page"] == 1 and body["page_size"] == 3
assert len(body["items"]) == 3
dates = [i["date"] for i in body["items"]]
assert dates == sorted(dates, reverse=True)
first = body["items"][0]
assert isinstance(first["outcome"], str)
assert first["outcome"].startswith("100.55")
assert first["outcome_rub"].startswith("100.55")
assert first["flow_type"] == "expense"
page2 = await client.get(
"/api/v1/transactions", headers=auth_headers, params={"page_size": 3, "page": 2}
)
assert len(page2.json()["items"]) == 3
assert {i["id"] for i in page2.json()["items"]} & {i["id"] for i in body["items"]} == set()
# filters
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"account_id": other})
assert [i["payee"] for i in r.json()["items"]] == ["Зарплата"]
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"q": "аванс"})
assert r.json()["total"] == 1
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"q": "магазин"})
assert r.json()["total"] == 5 # ILIKE, case-insensitive
r = await client.get(
"/api/v1/transactions", headers=auth_headers, params={"flow_type": "income"}
)
assert r.json()["total"] == 1
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"category_id": food})
assert r.json()["total"] == 1
assert r.json()["items"][0]["tags"] == [food]
r = await client.get(
"/api/v1/transactions",
headers=auth_headers,
params={"from": str(t - timedelta(days=2)), "to": str(t)},
)
assert r.json()["total"] == 2
r = await client.get(
"/api/v1/transactions", headers=auth_headers, params={"include_deleted": True}
)
assert r.json()["total"] == 8
# the USD purchase converts at its own date's rate, and never silently at another
r = await client.get(
"/api/v1/transactions", headers=auth_headers, params={"q": "", "page_size": 100}
)
usd = next(i for i in r.json()["items"] if i["outcome_currency"] == "USD")
assert usd["outcome_rub"].startswith("900")
async def test_unquoted_currency_gives_null_rub(client, auth_headers):
card = await make_account(balance="0")
await make_txn(
today_local() - timedelta(days=1),
outcome="2",
outcome_currency="XBT",
outcome_account_id=card,
)
await refresh()
r = await client.get("/api/v1/transactions", headers=auth_headers)
(item,) = r.json()["items"]
assert item["outcome"] == "2.0000000000"
assert item["outcome_rub"] is None
+97
View File
@@ -0,0 +1,97 @@
"""Test harness: a throwaway Postgres (pytest-postgresql + pg_ctl on PATH), migrated with
Alembic once per session, tables truncated after every test."""
from __future__ import annotations
import os
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from pytest_postgresql import factories
from pytest_postgresql.janitor import DatabaseJanitor
from sqlalchemy import text
BACKEND_DIR = Path(__file__).resolve().parent.parent
postgresql_proc = factories.postgresql_proc(port=None, unixsocketdir="/tmp")
@pytest.fixture(scope="session")
def database_url(postgresql_proc) -> Iterator[str]:
p = postgresql_proc
with DatabaseJanitor(
user=p.user,
host=p.host,
port=p.port,
dbname="fintracker_test",
password=p.password,
):
pw = f":{p.password}" if p.password else ""
url = f"postgresql+asyncpg://{p.user}{pw}@{p.host}:{p.port}/fintracker_test"
os.environ["DATABASE_URL"] = url
os.environ["JWT_SECRET"] = "test-secret-not-for-production-0123456789"
from fintracker.config import get_settings
get_settings.cache_clear()
yield url
@pytest.fixture(scope="session")
def migrated(database_url: str) -> str:
from alembic import command
from alembic.config import Config
cfg = Config(str(BACKEND_DIR / "alembic.ini"))
cfg.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
command.upgrade(cfg, "head")
return database_url
@pytest.fixture
async def app(migrated: str):
from fintracker.api import app as app_module
from fintracker.api.routers import auth as auth_router
from fintracker.db import reset_engine
auth_router._login_limiter = None # fresh rate limiter per test
application = app_module.create_app()
yield application
await _truncate_all()
await reset_engine()
async def _truncate_all() -> None:
from fintracker.db import get_engine
from fintracker.db.base import Base
tables = ", ".join(f'"{t.name}"' for t in Base.metadata.sorted_tables)
async with get_engine().begin() as conn:
await conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
@pytest.fixture
async def client(app) -> AsyncIterator[AsyncClient]:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def user(app) -> dict[str, str]:
from fintracker.api.security import hash_password
from fintracker.db import get_sessionmaker
from fintracker.models import AppUser
creds = {"email": "ada@example.com", "password": "correct horse battery staple"}
async with get_sessionmaker()() as session:
session.add(AppUser(email=creds["email"], password_hash=hash_password(creds["password"])))
await session.commit()
return creds
@pytest.fixture
async def auth_headers(client: AsyncClient, user: dict[str, str]) -> dict[str, str]:
r = await client.post("/api/v1/auth/login", json=user)
assert r.status_code == 200, r.text
return {"Authorization": f"Bearer {r.json()['access_token']}"}
+291
View File
@@ -0,0 +1,291 @@
"""Row factories: insert core data directly, without going through a source sync.
Everything returns the new id. Amounts accept str/int/Decimal and are normalised to Decimal,
so tests can write `outcome="1234.56"` and still exercise the NUMERIC(24,10) path.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from itertools import count
from typing import Any
from fintracker.db import get_sessionmaker
from fintracker.models import (
Account,
AccountKind,
AccountRole,
AssetClass,
CashTxn,
CashTxnTag,
Category,
Event,
EventKind,
EventStatus,
Instrument,
PriceDaily,
RawCbrRate,
Rule,
RuleKind,
RuleMatchType,
Trip,
)
_seq = count(1)
def month_back(months: int) -> date:
"""First day of the month `months` before the current one (in the deployment tz)."""
from fintracker.analytics import today_local
t = today_local()
total = (t.year * 12 + t.month - 1) - months
return date(total // 12, total % 12 + 1, 1)
Amount = str | int | float | Decimal
def money(value: Amount | None) -> Decimal | None:
if value is None:
return None
return Decimal(str(value))
async def _add(obj: Any) -> Any:
async with get_sessionmaker()() as session:
session.add(obj)
await session.commit()
await session.refresh(obj)
return obj
async def make_account(
*,
name: str = "Карта",
currency: str = "RUB",
role: AccountRole = AccountRole.liquid,
kind: AccountKind = AccountKind.zm_card,
balance: Amount | None = 0,
balance_as_of: datetime | None = None,
include_in_net_worth: bool = True,
archived: bool = False,
mirror_of_account_id: int | None = None,
source: str = "zenmoney",
source_id: str | None = None,
) -> int:
acc = await _add(
Account(
kind=kind,
source=source,
source_id=source_id or f"acc-{next(_seq)}",
name=name,
currency=currency,
role=role,
balance=money(balance),
balance_as_of=balance_as_of or datetime.now(UTC),
include_in_net_worth=include_in_net_worth,
archived=archived,
mirror_of_account_id=mirror_of_account_id,
)
)
return acc.id
async def make_category(
name: str, *, parent_id: int | None = None, source_id: str | None = None
) -> int:
cat = await _add(
Category(
source="zenmoney",
source_id=source_id or f"cat-{next(_seq)}",
name=name,
parent_id=parent_id,
)
)
return cat.id
async def make_txn(
d: date,
*,
income: Amount = 0,
income_account_id: int | None = None,
income_currency: str | None = None,
outcome: Amount = 0,
outcome_account_id: int | None = None,
outcome_currency: str | None = None,
payee: str | None = None,
comment: str | None = None,
mcc: int | None = None,
hold: bool = False,
deleted: bool = False,
primary_category_id: int | None = None,
tag_ids: list[int] | None = None,
source_id: str | None = None,
) -> int:
income_d = money(income) or Decimal(0)
outcome_d = money(outcome) or Decimal(0)
txn = await _add(
CashTxn(
source="zenmoney",
source_id=source_id or f"txn-{next(_seq)}",
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
date=d,
income=income_d,
income_account_id=income_account_id,
income_currency=income_currency or ("RUB" if income_d else None),
outcome=outcome_d,
outcome_account_id=outcome_account_id,
outcome_currency=outcome_currency or ("RUB" if outcome_d else None),
payee=payee,
comment=comment,
mcc=mcc,
hold=hold,
deleted=deleted,
primary_category_id=primary_category_id,
)
)
tags = (
tag_ids if tag_ids is not None else ([primary_category_id] if primary_category_id else [])
)
if tags:
async with get_sessionmaker()() as session:
for ord_, category_id in enumerate(tags):
session.add(CashTxnTag(txn_id=txn.id, ord=ord_, category_id=category_id))
await session.commit()
return txn.id
async def make_cbr_rate(rate_date: date, ccy: str, value: Amount, *, nominal: int = 1) -> None:
async with get_sessionmaker()() as session:
session.add(
RawCbrRate(
rate_date=rate_date, ccy=ccy, nominal=nominal, value=money(value) or Decimal(0)
)
)
await session.commit()
async def make_rule(
*,
kind: RuleKind,
match_type: RuleMatchType,
pattern: str,
value: str | None = None,
enabled: bool = True,
priority: int = 100,
) -> int:
rule = await _add(
Rule(
kind=kind,
match_type=match_type,
pattern=pattern,
value=value,
enabled=enabled,
priority=priority,
)
)
return rule.id
async def make_trip(
name: str, date_from: date, date_to: date, *, country: str | None = None
) -> int:
trip = await _add(Trip(name=name, date_from=date_from, date_to=date_to, country=country))
return trip.id
async def refresh(trigger: str = "test") -> Any:
"""Run the whole metric refresh the way the worker does."""
from fintracker.metrics.refresh import refresh_all
async with get_sessionmaker()() as session:
entry = await refresh_all(session, trigger=trigger)
assert entry.error is None, entry.error
return entry
async def make_instrument(
*,
ticker: str = "GAZP",
name: str | None = None,
asset_class: AssetClass = AssetClass.share,
currency: str = "RUB",
board: str | None = "TQBR",
) -> int:
instrument = await _add(
Instrument(
asset_class=asset_class,
ticker=ticker,
board=board,
name=name or ticker,
currency=currency,
)
)
return instrument.id
async def make_event(
d: date,
*,
account_id: int,
kind: EventKind,
instrument_id: int | None = None,
quantity: Amount | None = None,
price: Amount | None = None,
amount: Amount = 0,
currency: str = "RUB",
fee: Amount | None = None,
accrued_interest: Amount | None = None,
status: EventStatus = EventStatus.confirmed,
meta: dict[str, Any] | None = None,
source_id: str | None = None,
) -> int:
key = source_id or f"ev-{next(_seq)}"
event = await _add(
Event(
account_id=account_id,
instrument_id=instrument_id,
kind=kind,
status=status,
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
trade_date=d,
quantity=money(quantity),
price=money(price),
price_currency=currency if price is not None else None,
amount=money(amount) or Decimal(0),
currency=currency,
fee=money(fee),
fee_currency=currency if fee is not None else None,
accrued_interest=money(accrued_interest),
source="tinvest",
source_id=key,
dedupe_key=f"tinvest:{key}",
meta=meta,
)
)
return event.id
async def make_price(
d: date,
*,
instrument_id: int,
close: Amount,
currency: str = "RUB",
accrued_interest: Amount | None = None,
) -> None:
async with get_sessionmaker()() as session:
session.add(
PriceDaily(
instrument_id=instrument_id,
d=d,
close=money(close) or Decimal(0),
currency=currency,
source="moex",
accrued_interest=money(accrued_interest),
)
)
await session.commit()
+43
View File
@@ -0,0 +1,43 @@
async def test_login_refresh_logout_cycle(client, user):
r = await client.post("/api/v1/auth/login", json=user)
assert r.status_code == 200, r.text
pair = r.json()
assert pair["token_type"] == "bearer"
me = await client.get(
"/api/v1/auth/me", headers={"Authorization": f"Bearer {pair['access_token']}"}
)
assert me.status_code == 200
assert me.json()["email"] == user["email"]
# rotation: the refresh token is single-use
r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
assert r2.status_code == 200
r3 = await client.post("/api/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
assert r3.status_code == 401
assert r3.headers["content-type"].startswith("application/problem+json")
new_refresh = r2.json()["refresh_token"]
assert (
await client.post("/api/v1/auth/logout", json={"refresh_token": new_refresh})
).status_code == 204
assert (
await client.post("/api/v1/auth/refresh", json={"refresh_token": new_refresh})
).status_code == 401
async def test_wrong_password_and_rate_limit(client, user):
bad = {"email": user["email"], "password": "nope"}
for _ in range(5):
r = await client.post("/api/v1/auth/login", json=bad)
assert r.status_code == 401
r = await client.post("/api/v1/auth/login", json=bad)
assert r.status_code == 429
assert "retry-after" in r.headers
async def test_protected_routes_need_token(client):
r = await client.get("/api/v1/auth/me")
assert r.status_code == 401
r = await client.get("/api/v1/auth/me", headers={"Authorization": "Bearer garbage"})
assert r.status_code == 401
+6
View File
@@ -0,0 +1,6 @@
async def test_health_reports_db(client):
r = await client.get("/api/v1/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["database"] == "ok"
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.fixture
async def web_client(migrated: str, tmp_path: Path, monkeypatch):
(tmp_path / "index.html").write_text("<!doctype html><title>spa</title>")
(tmp_path / "main.dart.js").write_text("console.log(1)")
monkeypatch.setenv("WEB_DIR", str(tmp_path))
from fintracker.api.app import create_app
from fintracker.config import get_settings
from fintracker.db import reset_engine
get_settings.cache_clear()
app = create_app()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
get_settings.cache_clear()
await reset_engine()
async def test_spa_fallback_and_api_precedence(web_client):
assert (await web_client.get("/")).text.startswith("<!doctype html>")
assert (await web_client.get("/login")).text.startswith("<!doctype html>") # deep link
assert (await web_client.get("/main.dart.js")).text == "console.log(1)"
assert (await web_client.get("/api/v1/health")).json()["status"] == "ok"
r = await web_client.get("/api/v1/nope")
assert r.status_code == 404 and r.headers["content-type"].startswith("application/problem+json")
assert (await web_client.get("/../etc/passwd")).text.startswith("<!doctype html>")
+2963
View File
File diff suppressed because it is too large Load Diff