fix: make corporate actions atomic and recoverable
Publish verified PortfolioHub image / verify (push) Canceled after 0s
Publish verified PortfolioHub image / publish (push) Canceled after 0s

This commit is contained in:
2026-09-13 19:33:07 +09:00
parent 2b470c735f
commit c7d8235bde
16 changed files with 711 additions and 94 deletions
+73 -4
View File
@@ -7,7 +7,7 @@ import pytest
from fastapi.testclient import TestClient
from app.api import v1_portfolios
from app.domain.models import CorporateAction
from app.domain.models import CorporateAction, Portfolio
from app.main import create_app
from app.repositories.postgres import PostgresPortfolioRepository
@@ -20,6 +20,46 @@ class MissingPortfolioRepo:
pass
class ResumeCaptureRepo:
def __init__(self):
self.resume_partial = None
def get(self, portfolio_id):
return Portfolio(
portfolio_id=portfolio_id,
broker="kis",
status="enabled",
name="대교우B",
symbol="019685",
asset_id="asset-1",
owner_user_id="u1",
cash_balance=400060.6249,
asset_quantity=726,
price=643,
profit_buffer=0,
)
def record_corporate_action(
self,
portfolio_id,
*,
action,
idempotency_key,
payload_hash,
resume_partial=False,
):
self.resume_partial = resume_partial
action.average_price_before = Decimal("1086.23414594")
action.average_price_after = Decimal("2172.46829187")
action.total_cost_basis = Decimal("1577211.97990000")
action.ledger_entry_id = 7
action.recorded_at = datetime(2026, 9, 13, tzinfo=UTC)
return action
def close(self):
pass
def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypatch):
app = create_app()
repo = MissingPortfolioRepo()
@@ -35,6 +75,31 @@ def test_create_corporate_action_maps_postgres_missing_portfolio_to_404(monkeypa
assert response.json()["detail"] == "Portfolio not found"
def test_corporate_action_api_passes_explicit_resume_mode(monkeypatch):
app = create_app()
repo = ResumeCaptureRepo()
monkeypatch.setattr(v1_portfolios, "_get_repo", lambda: repo)
app.dependency_overrides[v1_portfolios.require_trade_write] = lambda: type(
"Auth", (), {"user_id": "u1", "permission": "trade_write"}
)()
response = TestClient(app).post(
"/v1/portfolios/pf-1/corporate-actions",
headers={"Idempotency-Key": "resume-1"},
json={
"effective_date": "2026-09-10",
"quantity_before": "1452",
"quantity_after": "726",
"evidence_ref": "KIS 019685 2026-09-10",
"resume_partial": True,
},
)
assert response.status_code == 201
assert response.json()["quantity_after"] == "726"
assert repo.resume_partial is True
def _action(asset_id="asset-1"):
return CorporateAction("ca-1", "pf-1", asset_id, "stock_consolidation", date(2026, 1, 1), Decimal(2), Decimal(1), Decimal(0), Decimal(0), Decimal(0), Decimal(10), Decimal(0), "ev", "idem", "hash", 0)
@@ -47,7 +112,12 @@ def _repo_for_row(asset_id):
conn.__enter__.return_value = conn
conn.cursor.return_value.__enter__.return_value = cur
repo._connect = MagicMock(return_value=conn)
cur.fetchone.side_effect = [(Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled"), None, None, None, (7, date(2026, 1, 1))]
cur.fetchone.side_effect = [
(Decimal(2), Decimal(10), Decimal(0), asset_id, "enabled", Decimal(643)),
None,
None,
(7, date(2026, 1, 1)),
]
cur.fetchall.return_value = [("buy", Decimal(2), Decimal(0), Decimal(0), Decimal(0), datetime(2025, 12, 31, tzinfo=UTC))]
return repo
@@ -74,8 +144,7 @@ def test_record_corporate_action_accepts_daekyo_rounding_residue():
conn.cursor.return_value.__enter__.return_value = cur
repo._connect = MagicMock(return_value=conn)
cur.fetchone.side_effect = [
(Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled"),
None,
(Decimal(1452), Decimal(10), Decimal(0), "asset-1", "enabled", Decimal(643)),
None,
None,
(7, datetime(2026, 9, 13, tzinfo=UTC)),