fix(ledger): keep legacy amount wire type and preserve dividend tax decimals

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-09 13:00:23 +00:00
committed by GitHub
co-authored by genichin
parent fde5b33f05
commit d3601d32dc
8 changed files with 131 additions and 19 deletions
+17 -9
View File
@@ -688,11 +688,14 @@ def record_ledger_trade_v1(
def _manual_entry_to_response(entry) -> ManualLedgerResponse: def _manual_entry_to_response(entry) -> ManualLedgerResponse:
action = (entry.action or entry.side or "").upper() action = (entry.action or entry.side or "").upper()
amount = ( amount: float | Decimal | None = None
(entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))) if action == "DIVIDEND":
if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} amount = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price))
else None 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( return ManualLedgerResponse(
ledger_entry_id=entry.ledger_entry_id, ledger_entry_id=entry.ledger_entry_id,
portfolio_id=entry.portfolio_id, portfolio_id=entry.portfolio_id,
@@ -701,7 +704,7 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse:
quantity=entry.quantity, quantity=entry.quantity,
price=entry.price, price=entry.price,
fee=entry.fee_amount, fee=entry.fee_amount,
tax=entry.tax_amount, tax=tax_value,
executed_at=entry.executed_at, executed_at=entry.executed_at,
note=entry.note, note=entry.note,
recorded_at=entry.recorded_at, 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: def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
action = (entry.action or entry.side or "").upper() action = (entry.action or entry.side or "").upper()
amount = None amount: float | Decimal | None = None
if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"}: if action == "DIVIDEND":
amount = entry.price_decimal if entry.price_decimal is not None else Decimal(str(entry.price)) 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( return LedgerHistoryItem(
ledger_entry_id=entry.ledger_entry_id, ledger_entry_id=entry.ledger_entry_id,
portfolio_id=entry.portfolio_id, portfolio_id=entry.portfolio_id,
@@ -807,7 +815,7 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
amount=amount, amount=amount,
price=entry.price, price=entry.price,
fee_amount=entry.fee_amount, fee_amount=entry.fee_amount,
tax_amount=entry.tax_amount, tax_amount=tax_amount,
note=entry.note, note=entry.note,
cash_balance_after=entry.cash_balance_after, cash_balance_after=entry.cash_balance_after,
asset_quantity_after=entry.asset_quantity_after, asset_quantity_after=entry.asset_quantity_after,
+1
View File
@@ -144,6 +144,7 @@ class LedgerEntry:
side: str | None = None side: str | None = None
fee_amount: float = 0.0 fee_amount: float = 0.0
tax_amount: float = 0.0 tax_amount: float = 0.0
tax_amount_decimal: Decimal | None = None
executed_at: datetime | None = None executed_at: datetime | None = None
recorded_at: datetime | None = None recorded_at: datetime | None = None
cash_balance_after: float = 0.0 cash_balance_after: float = 0.0
+6 -2
View File
@@ -703,6 +703,7 @@ class PostgresPortfolioRepository:
@staticmethod @staticmethod
def _row_to_ledger_entry_legacy(row: Any) -> LedgerEntry: def _row_to_ledger_entry_legacy(row: Any) -> LedgerEntry:
raw_tax = row[6]
return LedgerEntry( return LedgerEntry(
ledger_entry_id=0, ledger_entry_id=0,
portfolio_id=row[0], portfolio_id=row[0],
@@ -713,7 +714,8 @@ class PostgresPortfolioRepository:
price=float(row[3]), price=float(row[3]),
price_decimal=Decimal(str(row[3])) if row[3] is not None else None, price_decimal=Decimal(str(row[3])) if row[3] is not None else None,
fee_amount=float(row[5]), 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], executed_at=row[1],
recorded_at=row[1], recorded_at=row[1],
cash_balance_after=float(row[7]), cash_balance_after=float(row[7]),
@@ -1866,6 +1868,7 @@ class PostgresPortfolioRepository:
if row is None: if row is None:
raise ValueError("원장 항목을 찾을 수 없습니다") raise ValueError("원장 항목을 찾을 수 없습니다")
raw_price = row[6] raw_price = row[6]
raw_tax = row[8]
return LedgerEntry( return LedgerEntry(
ledger_entry_id=int(row[0]), ledger_entry_id=int(row[0]),
portfolio_id=row[1], portfolio_id=row[1],
@@ -1876,7 +1879,8 @@ class PostgresPortfolioRepository:
price=float(raw_price), price=float(raw_price),
price_decimal=Decimal(str(raw_price)) if raw_price is not None else None, price_decimal=Decimal(str(raw_price)) if raw_price is not None else None,
fee_amount=float(row[7]), 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], executed_at=row[9],
recorded_at=row[10], recorded_at=row[10],
cash_balance_after=float(row[11]), cash_balance_after=float(row[11]),
+4 -4
View File
@@ -220,11 +220,11 @@ class ManualLedgerResponse(BaseModel):
ledger_entry_id: int ledger_entry_id: int
portfolio_id: str portfolio_id: str
action: str action: str
amount: Decimal | None = None amount: float | Decimal | None = None
quantity: float = 0.0 quantity: float = 0.0
price: float = 0.0 price: float = 0.0
fee: float = 0.0 fee: float = 0.0
tax: float = 0.0 tax: float | Decimal = 0.0
executed_at: datetime | None = None executed_at: datetime | None = None
note: str = "" note: str = ""
recorded_at: datetime | None = None recorded_at: datetime | None = None
@@ -254,10 +254,10 @@ class LedgerHistoryItem(BaseModel):
order_id: str | None = None order_id: str | None = None
execution_id: str | None = None execution_id: str | None = None
quantity: float | None = None quantity: float | None = None
amount: Decimal | None = None amount: float | Decimal | None = None
price: float | None = None price: float | None = None
fee_amount: float | None = None fee_amount: float | None = None
tax_amount: float | None = None tax_amount: float | Decimal | None = None
note: str | None = None note: str | None = None
cash_balance_after: float | None = None cash_balance_after: float | None = None
asset_quantity_after: float | None = None asset_quantity_after: float | None = None
@@ -588,4 +588,4 @@ API가 수용한 최대 8자리 소수의 `amount`와 `tax`는 PostgreSQL 저장
- 상태: `approved` - 상태: `approved`
- 승인 주체: `genichin` - 승인 주체: `genichin`
- 승인 근거: 이 대화에서 `REQ-001` 승인과 OQ-001 선택지 A를 명시적으로 확정함 - 승인 근거: 이 대화에서 REQ-001 승인과 비-DIVIDEND confirm_duplicate=false 허용/true HTTP 422 결정을 명시적으로 확정함
@@ -81,9 +81,11 @@ Verifies:
- REQ-001.07-AC01 - REQ-001.07-AC01
Test reference: 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_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_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_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_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 Automated: true
Test ID: TC-REQ-001-011 Test ID: TC-REQ-001-011
@@ -92,6 +94,7 @@ Verifies:
Test reference: 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_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_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_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 - 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 Automated: true
@@ -292,6 +292,7 @@ def test_dividend_precision_and_reversal_round_trip_on_real_postgres(
assert dividend.price == 0.00000001 assert dividend.price == 0.00000001
assert dividend.tax_amount == 0.00000001 assert dividend.tax_amount == 0.00000001
assert str(dividend.tax_amount_decimal) == "0.00000001"
assert dividend.profit_buffer_after == 0.00000001 assert dividend.profit_buffer_after == 0.00000001
with repo._connect().cursor() as cur: with repo._connect().cursor() as cur:
cur.execute( cur.execute(
@@ -329,6 +330,7 @@ def test_dividend_tax_omitted_persists_zero_on_real_postgres(
note="tax-omitted", note="tax-omitted",
) )
assert dividend.tax_amount == 0.0 assert dividend.tax_amount == 0.0
assert str(dividend.tax_amount_decimal) == "0E-8"
with repo._connect().cursor() as cur: with repo._connect().cursor() as cur:
cur.execute("SELECT tax_amount::text FROM portfolio_ledger WHERE ledger_entry_id = %s", (dividend.ledger_entry_id,)) cur.execute("SELECT tax_amount::text FROM portfolio_ledger WHERE ledger_entry_id = %s", (dividend.ledger_entry_id,))
assert cur.fetchone()[0] == "0.00000000" 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", note="large-tax-metadata",
) )
assert dividend.tax_amount == 50000000.0 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() repo.close()
+68 -3
View File
@@ -73,6 +73,7 @@ class _FakeRepo:
self.entries: list[LedgerEntry] = [] self.entries: list[LedgerEntry] = []
self._next_id = 1 self._next_id = 1
self._dividend_amounts: dict[int, Decimal] = {} self._dividend_amounts: dict[int, Decimal] = {}
self._dividend_tax_amounts: dict[int, Decimal] = {}
def close(self) -> None: def close(self) -> None:
pass pass
@@ -95,6 +96,7 @@ class _FakeRepo:
price = float(kwargs.get("price") or 0.0) price = float(kwargs.get("price") or 0.0)
fee = float(kwargs.get("fee") or 0.0) fee = float(kwargs.get("fee") or 0.0)
tax = float(kwargs.get("tax") 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") executed_at = kwargs.get("executed_at")
confirm_duplicate = bool(kwargs.get("confirm_duplicate") or False) confirm_duplicate = bool(kwargs.get("confirm_duplicate") or False)
note = kwargs.get("note") or "" note = kwargs.get("note") or ""
@@ -202,6 +204,7 @@ class _FakeRepo:
price_decimal=amount_decimal if action == "DIVIDEND" else Decimal(str(price)), price_decimal=amount_decimal if action == "DIVIDEND" else Decimal(str(price)),
fee_amount=fee, fee_amount=fee,
tax_amount=tax, tax_amount=tax,
tax_amount_decimal=tax_decimal if action == "DIVIDEND" else Decimal(str(tax)),
executed_at=executed_at, executed_at=executed_at,
recorded_at=datetime.now(UTC), recorded_at=datetime.now(UTC),
cash_balance_after=self.portfolio.cash_balance, cash_balance_after=self.portfolio.cash_balance,
@@ -218,6 +221,7 @@ class _FakeRepo:
self.entries.append(entry) self.entries.append(entry)
if action == "DIVIDEND": if action == "DIVIDEND":
self._dividend_amounts[entry.ledger_entry_id] = amount_decimal self._dividend_amounts[entry.ledger_entry_id] = amount_decimal
self._dividend_tax_amounts[entry.ledger_entry_id] = tax_decimal
return entry return entry
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: 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() body = resp.json()
assert body["action"] == "DIVIDEND" assert body["action"] == "DIVIDEND"
assert body["amount"] == "120" assert body["amount"] == "120"
assert body["tax"] == 10.0 assert body["tax"] == "10"
assert body["executed_at"] == "2026-09-01T00:00:00Z" assert body["executed_at"] == "2026-09-01T00:00:00Z"
assert body["cash_balance_after"] == 1000.0 assert body["cash_balance_after"] == 1000.0
assert body["asset_quantity_after"] == 10.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 assert resp.status_code == 201
body = resp.json() body = resp.json()
assert body["tax"] == 0.0 assert body["tax"] == "0"
assert body["cash_balance_after"] == 1000.0 assert body["cash_balance_after"] == 1000.0
assert body["asset_quantity_after"] == 10.0 assert body["asset_quantity_after"] == 10.0
assert body["profit_buffer_after"] == 620.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 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: def test_reversed_dividend_is_excluded_from_future_duplicate_candidate_matching() -> None:
repo = _FakeRepo(_portfolio()) repo = _FakeRepo(_portfolio())
client = _make_client(repo) client = _make_client(repo)
@@ -762,6 +805,7 @@ def test_confirm_duplicate_false_is_accepted_for_non_dividend_actions() -> None:
headers=_auth(), headers=_auth(),
) )
assert invest.status_code == 201 assert invest.status_code == 201
assert isinstance(invest.json()["amount"], float)
buy = client.post( buy = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", 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 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: def test_confirm_duplicate_true_is_rejected_for_non_dividend_actions() -> None:
repo = _FakeRepo(_portfolio()) repo = _FakeRepo(_portfolio())
client = _make_client(repo) client = _make_client(repo)
@@ -906,7 +969,7 @@ def test_unified_ledger_includes_dividend_fields() -> None:
assert item["amount"] == "33" assert item["amount"] == "33"
assert item["price"] == 33.0 assert item["price"] == 33.0
assert item["executed_at"] == "2026-09-01T00:00:00Z" 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["note"] == "Q3"
assert item["profit_buffer_after"] == created.json()["profit_buffer_after"] 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 assert response.status_code == 200
schemas = response.json()["components"]["schemas"] schemas = response.json()["components"]["schemas"]
assert "amount" in schemas["LedgerHistoryItem"]["properties"] 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: def test_reversal_rejects_fee_tax_and_missing_target() -> None: