"""The tax year, checked against an example worked out by hand — the plan's фаза-4 check. Everything here is an estimate by construction (the broker is the tax agent), so the tests are about the two things that make the estimate worth having: that it is reproducible on paper, and that it never invents a number it does not have. """ from datetime import date from decimal import Decimal import pytest from sqlalchemy import select from factories import ( make_account, make_cbr_rate, make_event, make_instrument, ) from fintracker.analytics import FINDINGS from fintracker.analytics.tax import TAX_RATE, rebuild_tax_year from fintracker.db import get_sessionmaker from fintracker.ledger.rebuild import rebuild_lots from fintracker.models import ( AccountKind, AccountRole, AssetClass, EventKind, LotDisposal, MetricTaxYear, ) from fintracker.pricing.fx import rebuild_fx_daily D = Decimal async def _rebuild() -> None: """The three steps a tax year depends on, without the rest of the refresh.""" FINDINGS.reset() 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() async def _rows() -> dict[tuple[int, int], MetricTaxYear]: async with get_sessionmaker()() as session: found = await session.execute(select(MetricTaxYear)) return {(r.year, r.account_id): r for r in found.scalars()} async def _payment( account_id: int, instrument_id: int, kind: EventKind, d: date, amount: str, *, tax: str | None = None, currency: str = "RUB", ) -> None: """A dividend or coupon as a broker reports it: net cash plus the tax it kept back.""" from datetime import UTC, datetime, time from fintracker.models import Event key = f"{kind}-{instrument_id}-{d}" async with get_sessionmaker()() as session: session.add( Event( account_id=account_id, instrument_id=instrument_id, kind=kind, ts=datetime.combine(d, time.min, tzinfo=UTC), trade_date=d, amount=D(amount), currency=currency, tax=None if tax is None else D(tax), tax_currency=None if tax is None else currency, source="tinvest", source_id=key, dedupe_key=f"tinvest:{key}", ) ) await session.commit() async def _broker_account(name: str = "Брокерский") -> int: return await make_account( name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None, include_in_net_worth=False, source="tinvest", ) @pytest.fixture async def usd_rates(app) -> None: """Two CBR quotes: the dollar rose by 20 % between the purchase and the sale.""" await make_cbr_rate(date(2023, 3, 10), "USD", "75") await make_cbr_rate(date(2024, 6, 20), "USD", "90") async def test_the_worked_example_adds_up(usd_rates): """Done on paper first; the code has to agree with the paper, not the other way round. Purchase 10.03.2023: 10 units at $100 = $1000, CBR 75 ₽/$ -> cost 75 000 ₽ Sale 20.06.2024: 10 units at $110 = $1100, CBR 90 ₽/$ -> proceeds 99 000 ₽ Realised in roubles = 24 000 ₽ of which the price move is $100 x 90 = 9 000 ₽ and currency revaluation $1000 x 15 = 15 000 ₽ (in the base, plan §7 q4) Plus a rouble lot bought 10.01.2020 and sold the same day in 2024 for +1 000 ₽. It is held over three years, so art. 219.1 takes its result back out of the base. gain 24 000 + 1 000 = 25 000 ₽ loss 0 ЛДВ exempt 1 000 ₽ base 25 000 - 1 000 = 24 000 ₽ tax 24 000 x 0.13 = 3 120 ₽ """ account = await _broker_account() foreign = await make_instrument(ticker="AAPL", name="Apple", currency="USD") old = await make_instrument(ticker="SBER", name="Сбербанк") await make_event( date(2023, 3, 10), account_id=account, kind=EventKind.buy, instrument_id=foreign, quantity="10", price="100", amount="-1000", currency="USD", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=foreign, quantity="-10", price="110", amount="1100", currency="USD", ) await make_event( date(2020, 1, 10), account_id=account, kind=EventKind.buy, instrument_id=old, quantity="10", price="100", amount="-1000", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=old, quantity="-10", price="200", amount="2000", ) await _rebuild() row = (await _rows())[(2024, account)] assert row.realized_gain_rub == D("25000.00") assert row.realized_loss_rub == D("0.00") assert row.ldv_exempt_rub == D("1000.00") assert row.taxable_base_rub == D("24000.00") assert row.estimated_tax_rub == D("3120.00") # the rate is recorded, not implied, so a future change stays visible in old years assert row.tax_rate == TAX_RATE == D("0.13") async def test_the_three_year_lot_is_flagged_by_the_ledgers_own_rule(usd_rates): """`ldv_eligible` is computed once, in `ledger/lots.py`; the tax view only reads it.""" account = await _broker_account() instrument = await make_instrument(ticker="SBER", name="Сбербанк") await make_event( date(2020, 1, 10), account_id=account, kind=EventKind.buy, instrument_id=instrument, quantity="10", price="100", amount="-1000", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=instrument, quantity="-10", price="200", amount="2000", ) await _rebuild() async with get_sessionmaker()() as session: disposal = (await session.execute(select(LotDisposal))).scalar_one() assert disposal.holding_days >= 3 * 365 assert disposal.ldv_eligible is True row = (await _rows())[(2024, account)] assert row.ldv_exempt_rub == D("1000.00") assert row.taxable_base_rub == D("0.00") assert row.estimated_tax_rub == D("0.00") async def test_currency_revaluation_is_not_the_currency_result(usd_rates): """A position flat in dollars still owes tax when the dollar rose — and the two numbers must not be confused: the base is 15 000 ₽ while the dollar result is exactly zero.""" account = await _broker_account() instrument = await make_instrument(ticker="AAPL", name="Apple", currency="USD") await make_event( date(2023, 3, 10), account_id=account, kind=EventKind.buy, instrument_id=instrument, quantity="10", price="100", amount="-1000", currency="USD", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=instrument, quantity="-10", price="100", amount="1000", currency="USD", ) await _rebuild() async with get_sessionmaker()() as session: disposal = (await session.execute(select(LotDisposal))).scalar_one() assert disposal.realized_pnl_native == D(0) assert disposal.realized_pnl_rub == D("15000.0000000000") row = (await _rows())[(2024, account)] assert row.taxable_base_rub == D("15000.00") assert row.estimated_tax_rub == D("1950.00") # and the fact that a revaluation happened is said out loud, because a Minfin eurobond # hiding among these would be taxed differently and cannot be detected automatically assert any(f.check_name == "tax_currency_revaluation" for f in FINDINGS.items) async def test_a_leg_without_a_rate_is_left_out_and_reported(usd_rates): """No rate means no rouble result. Never a substitute — a finding instead.""" account = await _broker_account() quoted = await make_instrument(ticker="SBER", name="Сбербанк") unquoted = await make_instrument(ticker="0700", name="Tencent", currency="HKD", board="SPBHKEX") await make_event( date(2024, 2, 1), account_id=account, kind=EventKind.buy, instrument_id=quoted, quantity="10", price="100", amount="-1000", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=quoted, quantity="-10", price="150", amount="1500", ) await make_event( date(2024, 2, 1), account_id=account, kind=EventKind.buy, instrument_id=unquoted, quantity="10", price="100", amount="-1000", currency="HKD", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=unquoted, quantity="-10", price="300", amount="3000", currency="HKD", ) await _rebuild() row = (await _rows())[(2024, account)] # only the rouble trade is in the base; the HKD one did not inflate or deflate it assert row.realized_gain_rub == D("500.00") assert row.taxable_base_rub == D("500.00") assert row.estimated_tax_rub == D("65.00") assert any(f.check_name == "tax_disposal_no_fx" for f in FINDINGS.items) async def test_dividends_and_coupons_are_gross_with_the_tax_that_was_withheld(app): """`amount` is what landed and `tax` is what was taken; gross is their sum.""" account = await _broker_account() share = await make_instrument(ticker="SBER", name="Сбербанк") bond = await make_instrument(ticker="SU26238", name="ОФЗ", asset_class=AssetClass.bond) await _payment(account, share, EventKind.dividend, date(2024, 5, 15), "870", tax="130") await _payment(account, bond, EventKind.coupon, date(2024, 8, 1), "500") await _rebuild() row = (await _rows())[(2024, account)] assert row.dividends_gross_rub == D("1000.00") assert row.coupons_gross_rub == D("500.00") assert row.tax_withheld_rub == D("130.00") # income is outside the securities base: the agent already withheld on it assert row.taxable_base_rub == D("0.00") async def test_every_stored_number_is_a_decimal(usd_rates): account = await _broker_account() instrument = await make_instrument(ticker="SBER", name="Сбербанк") await make_event( date(2024, 2, 1), account_id=account, kind=EventKind.buy, instrument_id=instrument, quantity="10", price="100", amount="-1000", ) await make_event( date(2024, 6, 20), account_id=account, kind=EventKind.sell, instrument_id=instrument, quantity="-10", price="150", amount="1500", ) await _rebuild() row = (await _rows())[(2024, account)] for field in ( "dividends_gross_rub", "coupons_gross_rub", "tax_withheld_rub", "realized_gain_rub", "realized_loss_rub", "ldv_exempt_rub", "taxable_base_rub", "estimated_tax_rub", "tax_rate", ): value = getattr(row, field) assert isinstance(value, Decimal), field assert not isinstance(value, float), field