"""Tests for BAT-024 / REQ-056 manual ledger API.""" from __future__ import annotations import os import time from contextlib import ExitStack from datetime import UTC, datetime from decimal import Decimal from typing import Any from unittest.mock import patch import pytest from fastapi.testclient import TestClient from pydantic import ValidationError from app.core.config import Settings, get_settings from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal from app.main import create_app from app.schemas.v1 import LedgerHistoryItem _PORTFOLIO_ID = "pf-001" _USER_ID = "user-001" _OTHER_USER_ID = "user-002" def _make_settings() -> Settings: return Settings(allowed_origins_raw="http://localhost:3000", db_enabled=True) def _token_principal( user_id: str = _USER_ID, *, token_type: str = "session", permission: str = "read", ) -> TokenPrincipal: return TokenPrincipal( token_id=f"tok-{user_id}-{token_type}", user_id=user_id, permission=permission, token_type=token_type, ) def _portfolio( *, owner: str = _USER_ID, status: str = "enabled", cash: float = 1000.0, quantity: float = 10.0, price: float = 10.0, buffer: float = 500.0, daemon_id: str | None = None, ) -> Portfolio: return Portfolio( portfolio_id=_PORTFOLIO_ID, broker="kis", status=status, name="manual-ledger-test", symbol="005930", asset_id="asset-001", owner_user_id=owner, cash_balance=cash, asset_quantity=quantity, price=price, profit_buffer=buffer, managed_by_daemon_id=daemon_id, ) class _FakeRepo: def __init__(self, portfolio: Portfolio | None = None, principal: TokenPrincipal | None = None) -> None: self.portfolio = portfolio or _portfolio() self.principal = principal or _token_principal() self.entries: list[LedgerEntry] = [] self._next_id = 1 self._dividend_amounts: dict[int, Decimal] = {} self._dividend_tax_amounts: dict[int, Decimal] = {} def close(self) -> None: pass def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: return self.principal def get(self, portfolio_id: str) -> Portfolio: if portfolio_id != self.portfolio.portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") return self.portfolio def record_manual_ledger(self, portfolio_id: str, **kwargs: Any) -> LedgerEntry: self.get(portfolio_id) action = kwargs["action"] amount_raw = kwargs.get("amount") amount_decimal = Decimal(str(amount_raw if amount_raw is not None else 0)) amount = float(amount_decimal) quantity = float(kwargs.get("quantity") or 0.0) price = float(kwargs.get("price") or 0.0) fee = float(kwargs.get("fee") or 0.0) tax = float(kwargs.get("tax") or 0.0) tax_decimal = Decimal(str(kwargs.get("tax") if kwargs.get("tax") is not None else 0)) executed_at = kwargs.get("executed_at") confirm_duplicate = bool(kwargs.get("confirm_duplicate") or False) note = kwargs.get("note") or "" target = kwargs.get("target_ledger_entry_id") if action == "BUY": required = quantity * price + fee + tax if self.portfolio.cash_balance < required: raise ValueError("INSUFFICIENT_CASH") self.portfolio.cash_balance -= required self.portfolio.asset_quantity += quantity self.portfolio.price = price elif action == "SELL": if self.portfolio.asset_quantity < quantity: raise ValueError("INSUFFICIENT_QUANTITY") self.portfolio.cash_balance += quantity * price - fee - tax self.portfolio.asset_quantity -= quantity self.portfolio.price = price elif action == "INVEST": self.portfolio.profit_buffer += amount price = amount elif action == "BUFFER_OUT": if self.portfolio.profit_buffer < amount: raise ValueError("INSUFFICIENT_BUFFER") self.portfolio.profit_buffer -= amount self.portfolio.cash_balance += amount price = amount elif action == "BUFFER_IN": if self.portfolio.cash_balance < amount: raise ValueError("INSUFFICIENT_CASH") self.portfolio.cash_balance -= amount self.portfolio.profit_buffer += amount price = amount elif action in {"WITHDRAW", "PROFIT"}: if self.portfolio.profit_buffer < amount: raise ValueError("INSUFFICIENT_BUFFER") self.portfolio.profit_buffer -= amount price = amount elif action == "DIVIDEND": reversed_ids = { e.target_ledger_entry_id for e in self.entries if e.action == "REVERSAL" and e.target_ledger_entry_id is not None } duplicate = next( ( e for e in self.entries if e.action == "DIVIDEND" and e.ledger_entry_id not in reversed_ids and e.executed_at == executed_at and self._dividend_amounts.get(e.ledger_entry_id, Decimal(str(e.price))) == amount_decimal and e.note == note ), None, ) if duplicate is not None and not confirm_duplicate: raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{duplicate.ledger_entry_id}") self.portfolio.profit_buffer += amount price = amount elif action == "REVERSAL": target_entry = next((e for e in self.entries if e.ledger_entry_id == target), None) if target_entry is None: raise ValueError("LEDGER_ENTRY_NOT_FOUND") if any(e.target_ledger_entry_id == target and e.action == "REVERSAL" for e in self.entries): raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED") target_action = target_entry.action or "" target_amount = float(target_entry.price) target_quantity = float(target_entry.quantity) target_fee = float(target_entry.fee_amount) target_tax = float(target_entry.tax_amount) if target_action == "BUY": self.portfolio.cash_balance += target_quantity * target_amount + target_fee + target_tax self.portfolio.asset_quantity -= target_quantity elif target_action == "SELL": required_cash = target_quantity * target_amount - target_fee - target_tax if self.portfolio.cash_balance < required_cash: raise ValueError("INSUFFICIENT_CASH") self.portfolio.cash_balance -= required_cash self.portfolio.asset_quantity += target_quantity elif target_action == "BUFFER_OUT": if self.portfolio.cash_balance < target_amount: raise ValueError("INSUFFICIENT_CASH") self.portfolio.cash_balance -= target_amount self.portfolio.profit_buffer += target_amount elif target_action == "BUFFER_IN": if self.portfolio.profit_buffer < target_amount: raise ValueError("INSUFFICIENT_BUFFER") self.portfolio.cash_balance += target_amount self.portfolio.profit_buffer -= target_amount elif target_action in {"INVEST", "DIVIDEND"}: if self.portfolio.profit_buffer < target_amount: raise ValueError("INSUFFICIENT_BUFFER") self.portfolio.profit_buffer -= target_amount elif target_action in {"WITHDRAW", "PROFIT"}: self.portfolio.profit_buffer += target_amount price = 0.0 total = self.portfolio.cash_balance + self.portfolio.asset_quantity * self.portfolio.price + self.portfolio.profit_buffer entry = LedgerEntry( ledger_entry_id=self._next_id, portfolio_id=portfolio_id, action=action, side=action.lower(), quantity=quantity, price=price, price_decimal=amount_decimal if action == "DIVIDEND" else Decimal(str(price)), fee_amount=fee, tax_amount=tax, tax_amount_decimal=tax_decimal if action == "DIVIDEND" else Decimal(str(tax)), executed_at=executed_at, recorded_at=datetime.now(UTC), cash_balance_after=self.portfolio.cash_balance, asset_quantity_after=self.portfolio.asset_quantity, profit_buffer_after=self.portfolio.profit_buffer, total_value_after=total, note=note, source="manual", target_ledger_entry_id=target, confirmation_evidence=kwargs.get("confirmation_evidence"), actor_user_id=kwargs.get("actor_user_id"), ) self._next_id += 1 self.entries.append(entry) if action == "DIVIDEND": self._dividend_amounts[entry.ledger_entry_id] = amount_decimal self._dividend_tax_amounts[entry.ledger_entry_id] = tax_decimal return entry def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: self.get(portfolio_id) return list(reversed(self.entries))[:limit] def list_ledger_entries( self, portfolio_id: str, *, source: str | None = None, before_recorded_at: datetime | None = None, before_ledger_entry_id: int | None = None, limit: int = 50, ) -> list[LedgerEntry]: self.get(portfolio_id) entries = sorted(self.entries, key=lambda entry: (entry.recorded_at, entry.ledger_entry_id), reverse=True) if source is not None: entries = [entry for entry in entries if entry.source == source] if before_recorded_at is not None and before_ledger_entry_id is not None: entries = [ entry for entry in entries if (entry.recorded_at, entry.ledger_entry_id) < (before_recorded_at, before_ledger_entry_id) ] return entries[:limit] def get_return_summary(self, portfolio_id: str) -> dict[str, float]: self.get(portfolio_id) reversed_ids = { e.target_ledger_entry_id for e in self.entries if e.action == "REVERSAL" and e.target_ledger_entry_id is not None } active = [e for e in self.entries if e.ledger_entry_id not in reversed_ids] invested = sum(e.price for e in active if e.action == "INVEST") withdrawn = sum(e.price for e in active if e.action == "WITHDRAW") profit = sum(e.price for e in active if e.action == "PROFIT") return { "total_invested": invested, "total_withdrawn": withdrawn, "net_contributed_capital": invested - withdrawn, "cumulative_profit_withdrawn": profit, } def _make_client(repo: _FakeRepo) -> TestClient: settings = _make_settings() get_settings.cache_clear() app = create_app(settings_factory=lambda: settings) app.dependency_overrides[get_settings] = lambda: settings stack = ExitStack() stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)) stack.enter_context(patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)) client = TestClient(app) client._repo_stack = stack # type: ignore[attr-defined] # keep patches alive for the client lifetime return client def _auth() -> dict[str, str]: return {"Authorization": "Bearer user-token"} def test_manual_buy_updates_snapshot_and_returns_total_value_after() -> None: repo = _FakeRepo(_portfolio(cash=1000, quantity=1, price=10, buffer=100)) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "BUY", "quantity": 2, "price": 100, "fee": 1, "tax": 2}, headers=_auth(), ) assert resp.status_code == 201 data = resp.json() assert data["action"] == "BUY" assert data["cash_balance_after"] == 797.0 assert data["asset_quantity_after"] == 3.0 assert data["profit_buffer_after"] == 100.0 assert data["total_value_after"] == 1197.0 def test_manual_write_rejects_service_token() -> None: repo = _FakeRepo(principal=_token_principal(token_type="service")) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth(), ) assert resp.status_code == 403 def test_daemon_managed_requires_confirmation_then_records_evidence() -> None: repo = _FakeRepo(_portfolio(daemon_id="daemon-1")) client = _make_client(repo) first = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 100}, headers=_auth(), ) assert first.status_code == 409 assert first.json()["detail"]["code"] == "MANUAL_WRITE_REQUIRES_CONFIRMATION" second = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 100, "confirm_daemon_managed_manual_write": True}, headers=_auth(), ) assert second.status_code == 201 assert second.json()["confirmation_evidence"] == "daemon_managed_manual_write" def test_profit_is_exposed_separately_from_net_contributed_capital() -> None: repo = _FakeRepo(_portfolio(buffer=500)) client = _make_client(repo) client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 300}, headers=_auth()) client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "WITHDRAW", "amount": 100}, headers=_auth()) client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 50}, headers=_auth()) resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth()) assert resp.status_code == 200 data = resp.json() assert data["net_contributed_capital"] == 200.0 assert data["cumulative_profit_withdrawn"] == 50.0 def test_return_summary_excludes_reversed_invest_withdraw_and_profit() -> None: repo = _FakeRepo(_portfolio(buffer=1_000)) client = _make_client(repo) entries = [] for action, amount in (("INVEST", 300), ("WITHDRAW", 100), ("PROFIT", 50)): response = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": action, "amount": amount}, headers=_auth(), ) assert response.status_code == 201 entries.append(response.json()["ledger_entry_id"]) for target in entries: response = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": target}, headers=_auth(), ) assert response.status_code == 201 resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth()) assert resp.status_code == 200 assert resp.json() == { "portfolio_id": _PORTFOLIO_ID, "total_invested": 0.0, "total_withdrawn": 0.0, "net_contributed_capital": 0.0, "cumulative_profit_withdrawn": 0.0, } def test_insufficient_cash_rejected_as_409() -> None: repo = _FakeRepo(_portfolio(cash=10, quantity=0, price=0)) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "BUY", "quantity": 1, "price": 100}, headers=_auth(), ) assert resp.status_code == 409 assert resp.json()["detail"]["code"] == "INSUFFICIENT_CASH" assert resp.json()["detail"]["message"] == "Insufficient cash balance" def test_manual_ledger_conflict_message_is_sanitized() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) secret = "SENTINEL_SECRET_TOKEN_12345" def _raise_secret(*args: Any, **kwargs: Any) -> LedgerEntry: raise ValueError(f"synthetic backend failure: {secret}") repo.record_manual_ledger = _raise_secret # type: ignore[method-assign] resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"}, headers=_auth(), ) assert resp.status_code == 422 detail = resp.json()["detail"] assert detail["code"] == "MANUAL_LEDGER_CONFLICT" assert detail["message"] == "Manual ledger request could not be processed" assert secret not in resp.text def test_manual_history_returns_total_value_after() -> None: repo = _FakeRepo(_portfolio(buffer=500)) client = _make_client(repo) client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 100}, headers=_auth()) resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", headers=_auth()) assert resp.status_code == 200 item = resp.json()["items"][0] assert item["action"] == "PROFIT" assert "total_value_after" in item def test_owner_mismatch_returns_403() -> None: repo = _FakeRepo(_portfolio(owner=_OTHER_USER_ID)) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth(), ) assert resp.status_code == 403 def test_admin_session_does_not_bypass_manual_owner_scope() -> None: repo = _FakeRepo( _portfolio(owner=_OTHER_USER_ID), principal=_token_principal(user_id=_USER_ID, token_type="session", permission="admin"), ) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth(), ) assert resp.status_code == 403 def test_admin_session_does_not_bypass_manual_read_owner_scope() -> None: repo = _FakeRepo( _portfolio(owner=_OTHER_USER_ID), principal=_token_principal(user_id=_USER_ID, token_type="session", permission="admin"), ) client = _make_client(repo) history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", headers=_auth()) summary = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth()) assert history.status_code == 403 assert summary.status_code == 403 def test_amount_actions_reject_fee_and_tax_fields() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) for payload in [ {"action": "INVEST", "amount": 100, "fee": 1}, {"action": "INVEST", "amount": 100, "tax": 1}, {"action": "BUFFER_OUT", "amount": 100, "fee": 0}, ]: resp = client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json=payload, headers=_auth()) assert resp.status_code == 422 def test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax() -> None: repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500)) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 120, "tax": 10, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert resp.status_code == 201 body = resp.json() assert body["action"] == "DIVIDEND" assert body["amount"] == "120" assert body["tax"] == "10" assert body["executed_at"] == "2026-09-01T00:00:00Z" assert body["cash_balance_after"] == 1000.0 assert body["asset_quantity_after"] == 10.0 assert body["profit_buffer_after"] == 620.0 assert body["total_value_after"] == 1720.0 assert body["source"] == "manual" def test_dividend_tax_defaults_to_zero_when_omitted() -> None: repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500)) client = _make_client(repo) resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 120, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert resp.status_code == 201 body = resp.json() assert body["tax"] == "0" assert body["cash_balance_after"] == 1000.0 assert body["asset_quantity_after"] == 10.0 assert body["profit_buffer_after"] == 620.0 def test_dividend_executed_at_uses_utc_midnight_independent_of_server_timezone(monkeypatch: pytest.MonkeyPatch) -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) original_tz = None if hasattr(time, "tzset"): original_tz = os.environ.get("TZ") monkeypatch.setenv("TZ", "Asia/Seoul") time.tzset() try: resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"}, headers=_auth(), ) finally: if hasattr(time, "tzset"): if original_tz is None: monkeypatch.delenv("TZ", raising=False) else: monkeypatch.setenv("TZ", original_tz) time.tzset() assert resp.status_code == 201 body = resp.json() assert body["executed_at"] == "2026-09-01T00:00:00Z" assert body["recorded_at"] != body["executed_at"] def test_dividend_requires_executed_at_and_rejects_fee_field() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) missing_date = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 50}, headers=_auth(), ) assert missing_date.status_code == 422 invalid_fee = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "fee": 0}, headers=_auth(), ) assert invalid_fee.status_code == 422 def test_dividend_rejects_invalid_amount_tax_and_unrelated_fields_without_state_change() -> None: repo = _FakeRepo(_portfolio(cash=1000, quantity=10, price=10, buffer=500)) client = _make_client(repo) baseline = ( repo.portfolio.cash_balance, repo.portfolio.asset_quantity, repo.portfolio.profit_buffer, len(repo.entries), ) invalid_payloads = [ {"action": "DIVIDEND", "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": 1}, {"action": "DIVIDEND", "amount": 0, "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": -1, "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-13-40"}, {"action": "DIVIDEND", "amount": 1, "tax": -1, "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": 1, "tax": "0.000000001", "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": 1, "tax": "1000000000000.00000000", "executed_at": "2026-09-01"}, {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "fee": 0}, {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "quantity": 1}, {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "price": 1}, {"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01", "target_ledger_entry_id": 1}, ] for payload in invalid_payloads: resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json=payload, headers=_auth(), ) assert resp.status_code == 422 assert ( repo.portfolio.cash_balance, repo.portfolio.asset_quantity, repo.portfolio.profit_buffer, len(repo.entries), ) == baseline def test_dividend_rejects_sub_minimum_numeric_precision() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) too_precise = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": "0.000000004", "executed_at": "2026-09-01"}, headers=_auth(), ) assert too_precise.status_code == 422 def test_dividend_rejects_amount_exceeding_numeric_20_8() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) too_large = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": "1000000000000.00000000", "executed_at": "2026-09-01"}, headers=_auth(), ) assert too_large.status_code == 422 def test_dividend_duplicate_requires_confirmation() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) first = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert first.status_code == 201 duplicate = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert duplicate.status_code == 409 assert duplicate.json()["detail"]["code"] == "POTENTIAL_DUPLICATE_DIVIDEND" assert duplicate.json()["detail"]["matched_ledger_entry_id"] == first.json()["ledger_entry_id"] confirmed = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={ "action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3", "confirm_duplicate": True, }, headers=_auth(), ) assert confirmed.status_code == 201 def test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) first = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": "1.00000000", "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert first.status_code == 201 second = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": "1.00000001", "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert second.status_code == 201 def test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) first = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": "90000000.00000001", "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert first.status_code == 201 second = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": "90000000.00000002", "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert second.status_code == 201 assert first.json()["amount"] == "90000000.00000001" assert second.json()["amount"] == "90000000.00000002" history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth()) assert history.status_code == 200 amounts = [item["amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"] assert "90000000.00000001" in amounts assert "90000000.00000002" in amounts def test_dividend_tax_does_not_collapse_decimals_that_can_alias_in_float() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) first = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={ "action": "DIVIDEND", "amount": "1.00000000", "tax": "90000000.00000001", "executed_at": "2026-09-01", "note": "Q3-1", }, headers=_auth(), ) assert first.status_code == 201 second = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={ "action": "DIVIDEND", "amount": "1.00000000", "tax": "90000000.00000002", "executed_at": "2026-09-02", "note": "Q3-2", }, headers=_auth(), ) assert second.status_code == 201 assert first.json()["tax"] == "90000000.00000001" assert second.json()["tax"] == "90000000.00000002" history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth()) assert history.status_code == 200 taxes = [item["tax_amount"] for item in history.json()["items"] if item["action"] == "DIVIDEND"] assert "90000000.00000001" in taxes assert "90000000.00000002" in taxes def test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) first = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert first.status_code == 201 reversal = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": first.json()["ledger_entry_id"]}, headers=_auth(), ) assert reversal.status_code == 201 replay = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 50, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert replay.status_code == 201 def test_confirm_duplicate_false_is_accepted_for_non_dividend_actions() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) invest = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100, "confirm_duplicate": False}, headers=_auth(), ) assert invest.status_code == 201 assert isinstance(invest.json()["amount"], float) buy = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "BUY", "quantity": 1, "price": 10, "confirm_duplicate": False}, headers=_auth(), ) assert buy.status_code == 201 def test_unified_ledger_keeps_non_dividend_amount_as_number() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) created = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth(), ) assert created.status_code == 201 assert created.json()["amount"] == 100.0 history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth()) assert history.status_code == 200 item = history.json()["items"][0] assert item["action"] == "INVEST" assert item["amount"] == 100.0 def test_confirm_duplicate_true_is_rejected_for_non_dividend_actions() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) invest = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100, "confirm_duplicate": True}, headers=_auth(), ) assert invest.status_code == 422 def test_dividend_reversal_decreases_profit_buffer() -> None: repo = _FakeRepo(_portfolio(buffer=200)) client = _make_client(repo) dividend = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 80, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert dividend.status_code == 201 assert dividend.json()["profit_buffer_after"] == 280.0 reversal = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]}, headers=_auth(), ) assert reversal.status_code == 201 assert reversal.json()["profit_buffer_after"] == 200.0 def test_duplicate_reversal_is_rejected_without_state_change() -> None: repo = _FakeRepo(_portfolio(buffer=200)) client = _make_client(repo) dividend = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 80, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert dividend.status_code == 201 first_reversal = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]}, headers=_auth(), ) assert first_reversal.status_code == 201 before_count = len(repo.entries) second_reversal = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]}, headers=_auth(), ) assert second_reversal.status_code == 409 assert second_reversal.json()["detail"]["code"] == "LEDGER_ENTRY_ALREADY_REVERSED" assert len(repo.entries) == before_count assert repo.portfolio.profit_buffer == 200.0 def test_dividend_reversal_insufficient_buffer_is_rejected_without_state_change() -> None: repo = _FakeRepo(_portfolio(buffer=0, cash=1000, quantity=10, price=10)) client = _make_client(repo) dividend = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 100, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert dividend.status_code == 201 withdraw = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "WITHDRAW", "amount": 100}, headers=_auth(), ) assert withdraw.status_code == 201 assert repo.portfolio.profit_buffer == 0.0 before_count = len(repo.entries) reversal = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": dividend.json()["ledger_entry_id"]}, headers=_auth(), ) assert reversal.status_code == 409 assert reversal.json()["detail"]["code"] == "INSUFFICIENT_BUFFER" assert len(repo.entries) == before_count assert repo.portfolio.profit_buffer == 0.0 def test_manual_ledger_conflict_secret_is_not_rendered_in_logs(caplog: pytest.LogCaptureFixture) -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) secret = "SENTINEL_SECRET_TOKEN_67890" def _raise_secret(*args: Any, **kwargs: Any) -> LedgerEntry: raise ValueError(f"synthetic persistence {secret}") repo.record_manual_ledger = _raise_secret # type: ignore[method-assign] caplog.clear() caplog.set_level("INFO") resp = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 1, "executed_at": "2026-09-01"}, headers=_auth(), ) assert resp.status_code == 422 rendered = "\n".join(f"{record.name}:{record.getMessage()}" for record in caplog.records) assert secret not in rendered def test_unified_ledger_includes_dividend_fields() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) created = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "DIVIDEND", "amount": 33, "tax": 3, "executed_at": "2026-09-01", "note": "Q3"}, headers=_auth(), ) assert created.status_code == 201 resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=manual", headers=_auth()) assert resp.status_code == 200 item = resp.json()["items"][0] assert item["source"] == "manual" assert item["action"] == "DIVIDEND" assert item["amount"] == "33" assert item["price"] == 33.0 assert item["executed_at"] == "2026-09-01T00:00:00Z" assert item["tax_amount"] == "3" assert item["note"] == "Q3" assert item["profit_buffer_after"] == created.json()["profit_buffer_after"] def test_unified_ledger_openapi_contains_nullable_amount() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) response = client.get("/openapi.json") assert response.status_code == 200 schemas = response.json()["components"]["schemas"] manual_amount_types = {option.get("type") for option in schemas["ManualLedgerResponse"]["properties"]["amount"]["anyOf"]} assert manual_amount_types == {"number", "null"} assert schemas["DividendManualLedgerResponse"]["properties"]["amount"]["type"] == "string" history_amount_types = {option.get("type") for option in schemas["LedgerHistoryItem"]["properties"]["amount"]["anyOf"]} assert history_amount_types == {"number", "null"} assert schemas["DividendLedgerHistoryItem"]["properties"]["amount"]["type"] == "string" assert "DIVIDEND" not in schemas["LedgerHistoryItem"]["properties"]["action"]["enum"] assert schemas["DividendLedgerHistoryItem"]["properties"]["action"]["const"] == "DIVIDEND" def test_unified_ledger_history_item_rejects_dividend_numeric_branch_payload() -> None: with pytest.raises(ValidationError): LedgerHistoryItem( ledger_entry_id=1, portfolio_id=_PORTFOLIO_ID, source="manual", action="DIVIDEND", recorded_at=datetime.now(UTC), amount=1.0, tax_amount=0.0, ) def test_reversal_rejects_fee_tax_and_missing_target() -> None: repo = _FakeRepo(_portfolio()) client = _make_client(repo) invalid_field = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": 999, "fee": 1}, headers=_auth(), ) assert invalid_field.status_code == 422 missing_target = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "REVERSAL", "target_ledger_entry_id": 999}, headers=_auth(), ) assert missing_target.status_code == 404 assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND" def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None: repo = _FakeRepo() client = _make_client(repo) manual = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth() ) assert manual.status_code == 201 repo.entries.append( LedgerEntry( ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker", recorded_at=datetime.now(UTC), cash_balance_after=900, asset_quantity_after=12, ) ) first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()) assert first.status_code == 200 first_item = first.json()["items"][0] assert first_item["source"] == "broker" assert first_item["action"] == "BUY" assert "side" not in first_item assert first.json()["next_cursor"] second = client.get( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={first.json()['next_cursor']}", headers=_auth() ) assert second.status_code == 200 assert second.json()["items"][0]["ledger_entry_id"] == manual.json()["ledger_entry_id"] assert second.json()["next_cursor"] is None def test_unified_ledger_rejects_tampered_or_different_source_cursor() -> None: repo = _FakeRepo() client = _make_client(repo) for amount in (100, 200): assert client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": amount}, headers=_auth() ).status_code == 201 cursor = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()).json()["next_cursor"] assert client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={cursor[:-1]}x", headers=_auth()).status_code == 422 assert client.get( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&source=manual&cursor={cursor}", headers=_auth() ).status_code == 422 def test_unified_ledger_source_filter_unknown_value_is_empty_and_owner_scoped() -> None: repo = _FakeRepo() client = _make_client(repo) assert client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth() ).status_code == 201 filtered = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=unknown-producer", headers=_auth()) assert filtered.status_code == 200 assert filtered.json() == {"items": [], "next_cursor": None} foreign_client = _make_client(_FakeRepo(_portfolio(owner=_OTHER_USER_ID))) assert foreign_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403 service_client = _make_client(_FakeRepo(principal=_token_principal(token_type="service"))) assert service_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403