diff --git a/backend/src/fintracker/api/app.py b/backend/src/fintracker/api/app.py index 168c72f..c13a864 100644 --- a/backend/src/fintracker/api/app.py +++ b/backend/src/fintracker/api/app.py @@ -23,6 +23,7 @@ from fintracker.api.routers import ( events, health, instruments, + links, metrics, networth, rules, @@ -86,6 +87,7 @@ def create_app() -> FastAPI: app.include_router(analytics.router, prefix=API_PREFIX) app.include_router(events.router, prefix=API_PREFIX) app.include_router(instruments.router, prefix=API_PREFIX) + app.include_router(links.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) diff --git a/backend/src/fintracker/api/routers/links.py b/backend/src/fintracker/api/routers/links.py new file mode 100644 index 0000000..ae33601 --- /dev/null +++ b/backend/src/fintracker/api/routers/links.py @@ -0,0 +1,151 @@ +"""Manual review and confirmation of ZenMoney <-> broker cash-flow links (plan §1.6 C, §4). + +`ledger/matching.py` runs automatically after every refresh and links what it can; this +router is where the leftovers get resolved by hand. `GET /links/unmatched` recomputes the +candidate pools live rather than reading the last refresh's `FINDINGS` (a snapshot that goes +stale the moment a new sync runs), and shows only what has no `flow_link` yet, of either kind. +""" + +from __future__ import annotations + +from decimal import Decimal + +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.links import ( + LinkCreate, + LinkOut, + UnmatchedBrokerOut, + UnmatchedOut, + UnmatchedZmOut, +) +from fintracker.ledger.matching import ( + _load_broker_candidates, + _load_zm_candidates, + business_days_between, +) +from fintracker.models import CashTxn, Event, FlowLink, FlowLinkKind, FlowType + +router = APIRouter(prefix="/links", tags=["links"]) + +ZERO = Decimal(0) + + +@router.get("/unmatched", name="unmatched") +async def unmatched(session: SessionDep, _: CurrentUser) -> UnmatchedOut: + """Every candidate that survives matching without a link, on either side.""" + linked_txns = set((await session.execute(select(FlowLink.cash_txn_id))).scalars().all()) + linked_events = set((await session.execute(select(FlowLink.event_id))).scalars().all()) + + zm = [c for c in await _load_zm_candidates(session) if c.cash_txn_id not in linked_txns] + broker = [c for c in await _load_broker_candidates(session) if c.event_id not in linked_events] + + txns: dict[int, CashTxn] = {} + if zm: + rows = ( + await session.execute(select(CashTxn).where(CashTxn.id.in_(c.cash_txn_id for c in zm))) + ).scalars() + txns = {t.id: t for t in rows} + + return UnmatchedOut( + zm=[ + UnmatchedZmOut( + cash_txn_id=c.cash_txn_id, + date=c.date, + amount=c.amount, + currency=c.currency, + direction=c.direction.value, + broker_account_id=c.broker_account_id, + route=c.route, + payee=txns[c.cash_txn_id].payee if c.cash_txn_id in txns else None, + comment=txns[c.cash_txn_id].comment if c.cash_txn_id in txns else None, + ) + for c in zm + ], + broker=[ + UnmatchedBrokerOut( + event_id=c.event_id, + account_id=c.account_id, + date=c.date, + amount=c.amount, + currency=c.currency, + direction=c.direction.value, + ) + for c in broker + ], + ) + + +@router.post("", name="create", status_code=status.HTTP_201_CREATED) +async def create_link(body: LinkCreate, session: SessionDep, _: CurrentUser) -> LinkOut: + """Confirm a pairing the matcher missed. Never touched by the automatic rebuild afterwards.""" + txn = await session.get(CashTxn, body.cash_txn_id) + if txn is None: + raise Problem(404, "Not Found", f"Нет транзакции ZenMoney #{body.cash_txn_id}") + event = await session.get(Event, body.event_id) + if event is None: + raise Problem(404, "Not Found", f"Нет события #{body.event_id}") + + if ( + await session.execute(select(FlowLink).where(FlowLink.cash_txn_id == body.cash_txn_id)) + ).scalar_one_or_none() is not None: + raise Problem(409, "Conflict", f"Транзакция #{body.cash_txn_id} уже связана") + if ( + await session.execute(select(FlowLink).where(FlowLink.event_id == body.event_id)) + ).scalar_one_or_none() is not None: + raise Problem(409, "Conflict", f"Событие #{body.event_id} уже связано") + + zm_amount = txn.income if txn.income > ZERO else txn.outcome + zm_currency = (txn.income_currency if txn.income > ZERO else txn.outcome_currency) or "RUB" + event_amount = abs(event.amount or ZERO) + event_currency = (event.currency or "RUB").upper() + if zm_currency.upper() != event_currency: + raise Problem( + 400, "Bad Request", f"Валюты не совпадают: {zm_currency.upper()} vs {event_currency}" + ) + + link = FlowLink( + cash_txn_id=body.cash_txn_id, + event_id=body.event_id, + kind=FlowLinkKind.manual, + confidence=Decimal(1), + amount_delta=abs(zm_amount - event_amount), + currency=zm_currency.upper(), + day_gap=business_days_between(txn.date, event.trade_date), + note="manual", + ) + session.add(link) + # the point of the link: a linked transfer is not spending, same overwrite + # `ledger/matching.py` does for the automatic ones + txn.flow_type = FlowType.internal_transfer + await session.commit() + await session.refresh(link) + return _link_out(link) + + +@router.delete("/{link_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT) +async def delete_link(link_id: int, session: SessionDep, _: CurrentUser) -> None: + """Undo a link, manual or automatic. `flow_type` reverts on the next `POST /metrics/refresh`, + when `classify` rebuilds it from the rules and `matching` no longer overwrites it.""" + link = await session.get(FlowLink, link_id) + if link is None: + raise Problem(404, "Not Found", f"Нет связи #{link_id}") + await session.delete(link) + await session.commit() + + +def _link_out(link: FlowLink) -> LinkOut: + return LinkOut( + id=link.id, + cash_txn_id=link.cash_txn_id, + event_id=link.event_id, + kind=link.kind.value, + confidence=link.confidence, + amount_delta=link.amount_delta, + currency=link.currency, + day_gap=link.day_gap, + note=link.note, + ) diff --git a/backend/src/fintracker/api/schemas/links.py b/backend/src/fintracker/api/schemas/links.py new file mode 100644 index 0000000..367ea91 --- /dev/null +++ b/backend/src/fintracker/api/schemas/links.py @@ -0,0 +1,55 @@ +"""Schemas for manual review of ZenMoney <-> broker cash-flow links (plan §1.6 C, §4).""" + +from __future__ import annotations + +from datetime import date + +from pydantic import BaseModel + +from fintracker.api.schemas.common import Money + + +class UnmatchedZmOut(BaseModel): + cash_txn_id: int + date: date + amount: Money + """Always positive: the magnitude that crossed the boundary.""" + currency: str + direction: str + """to_broker | from_broker.""" + broker_account_id: int + route: str + """account_link | mirror | rule — how the broker account was identified.""" + payee: str | None + comment: str | None + + +class UnmatchedBrokerOut(BaseModel): + event_id: int + account_id: int + date: date + amount: Money + currency: str + direction: str + + +class UnmatchedOut(BaseModel): + zm: list[UnmatchedZmOut] + broker: list[UnmatchedBrokerOut] + + +class LinkCreate(BaseModel): + cash_txn_id: int + event_id: int + + +class LinkOut(BaseModel): + id: int + cash_txn_id: int + event_id: int + kind: str + confidence: Money + amount_delta: Money + currency: str + day_gap: int + note: str | None diff --git a/backend/tests/api/test_links_api.py b/backend/tests/api/test_links_api.py new file mode 100644 index 0000000..4406cd1 --- /dev/null +++ b/backend/tests/api/test_links_api.py @@ -0,0 +1,104 @@ +"""Manual review of ZenMoney <-> broker cash-flow links, over the API.""" + +from datetime import timedelta + +import pytest +from httpx import AsyncClient + +from factories import make_account, make_event, make_txn +from fintracker.analytics import today_local +from fintracker.models import AccountKind, AccountRole, EventKind + + +@pytest.fixture +async def leftover(app) -> dict[str, int]: + """A transfer and a deposit too far apart in amount to auto-match: both stay unmatched.""" + card = await make_account(name="Карта") + broker_account = await make_account( + name="ИИС", kind=AccountKind.broker, role=AccountRole.investment, source="tinvest" + ) + mirror = await make_account( + name="ИИС (зеркало)", include_in_net_worth=False, mirror_of_account_id=broker_account + ) + d = today_local() - timedelta(days=10) + + txn_id = await make_txn( + d, income="10000", income_account_id=mirror, outcome="10000", outcome_account_id=card + ) + event_id = await make_event(d, account_id=broker_account, kind=EventKind.deposit, amount="9500") + return {"txn": txn_id, "event": event_id, "broker_account": broker_account} + + +async def test_unmatched_lists_both_sides_of_a_leftover( + client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int] +): + r = await client.get("/api/v1/links/unmatched", headers=auth_headers) + assert r.status_code == 200, r.text + body = r.json() + assert [c["cash_txn_id"] for c in body["zm"]] == [leftover["txn"]] + assert [c["event_id"] for c in body["broker"]] == [leftover["event"]] + + +async def test_manual_link_reclassifies_the_transfer_and_leaves_unmatched( + client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int] +): + r = await client.post( + "/api/v1/links", + headers=auth_headers, + json={"cash_txn_id": leftover["txn"], "event_id": leftover["event"]}, + ) + assert r.status_code == 201, r.text + link = r.json() + assert link["kind"] == "manual" + assert link["amount_delta"] == "500.0000000000" + + r = await client.get("/api/v1/links/unmatched", headers=auth_headers) + assert r.json() == {"zm": [], "broker": []} + + r = await client.get("/api/v1/transactions", headers=auth_headers) + txn = next(t for t in r.json()["items"] if t["id"] == leftover["txn"]) + assert txn["flow_type"] == "internal_transfer" + + +async def test_a_linked_side_cannot_be_linked_again( + client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int] +): + body = {"cash_txn_id": leftover["txn"], "event_id": leftover["event"]} + r = await client.post("/api/v1/links", headers=auth_headers, json=body) + assert r.status_code == 201, r.text + + other_event = await make_event( + today_local(), account_id=leftover["broker_account"], kind=EventKind.deposit, amount="1" + ) + r = await client.post( + "/api/v1/links", + headers=auth_headers, + json={"cash_txn_id": leftover["txn"], "event_id": other_event}, + ) + assert r.status_code == 409 + + +async def test_delete_link_brings_the_pair_back_to_unmatched( + client: AsyncClient, auth_headers: dict[str, str], leftover: dict[str, int] +): + body = {"cash_txn_id": leftover["txn"], "event_id": leftover["event"]} + r = await client.post("/api/v1/links", headers=auth_headers, json=body) + link_id = r.json()["id"] + + r = await client.delete(f"/api/v1/links/{link_id}", headers=auth_headers) + assert r.status_code == 204 + + r = await client.get("/api/v1/links/unmatched", headers=auth_headers) + body = r.json() + assert [c["cash_txn_id"] for c in body["zm"]] == [leftover["txn"]] + assert [c["event_id"] for c in body["broker"]] == [leftover["event"]] + + +async def test_unknown_ids_are_a_problem(client: AsyncClient, auth_headers: dict[str, str]): + r = await client.post( + "/api/v1/links", headers=auth_headers, json={"cash_txn_id": 999, "event_id": 999} + ) + assert r.status_code == 404 + + r = await client.delete("/api/v1/links/999", headers=auth_headers) + assert r.status_code == 404