From d3601d32dce821fb535c578610929df84915c02f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:00:23 +0000 Subject: [PATCH] fix(ledger): keep legacy amount wire type and preserve dividend tax decimals Co-authored-by: genichin <4282227+genichin@users.noreply.github.com> --- app/api/v1_portfolios.py | 26 ++++--- app/domain/models.py | 1 + app/repositories/postgres.py | 8 ++- app/schemas/v1.py | 8 +-- .../req-001_manual-cash-dividend.md | 2 +- docs/requirements/req-001_test-catalog.md | 3 + ...tfolio_ledger_manual_metadata_migration.py | 31 ++++++++ tests/test_v1_portfolios_manual_ledger.py | 71 ++++++++++++++++++- 8 files changed, 131 insertions(+), 19 deletions(-) diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index bcaedf8..150cfd5 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -688,11 +688,14 @@ def record_ledger_trade_v1( def _manual_entry_to_response(entry) -> ManualLedgerResponse: action = (entry.action or entry.side or "").upper() - amount = ( - (entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))) - if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} - else None - ) + 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)) return ManualLedgerResponse( ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id, @@ -701,7 +704,7 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse: quantity=entry.quantity, price=entry.price, fee=entry.fee_amount, - tax=entry.tax_amount, + tax=tax_value, executed_at=entry.executed_at, note=entry.note, recorded_at=entry.recorded_at, @@ -791,9 +794,14 @@ def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None) def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem: action = (entry.action or entry.side or "").upper() - amount = None - if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"}: + 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)) return LedgerHistoryItem( ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id, @@ -807,7 +815,7 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem: amount=amount, price=entry.price, fee_amount=entry.fee_amount, - tax_amount=entry.tax_amount, + tax_amount=tax_amount, note=entry.note, cash_balance_after=entry.cash_balance_after, asset_quantity_after=entry.asset_quantity_after, diff --git a/app/domain/models.py b/app/domain/models.py index a388e93..d0bd324 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -144,6 +144,7 @@ class LedgerEntry: side: str | None = None fee_amount: float = 0.0 tax_amount: float = 0.0 + tax_amount_decimal: Decimal | None = None executed_at: datetime | None = None recorded_at: datetime | None = None cash_balance_after: float = 0.0 diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index a2a129a..df1b726 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -703,6 +703,7 @@ class PostgresPortfolioRepository: @staticmethod def _row_to_ledger_entry_legacy(row: Any) -> LedgerEntry: + raw_tax = row[6] return LedgerEntry( ledger_entry_id=0, portfolio_id=row[0], @@ -713,7 +714,8 @@ class PostgresPortfolioRepository: price=float(row[3]), price_decimal=Decimal(str(row[3])) if row[3] is not None else None, fee_amount=float(row[5]), - tax_amount=float(row[6]), + tax_amount=float(raw_tax), + tax_amount_decimal=Decimal(str(raw_tax)) if raw_tax is not None else None, executed_at=row[1], recorded_at=row[1], cash_balance_after=float(row[7]), @@ -1866,6 +1868,7 @@ class PostgresPortfolioRepository: if row is None: raise ValueError("원장 항목을 찾을 수 없습니다") raw_price = row[6] + raw_tax = row[8] return LedgerEntry( ledger_entry_id=int(row[0]), portfolio_id=row[1], @@ -1876,7 +1879,8 @@ class PostgresPortfolioRepository: price=float(raw_price), price_decimal=Decimal(str(raw_price)) if raw_price is not None else None, fee_amount=float(row[7]), - tax_amount=float(row[8]), + tax_amount=float(raw_tax), + tax_amount_decimal=Decimal(str(raw_tax)) if raw_tax is not None else None, executed_at=row[9], recorded_at=row[10], cash_balance_after=float(row[11]), diff --git a/app/schemas/v1.py b/app/schemas/v1.py index daa7bfc..ca28a00 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -220,11 +220,11 @@ class ManualLedgerResponse(BaseModel): ledger_entry_id: int portfolio_id: str action: str - amount: Decimal | None = None + amount: float | Decimal | None = None quantity: float = 0.0 price: float = 0.0 fee: float = 0.0 - tax: float = 0.0 + tax: float | Decimal = 0.0 executed_at: datetime | None = None note: str = "" recorded_at: datetime | None = None @@ -254,10 +254,10 @@ class LedgerHistoryItem(BaseModel): order_id: str | None = None execution_id: str | None = None quantity: float | None = None - amount: Decimal | None = None + amount: float | Decimal | None = None price: float | None = None fee_amount: float | None = None - tax_amount: float | None = None + tax_amount: float | Decimal | None = None note: str | None = None cash_balance_after: float | None = None asset_quantity_after: float | None = None diff --git a/docs/requirements/req-001_manual-cash-dividend.md b/docs/requirements/req-001_manual-cash-dividend.md index 1730fa7..647635d 100644 --- a/docs/requirements/req-001_manual-cash-dividend.md +++ b/docs/requirements/req-001_manual-cash-dividend.md @@ -588,4 +588,4 @@ API가 수용한 최대 8자리 소수의 `amount`와 `tax`는 PostgreSQL 저장 - 상태: `approved` - 승인 주체: `genichin` -- 승인 근거: 이 대화에서 `REQ-001` 승인과 OQ-001 선택지 A를 명시적으로 확정함 +- 승인 근거: 이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함 diff --git a/docs/requirements/req-001_test-catalog.md b/docs/requirements/req-001_test-catalog.md index fb6b47c..9fe7fa1 100644 --- a/docs/requirements/req-001_test-catalog.md +++ b/docs/requirements/req-001_test-catalog.md @@ -81,9 +81,11 @@ Verifies: - REQ-001.07-AC01 Test reference: - tests/test_v1_portfolios_manual_ledger.py::test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_float +- tests/test_v1_portfolios_manual_ledger.py::test_dividend_tax_does_not_collapse_decimals_that_can_alias_in_float - tests/test_v1_portfolios_manual_ledger.py::test_dividend_with_different_decimal_amount_is_not_treated_as_duplicate - tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_precision_and_reversal_round_trip_on_real_postgres - tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_existing_buffer_plus_min_dividend_is_rejected_before_snapshot_divergence +- tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgres Automated: true Test ID: TC-REQ-001-011 @@ -92,6 +94,7 @@ Verifies: Test reference: - tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_false_is_accepted_for_non_dividend_actions - tests/test_v1_portfolios_manual_ledger.py::test_confirm_duplicate_true_is_rejected_for_non_dividend_actions +- tests/test_v1_portfolios_manual_ledger.py::test_unified_ledger_keeps_non_dividend_amount_as_number - tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_existing_high_precision_snapshot_still_allows_legacy_manual_actions - tests/test_bat025_portfolio_ledger_manual_metadata_migration.py::test_large_invest_and_large_dividend_tax_metadata_are_accepted_when_snapshot_is_safe Automated: true diff --git a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py index b589574..161f205 100644 --- a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py +++ b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py @@ -292,6 +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.profit_buffer_after == 0.00000001 with repo._connect().cursor() as cur: cur.execute( @@ -329,6 +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" 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" @@ -429,6 +431,35 @@ 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" + repo.close() + + +def test_dividend_tax_alias_prone_decimals_round_trip_distinctly_on_real_postgres( + isolated_settings: Settings, +) -> None: + upgrade_to_head(isolated_settings) + repo = _repo(isolated_settings) + portfolio_id = _seed_portfolio(repo, "tax-alias-roundtrip") + + first = repo.record_manual_ledger( + portfolio_id, + action="DIVIDEND", + amount=Decimal("1.00000000"), + tax=Decimal("90000000.00000001"), + executed_at=datetime(2026, 9, 1, 0, 0, 0, tzinfo=UTC), + note="alias-tax-1", + ) + second = repo.record_manual_ledger( + portfolio_id, + action="DIVIDEND", + amount=Decimal("1.00000000"), + tax=Decimal("90000000.00000002"), + 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" repo.close() diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index 344b562..53653cd 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -73,6 +73,7 @@ class _FakeRepo: 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 @@ -95,6 +96,7 @@ class _FakeRepo: 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 "" @@ -202,6 +204,7 @@ class _FakeRepo: 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, @@ -218,6 +221,7 @@ class _FakeRepo: 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]: @@ -507,7 +511,7 @@ def test_dividend_updates_only_profit_buffer_and_records_executed_at_and_tax() - body = resp.json() assert body["action"] == "DIVIDEND" assert body["amount"] == "120" - assert body["tax"] == 10.0 + 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 @@ -528,7 +532,7 @@ def test_dividend_tax_defaults_to_zero_when_omitted() -> None: assert resp.status_code == 201 body = resp.json() - assert body["tax"] == 0.0 + 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 @@ -726,6 +730,45 @@ def test_dividend_duplicate_check_does_not_collapse_decimals_that_can_alias_in_f 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) @@ -762,6 +805,7 @@ def test_confirm_duplicate_false_is_accepted_for_non_dividend_actions() -> None: headers=_auth(), ) assert invest.status_code == 201 + assert isinstance(invest.json()["amount"], float) buy = client.post( f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", @@ -771,6 +815,25 @@ def test_confirm_duplicate_false_is_accepted_for_non_dividend_actions() -> None: 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) @@ -906,7 +969,7 @@ def test_unified_ledger_includes_dividend_fields() -> None: assert item["amount"] == "33" assert item["price"] == 33.0 assert item["executed_at"] == "2026-09-01T00:00:00Z" - assert item["tax_amount"] == 3.0 + assert item["tax_amount"] == "3" assert item["note"] == "Q3" assert item["profit_buffer_after"] == created.json()["profit_buffer_after"] @@ -919,6 +982,8 @@ def test_unified_ledger_openapi_contains_nullable_amount() -> None: 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", [])) def test_reversal_rejects_fee_tax_and_missing_target() -> None: