feat: support manual dividend ledger entries
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
9d2c2aea5f
commit
ccde81b7a0
@@ -88,6 +88,9 @@ 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)
|
||||
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
|
||||
@@ -122,12 +125,64 @@ class _FakeRepo:
|
||||
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 abs(float(e.price) - amount) < 1e-9
|
||||
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(
|
||||
@@ -139,12 +194,13 @@ class _FakeRepo:
|
||||
price=price,
|
||||
fee_amount=fee,
|
||||
tax_amount=tax,
|
||||
executed_at=executed_at,
|
||||
recorded_at=datetime.now(timezone.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=kwargs.get("note") or "",
|
||||
note=note,
|
||||
source="manual",
|
||||
target_ledger_entry_id=target,
|
||||
confirmation_evidence=kwargs.get("confirmation_evidence"),
|
||||
@@ -402,6 +458,125 @@ def test_amount_actions_reject_fee_and_tax_fields() -> None:
|
||||
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.0
|
||||
assert body["tax"] == 10.0
|
||||
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_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_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_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_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["executed_at"] == "2026-09-01T00:00:00Z"
|
||||
assert item["tax_amount"] == 3.0
|
||||
assert item["note"] == "Q3"
|
||||
assert item["profit_buffer_after"] == created.json()["profit_buffer_after"]
|
||||
|
||||
|
||||
def test_reversal_rejects_fee_tax_and_missing_target() -> None:
|
||||
repo = _FakeRepo(_portfolio())
|
||||
client = _make_client(repo)
|
||||
|
||||
Reference in New Issue
Block a user