diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 150cfd5..0262dc2 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -27,6 +27,8 @@ from app.domain.models import Portfolio, PortfolioComment from app.domain.pnl import calculate_current_wac, calculate_realized_pnl from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import ( + DividendLedgerHistoryItem, + DividendManualLedgerResponse, HoldingsCostBasisResponse, LedgerHistoryItem, LedgerHistoryResponse, @@ -686,25 +688,40 @@ def record_ledger_trade_v1( ) -def _manual_entry_to_response(entry) -> ManualLedgerResponse: +def _manual_entry_to_response(entry) -> ManualLedgerResponse | DividendManualLedgerResponse: action = (entry.action or entry.side or "").upper() - amount: float | Decimal | None = None if action == "DIVIDEND": - amount = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)) - elif action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}: - amount = float(entry.price) - tax_value: float | Decimal = float(entry.tax_amount) - if action == "DIVIDEND": - tax_value = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount)) + amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)) + tax_decimal = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount)) + return DividendManualLedgerResponse( + ledger_entry_id=entry.ledger_entry_id, + portfolio_id=entry.portfolio_id, + action=action, + amount=str(amount_decimal), + quantity=entry.quantity, + price=entry.price, + fee=entry.fee_amount, + tax=str(tax_decimal), + executed_at=entry.executed_at, + note=entry.note, + recorded_at=entry.recorded_at, + cash_balance_after=entry.cash_balance_after, + asset_quantity_after=entry.asset_quantity_after, + profit_buffer_after=entry.profit_buffer_after, + total_value_after=entry.total_value_after, + source=entry.source or "manual", + target_ledger_entry_id=entry.target_ledger_entry_id, + confirmation_evidence=entry.confirmation_evidence, + ) return ManualLedgerResponse( ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id, action=action, - amount=amount, + amount=float(entry.price) if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None, quantity=entry.quantity, price=entry.price, fee=entry.fee_amount, - tax=tax_value, + tax=float(entry.tax_amount), executed_at=entry.executed_at, note=entry.note, recorded_at=entry.recorded_at, @@ -792,16 +809,33 @@ def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None) raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid ledger cursor") from None -def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem: +def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHistoryItem: action = (entry.action or entry.side or "").upper() - amount: float | Decimal | None = None if action == "DIVIDEND": - amount = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)) - elif action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"}: - amount = float(entry.price) - tax_amount: float | Decimal | None = entry.tax_amount - if action == "DIVIDEND": - tax_amount = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount)) + amount_decimal = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)) + tax_decimal = entry.tax_amount_decimal if entry.tax_amount_decimal is not None else Decimal(str(entry.tax_amount)) + return DividendLedgerHistoryItem( + ledger_entry_id=entry.ledger_entry_id, + portfolio_id=entry.portfolio_id, + source=entry.source or "broker", + action=action, + recorded_at=entry.recorded_at, + executed_at=entry.executed_at, + order_id=entry.order_id, + execution_id=entry.execution_id, + quantity=entry.quantity, + amount=str(amount_decimal), + price=entry.price, + fee_amount=entry.fee_amount, + tax_amount=str(tax_decimal), + note=entry.note, + cash_balance_after=entry.cash_balance_after, + asset_quantity_after=entry.asset_quantity_after, + profit_buffer_after=entry.profit_buffer_after, + total_value_after=entry.total_value_after, + target_ledger_entry_id=entry.target_ledger_entry_id, + confirmation_evidence=entry.confirmation_evidence, + ) return LedgerHistoryItem( ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id, @@ -812,10 +846,10 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem: order_id=entry.order_id, execution_id=entry.execution_id, quantity=entry.quantity, - amount=amount, + amount=float(entry.price) if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None, price=entry.price, fee_amount=entry.fee_amount, - tax_amount=tax_amount, + tax_amount=float(entry.tax_amount), note=entry.note, cash_balance_after=entry.cash_balance_after, asset_quantity_after=entry.asset_quantity_after, @@ -828,14 +862,14 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem: @router.post( "/{portfolio_id}/ledger/manual", - response_model=ManualLedgerResponse, + response_model=ManualLedgerResponse | DividendManualLedgerResponse, status_code=status.HTTP_201_CREATED, ) def record_manual_ledger_v1( portfolio_id: str, body: ManualLedgerRequest, auth: AuthContext = Depends(require_read), -) -> ManualLedgerResponse: +) -> ManualLedgerResponse | DividendManualLedgerResponse: if auth.token_type in {"service", "client"} or auth.user_id is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token") diff --git a/app/schemas/v1.py b/app/schemas/v1.py index ca28a00..9f9f381 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -219,12 +219,33 @@ class ManualLedgerRequest(BaseModel): class ManualLedgerResponse(BaseModel): ledger_entry_id: int portfolio_id: str - action: str - amount: float | Decimal | None = None + action: Literal["INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "BUY", "SELL", "REVERSAL"] + amount: float | None = None quantity: float = 0.0 price: float = 0.0 fee: float = 0.0 - tax: float | Decimal = 0.0 + tax: float = 0.0 + executed_at: datetime | None = None + note: str = "" + recorded_at: datetime | None = None + cash_balance_after: float = 0.0 + asset_quantity_after: float = 0.0 + profit_buffer_after: float = 0.0 + total_value_after: float = 0.0 + source: str = "manual" + target_ledger_entry_id: int | None = None + confirmation_evidence: str | None = None + + +class DividendManualLedgerResponse(BaseModel): + ledger_entry_id: int + portfolio_id: str + action: Literal["DIVIDEND"] + amount: str + quantity: float = 0.0 + price: float = 0.0 + fee: float = 0.0 + tax: str = "0" executed_at: datetime | None = None note: str = "" recorded_at: datetime | None = None @@ -238,7 +259,7 @@ class ManualLedgerResponse(BaseModel): class ManualLedgerHistoryResponse(BaseModel): - items: list[ManualLedgerResponse] + items: list[ManualLedgerResponse | DividendManualLedgerResponse] total: int @@ -254,10 +275,35 @@ class LedgerHistoryItem(BaseModel): order_id: str | None = None execution_id: str | None = None quantity: float | None = None - amount: float | Decimal | None = None + amount: float | None = None price: float | None = None fee_amount: float | None = None - tax_amount: float | Decimal | None = None + tax_amount: float | None = None + note: str | None = None + cash_balance_after: float | None = None + asset_quantity_after: float | None = None + profit_buffer_after: float | None = None + total_value_after: float | None = None + target_ledger_entry_id: int | None = None + confirmation_evidence: str | None = None + + +class DividendLedgerHistoryItem(BaseModel): + """Source-neutral, owner-visible ledger fact for GET /ledger.""" + + ledger_entry_id: int + portfolio_id: str + source: str + action: Literal["DIVIDEND"] + recorded_at: datetime + executed_at: datetime | None = None + order_id: str | None = None + execution_id: str | None = None + quantity: float | None = None + amount: str + price: float | None = None + fee_amount: float | None = None + tax_amount: str | None = None note: str | None = None cash_balance_after: float | None = None asset_quantity_after: float | None = None @@ -268,7 +314,7 @@ class LedgerHistoryItem(BaseModel): class LedgerHistoryResponse(BaseModel): - items: list[LedgerHistoryItem] + items: list[LedgerHistoryItem | DividendLedgerHistoryItem] next_cursor: str | None = None diff --git a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py index 161f205..b6073ea 100644 --- a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py +++ b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py @@ -292,7 +292,7 @@ def test_dividend_precision_and_reversal_round_trip_on_real_postgres( assert dividend.price == 0.00000001 assert dividend.tax_amount == 0.00000001 - assert str(dividend.tax_amount_decimal) == "0.00000001" + assert dividend.tax_amount_decimal == Decimal("0.00000001") assert dividend.profit_buffer_after == 0.00000001 with repo._connect().cursor() as cur: cur.execute( @@ -330,7 +330,7 @@ def test_dividend_tax_omitted_persists_zero_on_real_postgres( note="tax-omitted", ) assert dividend.tax_amount == 0.0 - assert str(dividend.tax_amount_decimal) == "0E-8" + assert dividend.tax_amount_decimal == Decimal("0.00000000") with repo._connect().cursor() as cur: cur.execute("SELECT tax_amount::text FROM portfolio_ledger WHERE ledger_entry_id = %s", (dividend.ledger_entry_id,)) assert cur.fetchone()[0] == "0.00000000" @@ -431,7 +431,7 @@ def test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot note="large-tax-metadata", ) assert dividend.tax_amount == 50000000.0 - assert str(dividend.tax_amount_decimal) == "50000000.00000000" + assert dividend.tax_amount_decimal == Decimal("50000000.00000000") repo.close() @@ -458,8 +458,8 @@ def test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgre executed_at=datetime(2026, 9, 2, 0, 0, 0, tzinfo=UTC), note="alias-tax-2", ) - assert str(first.tax_amount_decimal) == "90000000.00000001" - assert str(second.tax_amount_decimal) == "90000000.00000002" + assert first.tax_amount_decimal == Decimal("90000000.00000001") + assert second.tax_amount_decimal == Decimal("90000000.00000002") repo.close() diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index 53653cd..ace8ca0 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -981,9 +981,12 @@ def test_unified_ledger_openapi_contains_nullable_amount() -> None: response = client.get("/openapi.json") assert response.status_code == 200 schemas = response.json()["components"]["schemas"] - assert "amount" in schemas["LedgerHistoryItem"]["properties"] - amount_schema = schemas["ManualLedgerResponse"]["properties"]["amount"] - assert any(option.get("type") == "number" for option in amount_schema.get("anyOf", [])) + 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" def test_reversal_rejects_fee_tax_and_missing_target() -> None: