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:
co-authored by
genichin
parent
fde5b33f05
commit
d3601d32dc
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user