"""`/income` over a small portfolio: one paid dividend, one announced, one bond coupon. The router is not wired into `create_app` yet (that is done separately), so the fixture mounts it on the same application the rest of the API tests use. """ from collections.abc import AsyncIterator from datetime import UTC, date, datetime, timedelta from decimal import Decimal from typing import Any import pytest from httpx import ASGITransport, AsyncClient from factories import make_account, make_event, make_instrument, refresh from fintracker.analytics import today_local from fintracker.analytics.income import add_months, rebuild_income from fintracker.db import get_sessionmaker from fintracker.models import ( AccountKind, AccountRole, AssetClass, BondNominalSchedule, CorporateAction, CorporateActionKind, CorporateActionStatus, Event, EventKind, Instrument, ) D = Decimal PREFIX = "/api/v1/income" async def make_bond(*, ticker: str, maturity: date | None = None) -> int: async with get_sessionmaker()() as session: bond = Instrument( asset_class=AssetClass.bond, ticker=ticker, board="TQOB", name=ticker, currency="RUB", nominal=D(1000), nominal_currency="RUB", maturity_date=maturity, ) session.add(bond) await session.commit() await session.refresh(bond) return bond.id async def make_payout( d: date, *, account_id: int, instrument_id: int, amount: str, kind: EventKind = EventKind.dividend, tax: str | None = None, ) -> None: async with get_sessionmaker()() as session: session.add( Event( account_id=account_id, instrument_id=instrument_id, kind=kind, ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC), trade_date=d, amount=D(amount), currency="RUB", tax=D(tax) if tax is not None else None, tax_currency="RUB" if tax is not None else None, source="tinvest", source_id=f"pay-{instrument_id}-{d}-{amount}", dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}", ) ) await session.commit() async def make_action( *, instrument_id: int, kind: CorporateActionKind, status: CorporateActionStatus, pay_date: date, record_date: date | None = None, amount_per_unit: str, ) -> None: async with get_sessionmaker()() as session: session.add( CorporateAction( instrument_id=instrument_id, kind=kind, status=status, pay_date=pay_date, record_date=record_date, amount_per_unit=D(amount_per_unit), currency="RUB", source="moex", source_id=f"{kind}-{pay_date}", ) ) await session.commit() async def make_nominal(instrument_id: int, effective: date, nominal: str) -> None: async with get_sessionmaker()() as session: session.add( BondNominalSchedule( instrument_id=instrument_id, effective_date=effective, nominal=D(nominal), currency="RUB", source="moex", ) ) await session.commit() async def rebuild() -> None: async with get_sessionmaker()() as session: await rebuild_income(session) await session.commit() @pytest.fixture async def income_client(app, user) -> AsyncIterator[AsyncClient]: from fintracker.api.routers import income app.include_router(income.router, prefix="/api/v1") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: r = await c.post("/api/v1/auth/login", json=user) assert r.status_code == 200, r.text c.headers["Authorization"] = f"Bearer {r.json()['access_token']}" yield c @pytest.fixture async def portfolio(app) -> dict[str, int]: """A share that paid twice and has a declared payout, and an amortising bond.""" today = today_local() account = await make_account( name="Брокерский", kind=AccountKind.broker, role=AccountRole.investment, balance=None, include_in_net_worth=False, source="tinvest", ) share = await make_instrument(ticker="SBER", name="Сбербанк России") bond = await make_bond(ticker="RU000API", maturity=add_months(today, 30)) await make_event( add_months(today, -20), account_id=account, kind=EventKind.buy, instrument_id=share, quantity="20", price="250", amount="-5000", ) await make_event( add_months(today, -20), account_id=account, kind=EventKind.buy, instrument_id=bond, quantity="10", price="1000", amount="-10000", ) await make_payout( add_months(today, -13), account_id=account, instrument_id=share, amount="696.80", tax="104" ) await make_payout( add_months(today, -1), account_id=account, instrument_id=share, amount="696.80", tax="104" ) await make_action( instrument_id=share, kind=CorporateActionKind.dividend, status=CorporateActionStatus.announced, record_date=today + timedelta(days=21), pay_date=today + timedelta(days=24), amount_per_unit="34.84", ) await make_nominal(bond, add_months(today, -24), "1000") await make_action( instrument_id=bond, kind=CorporateActionKind.coupon, status=CorporateActionStatus.announced, pay_date=add_months(today, 2), amount_per_unit="40", ) await refresh() await rebuild() return {"account": account, "share": share, "bond": bond} def floats(value: Any, path: str = "$") -> list[str]: """Every place a float leaked into the payload — money must travel as a string.""" if isinstance(value, bool): return [] if isinstance(value, float): return [path] if isinstance(value, dict): return [p for k, v in value.items() for p in floats(v, f"{path}.{k}")] if isinstance(value, list): return [p for i, v in enumerate(value) for p in floats(v, f"{path}[{i}]")] return [] async def test_calendar_shows_the_future_with_money_as_strings_and_a_basis_on_every_row( income_client: AsyncClient, portfolio: dict[str, int] ): r = await income_client.get(f"{PREFIX}/calendar") assert r.status_code == 200, r.text body = r.json() assert floats(body) == [] assert body["currency"] == "RUB" assert body["entries"], body for entry in body["entries"]: assert isinstance(entry["amount"], str) assert isinstance(entry["qty"], str) assert entry["basis"] in {"schedule", "announced", "history"} by_key = {(e["kind"], e["basis"]): e for e in body["entries"]} # the declared autumn dividend, and next year's payment that only history knows about announced = by_key[("dividend", "announced")] assert announced["amount"] == "696.8000000000" assert announced["ticker"] == "SBER" assert announced["per_unit"] == "34.8400000000" assert ("dividend", "history") in by_key assert by_key[("coupon", "schedule")]["amount"] == "400.0000000000" assert set(body["by_basis"]) == {"announced", "history", "schedule"} total = sum(D(v) for v in body["by_basis"].values()) assert D(body["total_expected_rub"]) == total async def test_include_paid_adds_the_history_rows_and_nothing_else( income_client: AsyncClient, portfolio: dict[str, int] ): today = today_local() window = {"date_from": str(add_months(today, -24)), "date_to": str(add_months(today, 12))} without = (await income_client.get(f"{PREFIX}/calendar", params=window)).json() assert {e["basis"] for e in without["entries"]} == {"announced", "history", "schedule"} with_paid = ( await income_client.get(f"{PREFIX}/calendar", params={**window, "include_paid": "true"}) ).json() paid = [e for e in with_paid["entries"] if e["basis"] == "paid"] assert len(paid) == 2 assert paid[0]["tax_withheld"] == "104.0000000000" # a payment already received is not an expectation: the totals must not move assert with_paid["total_expected_rub"] == without["total_expected_rub"] assert "paid" not in with_paid["by_basis"] async def test_history_groups_by_month_and_totals_the_tax( income_client: AsyncClient, portfolio: dict[str, int] ): r = await income_client.get(f"{PREFIX}/history") assert r.status_code == 200, r.text body = r.json() assert floats(body) == [] assert [row["kind"] for row in body["rows"]] == ["dividend", "dividend"] assert {row["payment_count"] for row in body["rows"]} == {1} assert D(body["totals"]["amount_rub"]) == D("1393.60") assert D(body["totals"]["tax_withheld_rub"]) == D("208") filtered = (await income_client.get(f"{PREFIX}/history", params={"kind": "coupon"})).json() assert filtered["rows"] == [] async def test_forecast_splits_every_month_by_basis( income_client: AsyncClient, portfolio: dict[str, int] ): r = await income_client.get(f"{PREFIX}/forecast", params={"months": 12}) assert r.status_code == 200, r.text body = r.json() assert floats(body) == [] assert body["months"], body bases = {basis for month in body["months"] for basis in month["by_basis"]} assert bases <= {"schedule", "announced", "history"} for month in body["months"]: assert D(month["amount_rub"]) == sum(D(v) for v in month["by_basis"].values()) assert D(body["total_rub"]) == sum(D(m["amount_rub"]) for m in body["months"]) assert isinstance(body["warnings"], list) async def test_forecast_rejects_a_horizon_the_table_was_not_built_for( income_client: AsyncClient, portfolio: dict[str, int] ): assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 0})).status_code == 422 assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 37})).status_code == 422 async def test_an_unknown_scope_is_a_404_not_an_empty_calendar( income_client: AsyncClient, portfolio: dict[str, int] ): r = await income_client.get(f"{PREFIX}/calendar", params={"scope": "account:999"}) assert r.status_code == 404 async def test_the_endpoints_require_a_token(income_client: AsyncClient, app): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as anon: assert (await anon.get(f"{PREFIX}/calendar")).status_code == 401 async def test_the_endpoints_are_pure_reads_and_repeat_themselves( income_client: AsyncClient, portfolio: dict[str, int] ): first = (await income_client.get(f"{PREFIX}/forecast")).json() second = (await income_client.get(f"{PREFIX}/forecast")).json() assert first == second