"""`GET /tax` and `GET /tax/lots` — the screen that prices selling before the three-year mark. The router is not wired into `api/app.py` by this module's author, so the tests mount it on a copy of the application. That keeps the check honest about the routes' own behaviour while leaving the inclusion order to whoever owns `app.py`. """ import json from collections.abc import AsyncIterator from datetime import timedelta from decimal import Decimal import pytest from httpx import ASGITransport, AsyncClient from factories import make_account, make_event, make_instrument, make_price from fintracker.analytics import today_local from fintracker.analytics.tax import rebuild_tax_year from fintracker.api.routers import tax as tax_router from fintracker.db import get_sessionmaker from fintracker.ledger.rebuild import rebuild_lots from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind from fintracker.pricing.fx import rebuild_fx_daily D = Decimal LDV_DAYS = 3 * 365 @pytest.fixture async def tax_client(app, auth_headers) -> AsyncIterator[AsyncClient]: """The application plus the tax router, which `app.py` does not include yet.""" app.include_router(tax_router.router, prefix="/api/v1") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: c.headers.update(auth_headers) yield c @pytest.fixture async def lots(app) -> dict[str, object]: """Two open lots of the same paper: one past the ЛДВ mark, one still short of it.""" t = today_local() old_date = t - timedelta(days=LDV_DAYS + 30) young_date = t - timedelta(days=400) account = await make_account( name="ИИС", kind=AccountKind.broker, role=AccountRole.investment, balance=None, include_in_net_worth=False, source="tinvest", ) old_share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share) young_share = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share) await make_event( old_date, account_id=account, kind=EventKind.buy, instrument_id=old_share, quantity="10", price="100", amount="-1000", ) await make_event( young_date, account_id=account, kind=EventKind.buy, instrument_id=young_share, quantity="20", price="240", amount="-4800", ) await make_price(t, instrument_id=old_share, close="150") await make_price(t, instrument_id=young_share, close="275.89") async with get_sessionmaker()() as session: await rebuild_fx_daily(session) await session.commit() await rebuild_lots(session) await session.commit() await rebuild_tax_year(session) await session.commit() return { "account": account, "old": old_share, "young": young_share, "old_date": old_date, "young_date": young_date, "today": t, } async def test_lots_show_the_days_left_to_the_exemption(tax_client, lots): r = await tax_client.get("/api/v1/tax/lots") assert r.status_code == 200, r.text body = r.json() assert body["estimated"] is True assert body["tax_rate"] == "0.13" assert body["disclaimer"] by_ticker = {lot["ticker"]: lot for lot in body["lots"]} young = by_ticker["GAZP"] ldv_date = lots["young_date"] + timedelta(days=LDV_DAYS) assert young["ldv_date"] == ldv_date.isoformat() assert young["days_to_ldv"] == (ldv_date - lots["today"]).days assert young["ldv_eligible"] is False # 20 x 275.89 = 5517.80 against a cost of 4800 -> 717.80 unrealised, 13 % of it is 93.31 assert young["market_value_rub"] == "5517.80" assert young["cost_rub"] == "4800.00" assert young["unrealized_gain_rub"] == "717.80" assert young["tax_if_sold_now_rub"] == "93.31" async def test_a_lot_past_three_years_costs_nothing_to_sell(tax_client, lots): body = (await tax_client.get("/api/v1/tax/lots")).json() old = {lot["ticker"]: lot for lot in body["lots"]}["SBER"] assert old["ldv_eligible"] is True assert old["days_to_ldv"] == 0 # 10 x 150 = 1500 against 1000 is a real gain, and art. 219.1 makes it untaxed assert old["unrealized_gain_rub"] == "500.00" assert old["tax_if_sold_now_rub"] == "0.00" async def test_the_year_summary_is_marked_an_estimate(tax_client, lots): r = await tax_client.get("/api/v1/tax", params={"year": lots["today"].year}) assert r.status_code == 200, r.text body = r.json() assert body["estimated"] is True assert body["tax_rate"] == "0.13" assert "брокер" in body["disclaimer"] # nothing was sold, so the year is empty — but it is still a well-formed answer assert body["totals"]["estimated_tax_rub"] == "0.00" assert body["accounts"] == [] async def test_no_float_reaches_the_wire(tax_client, lots): def reject_float(raw: str) -> None: raise AssertionError(f"float in the response: {raw}") for path in ("/api/v1/tax", "/api/v1/tax/lots"): r = await tax_client.get(path) assert r.status_code == 200, r.text json.loads(r.text, parse_float=reject_float)