diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 6f3ddbe..74e1e58 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -687,7 +687,7 @@ def record_ledger_trade_v1( def _manual_entry_to_response(entry) -> ManualLedgerResponse: action = (entry.action or entry.side or "").upper() - amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None + amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT", "DIVIDEND"} else None return ManualLedgerResponse( ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id, @@ -697,6 +697,7 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse: price=entry.price, fee=entry.fee_amount, tax=entry.tax_amount, + executed_at=entry.executed_at, note=entry.note, recorded_at=entry.recorded_at, cash_balance_after=entry.cash_balance_after, @@ -710,6 +711,8 @@ def _manual_entry_to_response(entry) -> ManualLedgerResponse: def _manual_error_code(message: str) -> str: + if "POTENTIAL_DUPLICATE_DIVIDEND" in message: + return "POTENTIAL_DUPLICATE_DIVIDEND" if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message: return "LEDGER_ENTRY_NOT_FOUND" if "INSUFFICIENT_CASH" in message or "현금" in message: @@ -723,6 +726,16 @@ def _manual_error_code(message: str) -> str: return "MANUAL_LEDGER_CONFLICT" +def _extract_duplicate_dividend_entry_id(message: str) -> int | None: + prefix = "POTENTIAL_DUPLICATE_DIVIDEND:" + if not message.startswith(prefix): + return None + try: + return int(message.removeprefix(prefix)) + except ValueError: + return None + + def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str: payload = { "portfolio_id": portfolio_id, @@ -817,10 +830,21 @@ def record_manual_ledger_v1( price=float(body.price) if body.price is not None else None, fee=float(body.fee), tax=float(body.tax), + executed_at=( + datetime( + body.executed_at.year, + body.executed_at.month, + body.executed_at.day, + tzinfo=timezone.utc, + ) + if body.executed_at is not None + else None + ), target_ledger_entry_id=body.target_ledger_entry_id, note=body.note, actor_user_id=auth.user_id, confirmation_evidence=evidence, + confirm_duplicate=body.confirm_duplicate, ) except HTTPException: raise @@ -829,9 +853,18 @@ def record_manual_ledger_v1( code = _manual_error_code(message) if code == "LEDGER_ENTRY_NOT_FOUND": status_code = status.HTTP_404_NOT_FOUND + detail: str | dict[str, object] = {"code": code, "message": message} + elif code == "POTENTIAL_DUPLICATE_DIVIDEND": + status_code = status.HTTP_409_CONFLICT + detail = { + "code": code, + "message": "Potential duplicate dividend entry detected", + "matched_ledger_entry_id": _extract_duplicate_dividend_entry_id(message), + } else: status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY - raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) from exc + detail = {"code": code, "message": message} + raise HTTPException(status_code=status_code, detail=detail) from exc finally: _close_repo(repo) return _manual_entry_to_response(entry) diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index f1f66b8..c0eb9d4 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -325,10 +325,12 @@ class PortfolioRepository(Protocol): price: float | None = None, fee: float = 0.0, tax: float = 0.0, + executed_at: datetime | None = None, target_ledger_entry_id: int | None = None, note: str = "", actor_user_id: str | None = None, confirmation_evidence: str | None = None, + confirm_duplicate: bool = False, ) -> LedgerEntry: ... def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ... @@ -362,4 +364,3 @@ class PortfolioRepository(Protocol): def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ... def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ... - diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index f53d0f2..c12c945 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -2017,14 +2017,16 @@ class PostgresPortfolioRepository: price: float | None = None, fee: float = 0.0, tax: float = 0.0, + executed_at: datetime | None = None, target_ledger_entry_id: int | None = None, note: str = "", actor_user_id: str | None = None, confirmation_evidence: str | None = None, + confirm_duplicate: bool = False, ) -> LedgerEntry: self.ensure_schema() normalized = str(action).upper() - if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "REVERSAL"}: + if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "DIVIDEND", "REVERSAL"}: raise ValueError("unsupported manual ledger action") conn = self._connect() with conn.transaction(): @@ -2043,6 +2045,9 @@ class PostgresPortfolioRepository: fee_f = float(fee or 0.0) tax_f = float(tax or 0.0) amount_f = float(amount or 0.0) + ledger_executed_at = executed_at or datetime.now(timezone.utc) + if ledger_executed_at.tzinfo is None: + ledger_executed_at = ledger_executed_at.replace(tzinfo=timezone.utc) reverse_target: LedgerEntry | None = None if normalized == "INVEST": @@ -2070,6 +2075,35 @@ class PostgresPortfolioRepository: raise ValueError("INSUFFICIENT_BUFFER") buffer -= amount_f ledger_price = amount_f + elif normalized == "DIVIDEND": + if not confirm_duplicate: + cur.execute( + """ + SELECT existing.ledger_entry_id + FROM portfolio_ledger AS existing + WHERE existing.portfolio_id = %s + AND existing.source = 'manual' + AND existing.side = 'dividend' + AND existing.price = %s::numeric(20,8) + AND existing.executed_at = %s + AND existing.note = %s + AND NOT EXISTS ( + SELECT 1 + FROM portfolio_ledger AS reversal + WHERE reversal.portfolio_id = existing.portfolio_id + AND reversal.side = 'reversal' + AND reversal.target_ledger_entry_id = existing.ledger_entry_id + ) + ORDER BY existing.ledger_entry_id DESC + LIMIT 1 + """, + (portfolio_id, amount_f, ledger_executed_at, note), + ) + duplicate_row = cur.fetchone() + if duplicate_row is not None: + raise ValueError(f"POTENTIAL_DUPLICATE_DIVIDEND:{int(duplicate_row[0])}") + buffer += amount_f + ledger_price = amount_f elif normalized == "BUY": required = ledger_quantity * ledger_price + fee_f + tax_f if cash < required: @@ -2128,7 +2162,7 @@ class PostgresPortfolioRepository: raise ValueError("INSUFFICIENT_BUFFER") cash += amount_f buffer -= amount_f - elif target_action == "INVEST": + elif target_action in {"INVEST", "DIVIDEND"}: if buffer < amount_f: raise ValueError("INSUFFICIENT_BUFFER") buffer -= amount_f @@ -2165,7 +2199,7 @@ class PostgresPortfolioRepository: tax_amount, cash_balance_after, asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, note, executed_at, source, target_ledger_entry_id, confirmation_evidence, actor_user_id - ) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), %s, %s, %s, %s) + ) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, @@ -2184,6 +2218,7 @@ class PostgresPortfolioRepository: float(buffer), "manual", note, + ledger_executed_at, "manual", target_ledger_entry_id, confirmation_evidence, diff --git a/app/schemas/v1.py b/app/schemas/v1.py index eaf8a6b..1b113b6 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -148,6 +148,7 @@ ManualLedgerAction = Literal[ "SELL", "WITHDRAW", "PROFIT", + "DIVIDEND", "REVERSAL", ] @@ -161,9 +162,11 @@ class ManualLedgerRequest(BaseModel): price: Decimal | None = Field(default=None, gt=0) fee: Decimal = Field(default=Decimal("0"), ge=0) tax: Decimal = Field(default=Decimal("0"), ge=0) + executed_at: date | None = None target_ledger_entry_id: int | None = None note: str = "" confirm_daemon_managed_manual_write: bool = False + confirm_duplicate: bool = False confirmation_evidence: str | None = None @model_validator(mode="after") @@ -172,6 +175,9 @@ class ManualLedgerRequest(BaseModel): trade_actions = {"BUY", "SELL"} provided_fields = self.model_fields_set fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields + fee_provided = "fee" in provided_fields + executed_at_provided = "executed_at" in provided_fields + confirm_duplicate_provided = "confirm_duplicate" in provided_fields and self.confirm_duplicate if self.action in amount_actions: if self.amount is None: raise ValueError("amount is required for this action") @@ -180,17 +186,33 @@ class ManualLedgerRequest(BaseModel): or self.price is not None or self.target_ledger_entry_id is not None or fee_or_tax_provided + or executed_at_provided + or confirm_duplicate_provided ): raise ValueError("unexpected numeric/target field for amount action") + elif self.action == "DIVIDEND": + if self.amount is None: + raise ValueError("amount is required for this action") + if self.executed_at is None: + raise ValueError("executed_at is required for DIVIDEND") + if self.quantity is not None or self.price is not None or self.target_ledger_entry_id is not None or fee_provided: + raise ValueError("unexpected numeric/target field for DIVIDEND") elif self.action in trade_actions: if self.quantity is None or self.price is None: raise ValueError("quantity and price are required for BUY/SELL") - if self.amount is not None or self.target_ledger_entry_id is not None: + if self.amount is not None or self.target_ledger_entry_id is not None or executed_at_provided or confirm_duplicate_provided: raise ValueError("unexpected amount/target field for BUY/SELL") elif self.action == "REVERSAL": if self.target_ledger_entry_id is None: raise ValueError("target_ledger_entry_id is required for REVERSAL") - if self.amount is not None or self.quantity is not None or self.price is not None or fee_or_tax_provided: + if ( + self.amount is not None + or self.quantity is not None + or self.price is not None + or fee_or_tax_provided + or executed_at_provided + or confirm_duplicate_provided + ): raise ValueError("unexpected numeric field for REVERSAL") return self @@ -204,6 +226,7 @@ class ManualLedgerResponse(BaseModel): price: float = 0.0 fee: float = 0.0 tax: float = 0.0 + executed_at: datetime | None = None note: str = "" recorded_at: datetime | None = None cash_balance_after: float = 0.0 diff --git a/docs/interface-contract.md b/docs/interface-contract.md index cab856f..4819b82 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -418,12 +418,14 @@ - Auth: DB user/session token with portfolio ownership. Service/client tokens and env tokens are HTTP 403 fail-closed. - Purpose: trex-web/user 수동 장부 append. Existing `/ledger/trades` remains daemon/service-token broker execution write only. -- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|REVERSAL`. +- Body: `ManualLedgerRequest` with `action` enum `INVEST|BUFFER_OUT|BUFFER_IN|BUY|SELL|WITHDRAW|PROFIT|DIVIDEND|REVERSAL`. - `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`. + - `DIVIDEND` requires `amount` and date-only `executed_at` (`YYYY-MM-DD`); `executed_at` is stored as UTC midnight (`00:00:00Z`). Optional non-negative `tax` is metadata only. - `BUY`, `SELL` require `quantity` and `price`; optional `fee`, `tax` default 0. - - `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported. + - Duplicate candidate `DIVIDEND` (same portfolio, same date, same decimal `amount`, same `note`, and original not already reversed): first POST returns HTTP 409 `POTENTIAL_DUPLICATE_DIVIDEND` with `matched_ledger_entry_id`; retry with `confirm_duplicate=true` records the entry. + - `REVERSAL` requires `target_ledger_entry_id`; direct mutation/delete of prior rows is not supported. Reversing `DIVIDEND` reduces `profit_buffer` by the original net amount under existing reversal conflict/insufficient-balance policy. - `QUANTITY_ADJUSTMENT` is not part of this API. -- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`. +- Response: HTTP 201 `ManualLedgerResponse` with `ledger_entry_id`, `portfolio_id`, `action`, `source=manual`, `executed_at`, `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`, `target_ledger_entry_id`, `confirmation_evidence`. - Validation/conflict policy: - disabled portfolio → HTTP 409 `PORTFOLIO_DISABLED`. - daemon-managed portfolio without `confirm_daemon_managed_manual_write=true` → HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION`. diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index 6356225..b42aaf1 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -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)