diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 3c57504..a8cfec7 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -1332,6 +1332,8 @@ def create_corporate_action(portfolio_id: str, payload: CorporateActionRequest, return CorporateActionResponse(**saved.__dict__) except ValueError as exc: code = str(exc) + if code == "portfolio not found": + raise HTTPException(status_code=404, detail="Portfolio not found") from exc raise HTTPException(status_code=409 if code.startswith(("IDEMPOTENCY", "CORPORATE_ACTION_")) else 400, detail=code) from exc finally: _close_repo(repo) diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index 09d5c43..0fd227c 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -290,6 +290,7 @@ def corporate_action_portfolio( """Record an auditable stock consolidation without a trade.""" from datetime import date from decimal import Decimal + import uuid from app.domain.corporate_actions import calculate_stock_consolidation, canonical_payload_hash from app.domain.models import CorporateAction if action_type != "stock_consolidation": @@ -312,7 +313,7 @@ def corporate_action_portfolio( payload = {"action_type": action_type, "effective_date": day.isoformat(), "quantity_before": str(quantity_before), "quantity_after": str(quantity_after), "evidence_ref": evidence_ref, "confirm_daemon_managed": False} digest = canonical_payload_hash(payload) calc = calculate_stock_consolidation(Decimal(str(quantity_before)), Decimal(str(quantity_after)), Decimal("0")) - action = CorporateAction("", portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user) + action = CorporateAction(str(uuid.uuid4()), portfolio_id, portfolio.asset_id, action_type, day, calc.quantity_before, calc.quantity_after, calc.average_price_before, calc.average_price_after, calc.total_cost_basis, Decimal(str(portfolio.cash_balance)), Decimal(str(portfolio.profit_buffer)), evidence_ref, idempotency_key, digest, 0, user) return repository.record_corporate_action(portfolio_id, action=action, idempotency_key=idempotency_key, payload_hash=digest) detail = _with_service_and_repo(ctx, _callback) render_output(ctx.obj.console, ctx.obj.output_format, detail) diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 12e27af..96cff58 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -1637,6 +1637,21 @@ class PostgresPortfolioRepository: row = cur.fetchone() if row is None: raise ValueError("portfolio not found") + cur.execute("SELECT corporate_action_id, portfolio_id, asset_id, action_type, effective_date, actor_user_id, evidence_ref, idempotency_key, payload_hash, quantity_before, quantity_after, average_price_before, average_price_after, total_cost_basis, cash_balance, profit_buffer, ledger_entry_id, recorded_at FROM portfolio_corporate_actions WHERE portfolio_id=%s AND idempotency_key=%s", (portfolio_id, idempotency_key)) + prior = cur.fetchone() + if prior: + if prior[8] != payload_hash: + raise ValueError("IDEMPOTENCY_KEY_REUSE") + (action.corporate_action_id, action.portfolio_id, action.asset_id, action.action_type, + action.effective_date, action.actor_user_id, action.evidence_ref, + action.idempotency_key, action.payload_hash, action.quantity_before, + action.quantity_after, action.average_price_before, action.average_price_after, + action.total_cost_basis, action.cash_balance, action.profit_buffer, + ledger_id, action.recorded_at) = prior + action.corporate_action_id = str(action.corporate_action_id) + action.ledger_entry_id = int(ledger_id) + action.idempotent_replay = True + return action if row[4] != "enabled": raise ValueError("CORPORATE_ACTION_PORTFOLIO_DISABLED") if row[3] != action.asset_id: @@ -1720,7 +1735,7 @@ class PostgresPortfolioRepository: boundaries (start of day / start of next day). """ self.ensure_schema() - clauses = ["portfolio_id = %s"] + clauses = ["portfolio_id = %s", "side IN ('buy','sell')"] params: list[object] = [portfolio_id] if date is not None: