feat(ledger): резолв несматченных линков ZenMoney↔брокер
GET /links/unmatched, POST /links, DELETE /links/{id}: ручная привязка того,
что ledger/matching.py не смог сопоставить автоматически.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user