diff --git a/backend/src/fintracker/api/__init__.py b/backend/src/fintracker/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/fintracker/api/app.py b/backend/src/fintracker/api/app.py new file mode 100644 index 0000000..84a81f0 --- /dev/null +++ b/backend/src/fintracker/api/app.py @@ -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] diff --git a/backend/src/fintracker/api/deps.py b/backend/src/fintracker/api/deps.py new file mode 100644 index 0000000..2568b0d --- /dev/null +++ b/backend/src/fintracker/api/deps.py @@ -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" diff --git a/backend/src/fintracker/api/errors.py b/backend/src/fintracker/api/errors.py new file mode 100644 index 0000000..97a24e4 --- /dev/null +++ b/backend/src/fintracker/api/errors.py @@ -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") diff --git a/backend/src/fintracker/api/ratelimit.py b/backend/src/fintracker/api/ratelimit.py new file mode 100644 index 0000000..0cd832e --- /dev/null +++ b/backend/src/fintracker/api/ratelimit.py @@ -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) diff --git a/backend/src/fintracker/api/routers/__init__.py b/backend/src/fintracker/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/fintracker/api/routers/accounts.py b/backend/src/fintracker/api/routers/accounts.py new file mode 100644 index 0000000..8a76935 --- /dev/null +++ b/backend/src/fintracker/api/routers/accounts.py @@ -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) diff --git a/backend/src/fintracker/api/routers/auth.py b/backend/src/fintracker/api/routers/auth.py new file mode 100644 index 0000000..e965bf9 --- /dev/null +++ b/backend/src/fintracker/api/routers/auth.py @@ -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) diff --git a/backend/src/fintracker/api/routers/cashflow.py b/backend/src/fintracker/api/routers/cashflow.py new file mode 100644 index 0000000..2c4889c --- /dev/null +++ b/backend/src/fintracker/api/routers/cashflow.py @@ -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) diff --git a/backend/src/fintracker/api/routers/categories.py b/backend/src/fintracker/api/routers/categories.py new file mode 100644 index 0000000..64c3e12 --- /dev/null +++ b/backend/src/fintracker/api/routers/categories.py @@ -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] diff --git a/backend/src/fintracker/api/routers/health.py b/backend/src/fintracker/api/routers/health.py new file mode 100644 index 0000000..5471bb3 --- /dev/null +++ b/backend/src/fintracker/api/routers/health.py @@ -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) diff --git a/backend/src/fintracker/api/routers/metrics.py b/backend/src/fintracker/api/routers/metrics.py new file mode 100644 index 0000000..76613ca --- /dev/null +++ b/backend/src/fintracker/api/routers/metrics.py @@ -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) diff --git a/backend/src/fintracker/api/routers/networth.py b/backend/src/fintracker/api/routers/networth.py new file mode 100644 index 0000000..b405e6e --- /dev/null +++ b/backend/src/fintracker/api/routers/networth.py @@ -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, + ) diff --git a/backend/src/fintracker/api/routers/rules.py b/backend/src/fintracker/api/routers/rules.py new file mode 100644 index 0000000..c0a01e0 --- /dev/null +++ b/backend/src/fintracker/api/routers/rules.py @@ -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) diff --git a/backend/src/fintracker/api/routers/sync.py b/backend/src/fintracker/api/routers/sync.py new file mode 100644 index 0000000..2e37ee2 --- /dev/null +++ b/backend/src/fintracker/api/routers/sync.py @@ -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) diff --git a/backend/src/fintracker/api/routers/transactions.py b/backend/src/fintracker/api/routers/transactions.py new file mode 100644 index 0000000..2700712 --- /dev/null +++ b/backend/src/fintracker/api/routers/transactions.py @@ -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) diff --git a/backend/src/fintracker/api/schemas/__init__.py b/backend/src/fintracker/api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/fintracker/api/schemas/accounts.py b/backend/src/fintracker/api/schemas/accounts.py new file mode 100644 index 0000000..64daf7b --- /dev/null +++ b/backend/src/fintracker/api/schemas/accounts.py @@ -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 diff --git a/backend/src/fintracker/api/schemas/auth.py b/backend/src/fintracker/api/schemas/auth.py new file mode 100644 index 0000000..80b82a6 --- /dev/null +++ b/backend/src/fintracker/api/schemas/auth.py @@ -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 diff --git a/backend/src/fintracker/api/schemas/cashflow.py b/backend/src/fintracker/api/schemas/cashflow.py new file mode 100644 index 0000000..44b9194 --- /dev/null +++ b/backend/src/fintracker/api/schemas/cashflow.py @@ -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.""" diff --git a/backend/src/fintracker/api/schemas/categories.py b/backend/src/fintracker/api/schemas/categories.py new file mode 100644 index 0000000..99d3c2c --- /dev/null +++ b/backend/src/fintracker/api/schemas/categories.py @@ -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 diff --git a/backend/src/fintracker/api/schemas/common.py b/backend/src/fintracker/api/schemas/common.py new file mode 100644 index 0000000..7a11727 --- /dev/null +++ b/backend/src/fintracker/api/schemas/common.py @@ -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, +] diff --git a/backend/src/fintracker/api/schemas/metrics.py b/backend/src/fintracker/api/schemas/metrics.py new file mode 100644 index 0000000..f6b9e3c --- /dev/null +++ b/backend/src/fintracker/api/schemas/metrics.py @@ -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 diff --git a/backend/src/fintracker/api/schemas/networth.py b/backend/src/fintracker/api/schemas/networth.py new file mode 100644 index 0000000..848b37f --- /dev/null +++ b/backend/src/fintracker/api/schemas/networth.py @@ -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] diff --git a/backend/src/fintracker/api/schemas/rules.py b/backend/src/fintracker/api/schemas/rules.py new file mode 100644 index 0000000..ac02a2f --- /dev/null +++ b/backend/src/fintracker/api/schemas/rules.py @@ -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 diff --git a/backend/src/fintracker/api/schemas/sync.py b/backend/src/fintracker/api/schemas/sync.py new file mode 100644 index 0000000..1bc2ba2 --- /dev/null +++ b/backend/src/fintracker/api/schemas/sync.py @@ -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 diff --git a/backend/src/fintracker/api/schemas/transactions.py b/backend/src/fintracker/api/schemas/transactions.py new file mode 100644 index 0000000..b6b202f --- /dev/null +++ b/backend/src/fintracker/api/schemas/transactions.py @@ -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 diff --git a/backend/src/fintracker/api/security.py b/backend/src/fintracker/api/security.py new file mode 100644 index 0000000..eb1f7b0 --- /dev/null +++ b/backend/src/fintracker/api/security.py @@ -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() diff --git a/backend/src/fintracker/api/web.py b/backend/src/fintracker/api/web.py new file mode 100644 index 0000000..9e1bed9 --- /dev/null +++ b/backend/src/fintracker/api/web.py @@ -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) diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/test_accounts_api.py b/backend/tests/api/test_accounts_api.py new file mode 100644 index 0000000..d82f99b --- /dev/null +++ b/backend/tests/api/test_accounts_api.py @@ -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 diff --git a/backend/tests/api/test_metrics_api.py b/backend/tests/api/test_metrics_api.py new file mode 100644 index 0000000..77a0c9e --- /dev/null +++ b/backend/tests/api/test_metrics_api.py @@ -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() == [] diff --git a/backend/tests/api/test_rules_api.py b/backend/tests/api/test_rules_api.py new file mode 100644 index 0000000..3e5454b --- /dev/null +++ b/backend/tests/api/test_rules_api.py @@ -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} diff --git a/backend/tests/api/test_transactions_api.py b/backend/tests/api/test_transactions_api.py new file mode 100644 index 0000000..999821f --- /dev/null +++ b/backend/tests/api/test_transactions_api.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..a0bf9b9 --- /dev/null +++ b/backend/tests/conftest.py @@ -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']}"} diff --git a/backend/tests/factories.py b/backend/tests/factories.py new file mode 100644 index 0000000..9e55835 --- /dev/null +++ b/backend/tests/factories.py @@ -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() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..ab02f77 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..1f7fa42 --- /dev/null +++ b/backend/tests/test_health.py @@ -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" diff --git a/backend/tests/test_web.py b/backend/tests/test_web.py new file mode 100644 index 0000000..24bf55d --- /dev/null +++ b/backend/tests/test_web.py @@ -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("spa") + (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("") + assert (await web_client.get("/login")).text.startswith("") # 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("") diff --git a/openapi/openapi.json b/openapi/openapi.json new file mode 100644 index 0000000..e18006d --- /dev/null +++ b/openapi/openapi.json @@ -0,0 +1,2963 @@ +{ + "components": { + "schemas": { + "AccountBalance": { + "properties": { + "account_id": { + "title": "Account Id", + "type": "integer" + }, + "balance": { + "description": "decimal as string", + "title": "Balance", + "type": "string" + }, + "balance_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Balance Rub" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/AccountRole" + } + }, + "required": [ + "account_id", + "name", + "currency", + "role", + "balance", + "balance_rub" + ], + "title": "AccountBalance", + "type": "object" + }, + "AccountKind": { + "enum": [ + "zm_cash", + "zm_card", + "zm_checking", + "zm_deposit", + "zm_loan", + "zm_emoney", + "zm_debt", + "broker", + "manual_asset" + ], + "title": "AccountKind", + "type": "string" + }, + "AccountOut": { + "properties": { + "archived": { + "title": "Archived", + "type": "boolean" + }, + "balance": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Balance" + }, + "balance_as_of": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Balance As Of" + }, + "broker": { + "anyOf": [ + { + "$ref": "#/components/schemas/Broker" + }, + { + "type": "null" + } + ] + }, + "credit_limit": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Credit Limit" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "include_in_net_worth": { + "title": "Include In Net Worth", + "type": "boolean" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind" + }, + "mirror_of_account_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mirror Of Account Id" + }, + "name": { + "title": "Name", + "type": "string" + }, + "opened_at": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Opened At" + }, + "primary_event_source": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventSource" + }, + { + "type": "null" + } + ] + }, + "role": { + "$ref": "#/components/schemas/AccountRole" + }, + "source": { + "title": "Source", + "type": "string" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "start_balance": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Balance" + } + }, + "required": [ + "id", + "kind", + "source", + "source_id", + "broker", + "name", + "currency", + "role", + "include_in_net_worth", + "mirror_of_account_id", + "primary_event_source", + "archived", + "opened_at", + "balance", + "balance_as_of", + "start_balance", + "credit_limit" + ], + "title": "AccountOut", + "type": "object" + }, + "AccountPatch": { + "additionalProperties": false, + "description": "Only the fields the user owns: everything else comes from the source on each sync.\n\nUnset fields are left alone; an explicit `null` clears the column.", + "properties": { + "include_in_net_worth": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Include In Net Worth" + }, + "mirror_of_account_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mirror Of Account Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "primary_event_source": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventSource" + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "$ref": "#/components/schemas/AccountRole" + }, + { + "type": "null" + } + ] + } + }, + "title": "AccountPatch", + "type": "object" + }, + "AccountRole": { + "enum": [ + "liquid", + "savings", + "investment", + "debt" + ], + "title": "AccountRole", + "type": "string" + }, + "Broker": { + "enum": [ + "tinvest", + "sber", + "vtb", + "other" + ], + "title": "Broker", + "type": "string" + }, + "CashFlowMonth": { + "properties": { + "baseline_rub": { + "description": "decimal as string", + "title": "Baseline Rub", + "type": "string" + }, + "expense_rub": { + "description": "decimal as string", + "title": "Expense Rub", + "type": "string" + }, + "income_rub": { + "description": "decimal as string", + "title": "Income Rub", + "type": "string" + }, + "month": { + "format": "date", + "title": "Month", + "type": "string" + }, + "one_off_rub": { + "description": "decimal as string", + "title": "One Off Rub", + "type": "string" + }, + "savings_rate": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Savings Rate" + }, + "savings_transfer_rub": { + "description": "decimal as string", + "title": "Savings Transfer Rub", + "type": "string" + }, + "txn_count": { + "title": "Txn Count", + "type": "integer" + } + }, + "required": [ + "month", + "income_rub", + "expense_rub", + "baseline_rub", + "one_off_rub", + "savings_transfer_rub", + "savings_rate", + "txn_count" + ], + "title": "CashFlowMonth", + "type": "object" + }, + "CategoryOut": { + "description": "Flat list; the client builds the tree from `parent_id` (ZenMoney nests one level).", + "properties": { + "archived": { + "title": "Archived", + "type": "boolean" + }, + "color": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Icon" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "show_income": { + "title": "Show Income", + "type": "boolean" + }, + "show_outcome": { + "title": "Show Outcome", + "type": "boolean" + } + }, + "required": [ + "id", + "parent_id", + "name", + "icon", + "color", + "show_income", + "show_outcome", + "archived" + ], + "title": "CategoryOut", + "type": "object" + }, + "DataQualityRow": { + "properties": { + "check_name": { + "title": "Check Name", + "type": "string" + }, + "computed_at": { + "format": "date-time", + "title": "Computed At", + "type": "string" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "detail": { + "title": "Detail", + "type": "string" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "ref": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Ref" + }, + "severity": { + "title": "Severity", + "type": "string" + } + }, + "required": [ + "id", + "check_name", + "severity", + "detail", + "count", + "ref", + "computed_at" + ], + "title": "DataQualityRow", + "type": "object" + }, + "EventSource": { + "description": "Which feed is the truth for an account's ledger; others become `shadow` events.", + "enum": [ + "tinvest_api", + "report_sber", + "report_vtb", + "manual" + ], + "title": "EventSource", + "type": "string" + }, + "FlowType": { + "enum": [ + "income", + "expense", + "internal_transfer", + "savings_transfer", + "broker_external_flow", + "deleted", + "other" + ], + "title": "FlowType", + "type": "string" + }, + "Health": { + "properties": { + "database": { + "title": "Database", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "status", + "version", + "database" + ], + "title": "Health", + "type": "object" + }, + "JobStatus": { + "enum": [ + "queued", + "running", + "done", + "error" + ], + "title": "JobStatus", + "type": "string" + }, + "LoginRequest": { + "properties": { + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 1, + "title": "Password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "title": "LoginRequest", + "type": "object" + }, + "NetWorthBreakdown": { + "properties": { + "accounts": { + "items": { + "$ref": "#/components/schemas/AccountBalance" + }, + "title": "Accounts", + "type": "array" + }, + "by_currency": { + "additionalProperties": { + "type": "string" + }, + "title": "By Currency", + "type": "object" + }, + "d": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "D" + }, + "debt_rub": { + "description": "decimal as string", + "title": "Debt Rub", + "type": "string" + }, + "investment_rub": { + "description": "decimal as string", + "title": "Investment Rub", + "type": "string" + }, + "liquid_rub": { + "description": "decimal as string", + "title": "Liquid Rub", + "type": "string" + }, + "missing_fx_count": { + "title": "Missing Fx Count", + "type": "integer" + }, + "savings_rub": { + "description": "decimal as string", + "title": "Savings Rub", + "type": "string" + }, + "total_rub": { + "description": "decimal as string", + "title": "Total Rub", + "type": "string" + } + }, + "required": [ + "d", + "total_rub", + "liquid_rub", + "savings_rub", + "investment_rub", + "debt_rub", + "by_currency", + "missing_fx_count", + "accounts" + ], + "title": "NetWorthBreakdown", + "type": "object" + }, + "NetWorthDay": { + "properties": { + "by_currency": { + "additionalProperties": { + "type": "string" + }, + "title": "By Currency", + "type": "object" + }, + "d": { + "format": "date", + "title": "D", + "type": "string" + }, + "debt_rub": { + "description": "decimal as string", + "title": "Debt Rub", + "type": "string" + }, + "investment_rub": { + "description": "decimal as string", + "title": "Investment Rub", + "type": "string" + }, + "liquid_rub": { + "description": "decimal as string", + "title": "Liquid Rub", + "type": "string" + }, + "missing_fx_count": { + "title": "Missing Fx Count", + "type": "integer" + }, + "savings_rub": { + "description": "decimal as string", + "title": "Savings Rub", + "type": "string" + }, + "total_rub": { + "description": "decimal as string", + "title": "Total Rub", + "type": "string" + } + }, + "required": [ + "d", + "total_rub", + "liquid_rub", + "savings_rub", + "investment_rub", + "debt_rub", + "by_currency", + "missing_fx_count" + ], + "title": "NetWorthDay", + "type": "object" + }, + "Problem": { + "description": "RFC 7807 error body (application/problem+json)", + "properties": { + "detail": { + "type": "string" + }, + "errors": { + "items": {}, + "type": "array" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "status", + "title" + ], + "title": "Problem", + "type": "object" + }, + "RefreshLogOut": { + "properties": { + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "finished_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + }, + "trigger": { + "title": "Trigger", + "type": "string" + } + }, + "required": [ + "id", + "started_at", + "finished_at", + "trigger", + "error" + ], + "title": "RefreshLogOut", + "type": "object" + }, + "RefreshRequest": { + "properties": { + "refresh_token": { + "minLength": 1, + "title": "Refresh Token", + "type": "string" + } + }, + "required": [ + "refresh_token" + ], + "title": "RefreshRequest", + "type": "object" + }, + "RuleCreate": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + }, + "kind": { + "$ref": "#/components/schemas/RuleKind" + }, + "match_type": { + "$ref": "#/components/schemas/RuleMatchType" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "pattern": { + "maxLength": 512, + "minLength": 1, + "title": "Pattern", + "type": "string" + }, + "priority": { + "default": 100, + "title": "Priority", + "type": "integer" + }, + "value": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "kind", + "match_type", + "pattern" + ], + "title": "RuleCreate", + "type": "object" + }, + "RuleKind": { + "enum": [ + "savings", + "one_off", + "category", + "payee", + "broker_target", + "ignore" + ], + "title": "RuleKind", + "type": "string" + }, + "RuleMatchType": { + "enum": [ + "id", + "payee", + "comment", + "category", + "mcc", + "account" + ], + "title": "RuleMatchType", + "type": "string" + }, + "RuleOut": { + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "kind": { + "$ref": "#/components/schemas/RuleKind" + }, + "last_matched_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Matched At" + }, + "match_count": { + "title": "Match Count", + "type": "integer" + }, + "match_type": { + "$ref": "#/components/schemas/RuleMatchType" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "pattern": { + "title": "Pattern", + "type": "string" + }, + "priority": { + "title": "Priority", + "type": "integer" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "required": [ + "id", + "kind", + "match_type", + "pattern", + "value", + "note", + "enabled", + "priority", + "last_matched_at", + "match_count" + ], + "title": "RuleOut", + "type": "object" + }, + "RulePatch": { + "additionalProperties": false, + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuleKind" + }, + { + "type": "null" + } + ] + }, + "match_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuleMatchType" + }, + { + "type": "null" + } + ] + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "pattern": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pattern" + }, + "priority": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Priority" + }, + "value": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "title": "RulePatch", + "type": "object" + }, + "RunStatus": { + "enum": [ + "running", + "ok", + "error" + ], + "title": "RunStatus", + "type": "string" + }, + "RunwayOut": { + "properties": { + "as_of": { + "format": "date", + "title": "As Of", + "type": "string" + }, + "avg_baseline_3m_rub": { + "description": "decimal as string", + "title": "Avg Baseline 3M Rub", + "type": "string" + }, + "liquid_reserve_rub": { + "description": "decimal as string", + "title": "Liquid Reserve Rub", + "type": "string" + }, + "runway_months": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Runway Months" + } + }, + "required": [ + "as_of", + "liquid_reserve_rub", + "avg_baseline_3m_rub", + "runway_months" + ], + "title": "RunwayOut", + "type": "object" + }, + "SourceStatus": { + "properties": { + "cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + }, + "last_run_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "last_run_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/RunStatus" + }, + { + "type": "null" + } + ] + }, + "last_success_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "queued": { + "title": "Queued", + "type": "boolean" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "source", + "cursor", + "last_run_at", + "last_success_at", + "last_run_status", + "queued" + ], + "title": "SourceStatus", + "type": "object" + }, + "SpendingRow": { + "properties": { + "amount_rub": { + "description": "decimal as string", + "title": "Amount Rub", + "type": "string" + }, + "category_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "category_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Name" + }, + "month": { + "format": "date", + "title": "Month", + "type": "string" + }, + "root_category_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Root Category Id" + }, + "root_category_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Root Category Name" + }, + "txn_count": { + "title": "Txn Count", + "type": "integer" + } + }, + "required": [ + "month", + "category_id", + "category_name", + "root_category_id", + "root_category_name", + "amount_rub", + "txn_count" + ], + "title": "SpendingRow", + "type": "object" + }, + "SyncJobOut": { + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "requested_at": { + "format": "date-time", + "title": "Requested At", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/JobStatus" + } + }, + "required": [ + "id", + "source", + "status", + "requested_at" + ], + "title": "SyncJobOut", + "type": "object" + }, + "SyncRunOut": { + "properties": { + "counts": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Counts" + }, + "cursor_after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor After" + }, + "cursor_before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor Before" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "finished_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "source": { + "title": "Source", + "type": "string" + }, + "started_at": { + "format": "date-time", + "title": "Started At", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/RunStatus" + }, + "triggered_by": { + "title": "Triggered By", + "type": "string" + }, + "warnings": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Warnings" + } + }, + "required": [ + "id", + "source", + "status", + "triggered_by", + "started_at", + "finished_at", + "cursor_before", + "cursor_after", + "counts", + "warnings", + "error" + ], + "title": "SyncRunOut", + "type": "object" + }, + "TokenPair": { + "properties": { + "access_token": { + "title": "Access Token", + "type": "string" + }, + "expires_in": { + "title": "Expires In", + "type": "integer" + }, + "refresh_token": { + "title": "Refresh Token", + "type": "string" + }, + "token_type": { + "default": "bearer", + "title": "Token Type", + "type": "string" + } + }, + "required": [ + "access_token", + "refresh_token", + "expires_in" + ], + "title": "TokenPair", + "type": "object" + }, + "TransactionOut": { + "properties": { + "category_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "comment": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + }, + "date": { + "format": "date", + "title": "Date", + "type": "string" + }, + "deleted": { + "title": "Deleted", + "type": "boolean" + }, + "flow_type": { + "$ref": "#/components/schemas/FlowType" + }, + "hold": { + "title": "Hold", + "type": "boolean" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "income": { + "description": "decimal as string", + "title": "Income", + "type": "string" + }, + "income_account_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Income Account Id" + }, + "income_currency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Income Currency" + }, + "income_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Income Rub" + }, + "is_one_off": { + "title": "Is One Off", + "type": "boolean" + }, + "mcc": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mcc" + }, + "outcome": { + "description": "decimal as string", + "title": "Outcome", + "type": "string" + }, + "outcome_account_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Outcome Account Id" + }, + "outcome_currency": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Outcome Currency" + }, + "outcome_rub": { + "anyOf": [ + { + "description": "decimal as string", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Outcome Rub" + }, + "payee": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Payee" + }, + "payee_canonical": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Payee Canonical" + }, + "source_id": { + "title": "Source Id", + "type": "string" + }, + "tags": { + "items": { + "type": "integer" + }, + "title": "Tags", + "type": "array" + }, + "trip_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Trip Id" + }, + "ts": { + "format": "date-time", + "title": "Ts", + "type": "string" + } + }, + "required": [ + "id", + "source_id", + "ts", + "date", + "income", + "income_currency", + "income_account_id", + "income_rub", + "outcome", + "outcome_currency", + "outcome_account_id", + "outcome_rub", + "payee", + "payee_canonical", + "comment", + "mcc", + "hold", + "deleted", + "flow_type", + "category_id", + "is_one_off", + "trip_id", + "tags" + ], + "title": "TransactionOut", + "type": "object" + }, + "TransactionPage": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TransactionOut" + }, + "title": "Items", + "type": "array" + }, + "page": { + "title": "Page", + "type": "integer" + }, + "page_size": { + "title": "Page Size", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "TransactionPage", + "type": "object" + }, + "UserOut": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "id": { + "title": "Id", + "type": "integer" + } + }, + "required": [ + "id", + "email" + ], + "title": "UserOut", + "type": "object" + } + }, + "securitySchemes": { + "HTTPBearer": { + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "title": "fin-tracker", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/accounts": { + "get": { + "description": "Every account, archived ones included — the client decides what to show.", + "operationId": "accounts_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AccountOut" + }, + "title": "Response Accounts List", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "accounts" + ] + } + }, + "/api/v1/accounts/{account_id}": { + "patch": { + "description": "Update the user-owned fields. Everything else is overwritten by the next sync.", + "operationId": "accounts_patch", + "parameters": [ + { + "in": "path", + "name": "account_id", + "required": true, + "schema": { + "title": "Account Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountPatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Patch", + "tags": [ + "accounts" + ] + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "auth_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "summary": "Login", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "auth_logout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "summary": "Logout", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/me": { + "get": { + "operationId": "auth_me", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Me", + "tags": [ + "auth" + ] + } + }, + "/api/v1/auth/refresh": { + "post": { + "operationId": "auth_refresh", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenPair" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "summary": "Refresh", + "tags": [ + "auth" + ] + } + }, + "/api/v1/cashflow/monthly": { + "get": { + "description": "The last `months` months, oldest first.", + "operationId": "cashflow_monthly", + "parameters": [ + { + "in": "query", + "name": "months", + "required": false, + "schema": { + "default": 12, + "maximum": 120, + "minimum": 1, + "title": "Months", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CashFlowMonth" + }, + "title": "Response Cashflow Monthly", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Monthly", + "tags": [ + "cashflow" + ] + } + }, + "/api/v1/categories": { + "get": { + "description": "Flat list of the ZenMoney tag tree; the client nests it by `parent_id`.", + "operationId": "categories_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CategoryOut" + }, + "title": "Response Categories List", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "categories" + ] + } + }, + "/api/v1/data-quality": { + "get": { + "description": "Findings of the latest refresh, most serious first.", + "operationId": "metrics_data_quality", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DataQualityRow" + }, + "title": "Response Metrics Data Quality", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Data Quality", + "tags": [ + "metrics" + ] + } + }, + "/api/v1/health": { + "get": { + "operationId": "health_check", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "summary": "Check", + "tags": [ + "health" + ] + } + }, + "/api/v1/metrics/refresh": { + "post": { + "description": "Rebuild every metric_* table inline (seconds at personal volumes).", + "operationId": "metrics_refresh", + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshLogOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Refresh", + "tags": [ + "metrics" + ] + } + }, + "/api/v1/metrics/status": { + "get": { + "description": "When the metric tables were last rebuilt, and whether it failed.", + "operationId": "metrics_status", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RefreshLogOut" + }, + { + "type": "null" + } + ], + "title": "Response Metrics Status" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Status", + "tags": [ + "metrics" + ] + } + }, + "/api/v1/networth/breakdown": { + "get": { + "description": "The latest day's buckets, plus every account's current balance native and in RUB.", + "operationId": "networth_breakdown", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetWorthBreakdown" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Breakdown", + "tags": [ + "networth" + ] + } + }, + "/api/v1/networth/series": { + "get": { + "description": "Daily net worth; defaults to the last 365 days.", + "operationId": "networth_series", + "parameters": [ + { + "in": "query", + "name": "from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "in": "query", + "name": "to", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "To" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NetWorthDay" + }, + "title": "Response Networth Series", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Series", + "tags": [ + "networth" + ] + } + }, + "/api/v1/rules": { + "get": { + "operationId": "rules_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RuleOut" + }, + "title": "Response Rules List", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "rules" + ] + }, + "post": { + "operationId": "rules_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create", + "tags": [ + "rules" + ] + } + }, + "/api/v1/rules/apply": { + "post": { + "description": "Re-run the whole metric refresh so edited rules take effect everywhere at once.", + "operationId": "rules_apply", + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshLogOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Apply", + "tags": [ + "rules" + ] + } + }, + "/api/v1/rules/stale": { + "get": { + "description": "Enabled rules that matched nothing in the latest refresh: the payee was renamed,\nor the transaction was re-categorised in ZenMoney.", + "operationId": "rules_stale", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RuleOut" + }, + "title": "Response Rules Stale", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Stale", + "tags": [ + "rules" + ] + } + }, + "/api/v1/rules/{rule_id}": { + "delete": { + "operationId": "rules_delete", + "parameters": [ + { + "in": "path", + "name": "rule_id", + "required": true, + "schema": { + "title": "Rule Id", + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete", + "tags": [ + "rules" + ] + }, + "patch": { + "operationId": "rules_patch", + "parameters": [ + { + "in": "path", + "name": "rule_id", + "required": true, + "schema": { + "title": "Rule Id", + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RulePatch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Patch", + "tags": [ + "rules" + ] + } + }, + "/api/v1/runway": { + "get": { + "description": "How many months the liquid reserve covers; null before the first refresh.", + "operationId": "cashflow_runway", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/RunwayOut" + }, + { + "type": "null" + } + ], + "title": "Response Cashflow Runway" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Runway", + "tags": [ + "cashflow" + ] + } + }, + "/api/v1/spending/categories": { + "get": { + "description": "Expenses of one month by category, largest first.", + "operationId": "cashflow_spending", + "parameters": [ + { + "description": "YYYY-MM; defaults to the latest month", + "in": "query", + "name": "month", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "YYYY-MM; defaults to the latest month", + "title": "Month" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SpendingRow" + }, + "title": "Response Cashflow Spending", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Spending", + "tags": [ + "cashflow" + ] + } + }, + "/api/v1/sync/runs": { + "get": { + "operationId": "sync_runs", + "parameters": [ + { + "in": "query", + "name": "source", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 200, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SyncRunOut" + }, + "title": "Response Sync Runs", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Runs", + "tags": [ + "sync" + ] + } + }, + "/api/v1/sync/status": { + "get": { + "operationId": "sync_status", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SourceStatus" + }, + "title": "Response Sync Status", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Status", + "tags": [ + "sync" + ] + } + }, + "/api/v1/sync/{source}": { + "post": { + "operationId": "sync_trigger", + "parameters": [ + { + "in": "path", + "name": "source", + "required": true, + "schema": { + "title": "Source", + "type": "string" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncJobOut" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Trigger", + "tags": [ + "sync" + ] + } + }, + "/api/v1/transactions": { + "get": { + "description": "One page of transactions, newest first, with RUB amounts at each own date's rate.", + "operationId": "transactions_list", + "parameters": [ + { + "in": "query", + "name": "from", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "in": "query", + "name": "to", + "required": false, + "schema": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "in": "query", + "name": "account_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + }, + { + "in": "query", + "name": "category_id", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Category Id" + } + }, + { + "in": "query", + "name": "flow_type", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/FlowType" + }, + { + "type": "null" + } + ], + "title": "Flow Type" + } + }, + { + "description": "substring of payee or comment", + "in": "query", + "name": "q", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "substring of payee or comment", + "title": "Q" + } + }, + { + "in": "query", + "name": "include_deleted", + "required": false, + "schema": { + "default": false, + "title": "Include Deleted", + "type": "boolean" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "required": false, + "schema": { + "default": 50, + "maximum": 500, + "minimum": 1, + "title": "Page Size", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionPage" + } + } + }, + "description": "Successful Response" + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List", + "tags": [ + "transactions" + ] + } + } + }, + "servers": [ + { + "url": "/" + } + ] +}