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