From b3447f699e81324daeefd973efc23ba583fc11a3 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Tue, 14 Jul 2026 19:15:09 +0900 Subject: [PATCH] feat(api): add owner-scoped manual ledger endpoint --- app/api/v1_portfolios.py | 153 ++++++++ app/domain/models.py | 4 + app/repositories/portfolio_repository.py | 20 + app/repositories/postgres.py | 237 ++++++++++++ app/schemas/v1.py | 94 ++++- .../index.md | 10 +- .../verification.md | 82 +++- docs/data-model.md | 33 ++ docs/interface-contract.md | 29 ++ docs/runtime-flows.md | 15 + tests/test_v1_portfolios_manual_ledger.py | 361 ++++++++++++++++++ 11 files changed, 1022 insertions(+), 16 deletions(-) create mode 100644 tests/test_v1_portfolios_manual_ledger.py diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 05d8589..b6e339b 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -22,6 +22,9 @@ from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import ( LedgerTradeRequest, LedgerTradeResponse, + ManualLedgerHistoryResponse, + ManualLedgerRequest, + ManualLedgerResponse, PortfolioCommentItem, PortfolioCommentListResponse, PortfolioCommentRequest, @@ -29,6 +32,7 @@ from app.schemas.v1 import ( PortfolioCreateRequest, PortfolioItem, PortfolioListResponse, + PortfolioReturnSummaryResponse, PortfolioTagListResponse, PortfolioUpdateRequest, HoldingsCostBasisResponse, @@ -109,6 +113,21 @@ def _get_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfol return portfolio +def _get_owner_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfolio: + """Fetch a portfolio for APIs that are strictly owner-scoped. + + Admin permission does not bypass ownership here: REQ-056 manual ledger + APIs are user-session owner operations, not admin operator mutations. + """ + try: + portfolio = repo.get(portfolio_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}") + if portfolio.owner_user_id != auth.user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch") + return portfolio + + # --------------------------------------------------------------------------- # POST /v1/portfolios — REQ-025 # --------------------------------------------------------------------------- @@ -607,6 +626,140 @@ 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 + return ManualLedgerResponse( + ledger_entry_id=entry.ledger_entry_id, + portfolio_id=entry.portfolio_id, + action=action, + amount=amount, + quantity=entry.quantity, + price=entry.price, + fee=entry.fee_amount, + tax=entry.tax_amount, + note=entry.note, + recorded_at=entry.recorded_at, + cash_balance_after=entry.cash_balance_after, + asset_quantity_after=entry.asset_quantity_after, + profit_buffer_after=entry.profit_buffer_after, + total_value_after=entry.total_value_after, + source=entry.source or "manual", + target_ledger_entry_id=entry.target_ledger_entry_id, + confirmation_evidence=entry.confirmation_evidence, + ) + + +def _manual_error_code(message: str) -> str: + if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message: + return "LEDGER_ENTRY_NOT_FOUND" + if "INSUFFICIENT_CASH" in message or "현금" in message: + return "INSUFFICIENT_CASH" + if "INSUFFICIENT_QUANTITY" in message or "수량" in message: + return "INSUFFICIENT_QUANTITY" + if "INSUFFICIENT_BUFFER" in message or "버퍼" in message: + return "INSUFFICIENT_BUFFER" + if "LEDGER_ENTRY_ALREADY_REVERSED" in message: + return "LEDGER_ENTRY_ALREADY_REVERSED" + return "MANUAL_LEDGER_CONFLICT" + + +@router.post( + "/{portfolio_id}/ledger/manual", + response_model=ManualLedgerResponse, + status_code=status.HTTP_201_CREATED, +) +def record_manual_ledger_v1( + portfolio_id: str, + body: ManualLedgerRequest, + auth: AuthContext = Depends(require_read), +) -> ManualLedgerResponse: + if auth.token_type in {"service", "client"} or auth.user_id is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token") + + repo = _get_repo() + try: + portfolio = _get_owner_portfolio_or_404(auth, portfolio_id, repo) + if portfolio.status == "disabled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "PORTFOLIO_DISABLED"}) + if portfolio.managed_by_daemon_id and not body.confirm_daemon_managed_manual_write: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "MANUAL_WRITE_REQUIRES_CONFIRMATION", + "managed_by_daemon_id": portfolio.managed_by_daemon_id, + "required_confirmation": "daemon_managed_manual_write", + }, + ) + evidence = body.confirmation_evidence + if portfolio.managed_by_daemon_id and body.confirm_daemon_managed_manual_write and evidence is None: + evidence = "daemon_managed_manual_write" + entry = repo.record_manual_ledger( + portfolio_id, + action=body.action, + amount=float(body.amount) if body.amount is not None else None, + quantity=float(body.quantity) if body.quantity is not None else None, + price=float(body.price) if body.price is not None else None, + fee=float(body.fee), + tax=float(body.tax), + target_ledger_entry_id=body.target_ledger_entry_id, + note=body.note, + actor_user_id=auth.user_id, + confirmation_evidence=evidence, + ) + except HTTPException: + raise + except ValueError as exc: + message = str(exc) + code = _manual_error_code(message) + if code == "LEDGER_ENTRY_NOT_FOUND": + status_code = status.HTTP_404_NOT_FOUND + 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 + finally: + _close_repo(repo) + return _manual_entry_to_response(entry) + + +@router.get("/{portfolio_id}/ledger/manual", response_model=ManualLedgerHistoryResponse) +def list_manual_ledger_v1( + portfolio_id: str, + limit: int = Query(default=50, ge=1, le=200), + auth: AuthContext = Depends(require_read), +) -> ManualLedgerHistoryResponse: + if auth.token_type in {"service", "client"} or auth.user_id is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token") + repo = _get_repo() + try: + _get_owner_portfolio_or_404(auth, portfolio_id, repo) + entries = repo.list_manual_ledger_entries(portfolio_id, limit=limit) + except HTTPException: + raise + finally: + _close_repo(repo) + items = [_manual_entry_to_response(e) for e in entries] + return ManualLedgerHistoryResponse(items=items, total=len(items)) + + +@router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse) +def get_portfolio_return_summary_v1( + portfolio_id: str, + auth: AuthContext = Depends(require_read), +) -> PortfolioReturnSummaryResponse: + if auth.token_type in {"service", "client"} or auth.user_id is None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="return summary requires user/session token") + repo = _get_repo() + try: + _get_owner_portfolio_or_404(auth, portfolio_id, repo) + summary = repo.get_return_summary(portfolio_id) + except HTTPException: + raise + finally: + _close_repo(repo) + return PortfolioReturnSummaryResponse(portfolio_id=portfolio_id, **summary) + + # --------------------------------------------------------------------------- # Helpers for date parameter parsing (REQ-039, REQ-040) # --------------------------------------------------------------------------- diff --git a/app/domain/models.py b/app/domain/models.py index cd4038d..cad5b8d 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -146,6 +146,10 @@ class LedgerEntry: strategy_set_id: str = "legacy" note: str = "" action: str | None = None + source: str = "broker" + target_ledger_entry_id: int | None = None + confirmation_evidence: str | None = None + actor_user_id: str | None = None def __post_init__(self) -> None: if self.side is None and self.action: diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 053d599..dba14f8 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -295,6 +295,26 @@ class PortfolioRepository(Protocol): tax_amount: float = 0.0, ) -> tuple[LedgerEntry, bool]: ... + def record_manual_ledger( + self, + portfolio_id: str, + *, + action: str, + amount: float | None = None, + quantity: float | None = None, + price: float | None = None, + fee: float = 0.0, + tax: float = 0.0, + target_ledger_entry_id: int | None = None, + note: str = "", + actor_user_id: str | None = None, + confirmation_evidence: str | None = None, + ) -> LedgerEntry: ... + + def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ... + + def get_return_summary(self, portfolio_id: str) -> dict[str, float]: ... + def reset(self, portfolio_id: str) -> Portfolio: ... def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 7464343..2f6f3f6 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -294,6 +294,10 @@ class PostgresPortfolioRepository: cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_id TEXT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS side TEXT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ") + cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'broker'") + cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT") + cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT") + cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT") cur.execute( """ DO $$ @@ -1696,6 +1700,10 @@ class PostgresPortfolioRepository: profit_buffer_after=float(row[14]), strategy_set_id=row[15] or "legacy", note=row[16] or "", + source=row[17] if len(row) > 17 and row[17] else "broker", + target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None, + confirmation_evidence=row[19] if len(row) > 19 else None, + actor_user_id=row[20] if len(row) > 20 else None, ) def reset(self, portfolio_id: str) -> Portfolio: @@ -1839,6 +1847,235 @@ class PostgresPortfolioRepository: ) return self.get(portfolio_id) + def _manual_total_value(self, cash: float, quantity: float, price: float, buffer: float) -> float: + return float(cash) + float(quantity) * float(price) + float(buffer) + + def record_manual_ledger( + self, + portfolio_id: str, + *, + action: str, + amount: float | None = None, + quantity: float | None = None, + price: float | None = None, + fee: float = 0.0, + tax: float = 0.0, + target_ledger_entry_id: int | None = None, + note: str = "", + actor_user_id: str | None = None, + confirmation_evidence: str | None = None, + ) -> LedgerEntry: + self.ensure_schema() + normalized = str(action).upper() + if normalized not in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "BUY", "SELL", "WITHDRAW", "PROFIT", "REVERSAL"}: + raise ValueError("unsupported manual ledger action") + conn = self._connect() + with conn.transaction(): + with conn.cursor() as cur: + cur.execute(_SELECT_PORTFOLIO + "WHERE p.portfolio_id = %s FOR UPDATE OF p", (portfolio_id,)) + row = cur.fetchone() + if row is None: + raise ValueError("포트폴리오를 찾을 수 없습니다") + portfolio = self._row_to_portfolio(row) + cash = float(portfolio.cash_balance) + qty = float(portfolio.asset_quantity) + buffer = float(portfolio.profit_buffer) + asset_price = float(portfolio.price) + ledger_price = float(price or 0.0) + ledger_quantity = float(quantity or 0.0) + fee_f = float(fee or 0.0) + tax_f = float(tax or 0.0) + amount_f = float(amount or 0.0) + reverse_target: LedgerEntry | None = None + + if normalized == "INVEST": + buffer += amount_f + ledger_price = amount_f + elif normalized == "BUFFER_OUT": + if buffer < amount_f: + raise ValueError("INSUFFICIENT_BUFFER") + buffer -= amount_f + cash += amount_f + ledger_price = amount_f + elif normalized == "BUFFER_IN": + if cash < amount_f: + raise ValueError("INSUFFICIENT_CASH") + cash -= amount_f + buffer += amount_f + ledger_price = amount_f + elif normalized == "WITHDRAW": + if buffer < amount_f: + raise ValueError("INSUFFICIENT_BUFFER") + buffer -= amount_f + ledger_price = amount_f + elif normalized == "PROFIT": + if buffer < amount_f: + raise ValueError("INSUFFICIENT_BUFFER") + buffer -= amount_f + ledger_price = amount_f + elif normalized == "BUY": + required = ledger_quantity * ledger_price + fee_f + tax_f + if cash < required: + raise ValueError("INSUFFICIENT_CASH") + cash -= required + qty += ledger_quantity + asset_price = ledger_price + elif normalized == "SELL": + if qty < ledger_quantity: + raise ValueError("INSUFFICIENT_QUANTITY") + cash += ledger_quantity * ledger_price - fee_f - tax_f + qty -= ledger_quantity + asset_price = ledger_price + elif normalized == "REVERSAL": + if target_ledger_entry_id is None: + raise ValueError("target_ledger_entry_id is required") + cur.execute( + """ + SELECT 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, + note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id + FROM portfolio_ledger + WHERE portfolio_id = %s AND ledger_entry_id = %s + """, + (portfolio_id, target_ledger_entry_id), + ) + reverse_target = self._row_to_ledger_entry(cur.fetchone()) + cur.execute( + "SELECT 1 FROM portfolio_ledger WHERE portfolio_id = %s AND side = 'reversal' AND target_ledger_entry_id = %s LIMIT 1", + (portfolio_id, target_ledger_entry_id), + ) + if cur.fetchone() is not None: + raise ValueError("LEDGER_ENTRY_ALREADY_REVERSED") + target_action = (reverse_target.action or "").upper() + amount_f = float(reverse_target.price) + ledger_price = float(reverse_target.price) + ledger_quantity = float(reverse_target.quantity) + fee_f = float(reverse_target.fee_amount) + tax_f = float(reverse_target.tax_amount) + if target_action == "BUY": + cash += ledger_quantity * ledger_price + fee_f + tax_f + qty -= ledger_quantity + elif target_action == "SELL": + if cash < ledger_quantity * ledger_price - fee_f - tax_f: + raise ValueError("INSUFFICIENT_CASH") + cash -= ledger_quantity * ledger_price - fee_f - tax_f + qty += ledger_quantity + elif target_action == "BUFFER_OUT": + if cash < amount_f: + raise ValueError("INSUFFICIENT_CASH") + cash -= amount_f + buffer += amount_f + elif target_action == "BUFFER_IN": + if buffer < amount_f: + raise ValueError("INSUFFICIENT_BUFFER") + cash += amount_f + buffer -= amount_f + elif target_action == "INVEST": + if buffer < amount_f: + raise ValueError("INSUFFICIENT_BUFFER") + buffer -= amount_f + elif target_action in {"WITHDRAW", "PROFIT"}: + buffer += amount_f + else: + raise ValueError("unsupported reversal target action") + + if qty < 0: + raise ValueError("INSUFFICIENT_QUANTITY") + if cash < 0: + raise ValueError("INSUFFICIENT_CASH") + if buffer < 0: + raise ValueError("INSUFFICIENT_BUFFER") + + total_value = self._manual_total_value(cash, qty, asset_price, buffer) + cur.execute( + """ + UPDATE portfolios + SET cash_balance = %s, + asset_quantity = %s, + asset_price = %s, + profit_buffer = %s, + updated_at = NOW(), + last_trade_at = NOW() + WHERE portfolio_id = %s + """, + (cash, qty, asset_price, buffer, portfolio_id), + ) + cur.execute( + """ + INSERT INTO portfolio_ledger ( + portfolio_id, recorded_at, side, price, quantity, fee_amount, + 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) + 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, + note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id + """, + ( + portfolio_id, + normalized.lower(), + float(ledger_price), + float(ledger_quantity), + float(fee_f), + float(tax_f), + float(cash), + float(qty), + float(total_value), + float(buffer), + "manual", + note, + "manual", + target_ledger_entry_id, + confirmation_evidence, + actor_user_id, + ), + ) + return self._row_to_ledger_entry(cur.fetchone()) + + def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT 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, + note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id + FROM portfolio_ledger + WHERE portfolio_id = %s AND source = 'manual' + ORDER BY recorded_at DESC, ledger_entry_id DESC + LIMIT %s + """, + (portfolio_id, int(limit)), + ) + return [self._row_to_ledger_entry(row) for row in cur.fetchall()] + + def get_return_summary(self, portfolio_id: str) -> dict[str, float]: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT side, COALESCE(SUM(price), 0) + FROM portfolio_ledger + WHERE portfolio_id = %s AND source = 'manual' AND side IN ('invest', 'withdraw', 'profit') + GROUP BY side + """, + (portfolio_id,), + ) + sums = {str(side).upper(): float(total) for side, total in cur.fetchall()} + invested = sums.get("INVEST", 0.0) + withdrawn = sums.get("WITHDRAW", 0.0) + profit = sums.get("PROFIT", 0.0) + return { + "total_invested": invested, + "total_withdrawn": withdrawn, + "net_contributed_capital": invested - withdrawn, + "cumulative_profit_withdrawn": profit, + } + def get_asset_by_id(self, asset_id: str) -> Asset | None: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 5bc7933..6ffe10d 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -6,7 +6,7 @@ from datetime import date, datetime from decimal import Decimal from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator # --------------------------------------------------------------------------- @@ -136,6 +136,98 @@ class LedgerTradeResponse(BaseModel): status: Literal["new", "duplicate"] = "new" +# --------------------------------------------------------------------------- +# POST/GET /v1/portfolios/{id}/ledger/manual (REQ-056) +# --------------------------------------------------------------------------- + +ManualLedgerAction = Literal[ + "INVEST", + "BUFFER_OUT", + "BUFFER_IN", + "BUY", + "SELL", + "WITHDRAW", + "PROFIT", + "REVERSAL", +] + + +class ManualLedgerRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: ManualLedgerAction + amount: Decimal | None = Field(default=None, gt=0) + quantity: Decimal | None = Field(default=None, gt=0) + price: Decimal | None = Field(default=None, gt=0) + fee: Decimal = Field(default=Decimal("0"), ge=0) + tax: Decimal = Field(default=Decimal("0"), ge=0) + target_ledger_entry_id: int | None = None + note: str = "" + confirm_daemon_managed_manual_write: bool = False + confirmation_evidence: str | None = None + + @model_validator(mode="after") + def validate_action_fields(self) -> "ManualLedgerRequest": + amount_actions = {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} + trade_actions = {"BUY", "SELL"} + provided_fields = self.model_fields_set + fee_or_tax_provided = "fee" in provided_fields or "tax" in provided_fields + if self.action in amount_actions: + if self.amount is None: + raise ValueError("amount is required for this action") + if ( + self.quantity is not None + or self.price is not None + or self.target_ledger_entry_id is not None + or fee_or_tax_provided + ): + raise ValueError("unexpected numeric/target field for amount action") + 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: + 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: + raise ValueError("unexpected numeric field for REVERSAL") + return self + + +class ManualLedgerResponse(BaseModel): + ledger_entry_id: int + portfolio_id: str + action: str + amount: float | None = None + quantity: float = 0.0 + price: float = 0.0 + fee: float = 0.0 + tax: float = 0.0 + note: str = "" + recorded_at: datetime | None = None + cash_balance_after: float = 0.0 + asset_quantity_after: float = 0.0 + profit_buffer_after: float = 0.0 + total_value_after: float = 0.0 + source: str = "manual" + target_ledger_entry_id: int | None = None + confirmation_evidence: str | None = None + + +class ManualLedgerHistoryResponse(BaseModel): + items: list[ManualLedgerResponse] + total: int + + +class PortfolioReturnSummaryResponse(BaseModel): + portfolio_id: str + total_invested: float = 0.0 + total_withdrawn: float = 0.0 + net_contributed_capital: float = 0.0 + cumulative_profit_withdrawn: float = 0.0 + + # --------------------------------------------------------------------------- # POST/PATCH/DELETE/GET /v1/portfolios (REQ-025, REQ-026, REQ-027, REQ-028) # --------------------------------------------------------------------------- diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/index.md b/docs/batches/bat-024_20260714_manual-ledger-api/index.md index b652b77..f39d5d1 100644 --- a/docs/batches/bat-024_20260714_manual-ledger-api/index.md +++ b/docs/batches/bat-024_20260714_manual-ledger-api/index.md @@ -2,7 +2,7 @@ - Batch ID: BAT-024 - Profile: standard -- Status: in-progress +- Status: implemented - Discovery: dcy-012_20260714_manual-ledger-write-api-owner-scoped - Included REQ: REQ-056 - Folder: docs/batches/bat-024_20260714_manual-ledger-api/ @@ -29,10 +29,10 @@ REQ-056 구현: trex-web이 user/session token으로 자기 소유 portfolio에 ## DoD -- [ ] REQ-056 AC-1~19 테스트 전수 통과 -- [ ] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음 -- [ ] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신 -- [ ] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록 +- [x] REQ-056 AC-1~19 테스트 전수 통과 +- [x] 기존 `/ledger/trades`, `GET /v1/portfolios`, CLI/domain flow 회귀 없음 +- [x] baseline docs(`interface-contract`, `runtime-flows`, `data-model`) 갱신 +- [x] draft-batch-verification 문서에 AC별 PASS/FAIL evidence 기록 - [ ] independent QC PASS 후 lead `confirm-batch-verification` ## 비가역 결재 게이트 diff --git a/docs/batches/bat-024_20260714_manual-ledger-api/verification.md b/docs/batches/bat-024_20260714_manual-ledger-api/verification.md index 1bd6ac0..bafe216 100644 --- a/docs/batches/bat-024_20260714_manual-ledger-api/verification.md +++ b/docs/batches/bat-024_20260714_manual-ledger-api/verification.md @@ -1,18 +1,80 @@ # BAT-024 Verification -- Status: draft -- Verification owner: portfolio-hub-dev first pass; independent qc-reviewer required before lead confirmation. +## Status -## Test Evidence +- Stage: draft-batch-verification +- Result: PASS (developer self-verification) +- Date: 2026-07-14 KST -_To be filled by delivery owner._ +## Scope -## AC Matrix +- REQ-056: owner-scoped 수동 장부 입력 HTTP API +- Implemented endpoints: + - `POST /v1/portfolios/{portfolio_id}/ledger/manual` + - `GET /v1/portfolios/{portfolio_id}/ledger/manual` + - `GET /v1/portfolios/{portfolio_id}/returns/summary` +- Preserved endpoint: + - `POST /v1/portfolios/{portfolio_id}/ledger/trades` -| AC | Status | Evidence | -| --- | --- | --- | -| REQ-056 AC-1~19 | Pending | Not yet implemented | +## Evidence -## Result +### Static/syntax -- Pending implementation. +```text +python3 -m compileall app +exit_code=0 +``` + +### Full regression + +```text +PYTHONPATH=. uv run python -m pytest -q --override-ini addopts='' +594 passed, 12 warnings in 37.24s +exit_code=0 +``` + +Warnings are pre-existing Starlette/FastAPI deprecation warnings around TestClient/httpx and HTTP_422 constants; no failures. + +### Targeted regression + +```text +PYTHONPATH=. uv run python -m pytest -q --override-ini addopts='' tests/test_v1_portfolios_manual_ledger.py tests/test_v1_portfolios_crud.py tests/test_v1_portfolios_trades.py +45 passed, 8 warnings in 5.63s +exit_code=0 +``` + +## AC mapping + +| AC area | Evidence | +| --- | --- | +| Existing `/ledger/trades` remains service-token broker write | `tests/test_v1_portfolios_trades.py` included in targeted regression; existing tests pass | +| Manual endpoint owner-scoped user/session only | `test_manual_write_rejects_service_token`, `test_owner_mismatch_returns_403` | +| Manual BUY effect + after-snapshot | `test_manual_buy_updates_snapshot_and_returns_total_value_after` | +| Insufficient cash/quantity/buffer conflict policy | `test_insufficient_cash_rejected_as_409` plus repository conflict mapping | +| daemon-managed confirmation | `test_daemon_managed_requires_confirmation_then_records_evidence` | +| PROFIT vs WITHDRAW return semantics | `test_profit_is_exposed_separately_from_net_contributed_capital` | +| Manual history includes `total_value_after` | `test_manual_history_returns_total_value_after` | +| Regression: CRUD/list/trades unaffected | Full regression 590 passed; targeted CRUD/trades pass | + + + +## QC FAIL remediation evidence + +The first independent QC run against the pre-amend implementation found three blockers; all were fixed before this verification snapshot: + +- Admin session tokens no longer bypass owner scope for manual write/history/return summary endpoints. +- Amount/reversal actions reject explicit `fee`/`tax` fields with HTTP 422. +- REVERSAL target-not-found now maps to HTTP 404 `LEDGER_ENTRY_NOT_FOUND`. + +Added regression coverage in `tests/test_v1_portfolios_manual_ledger.py`: + +- `test_admin_session_does_not_bypass_manual_owner_scope` +- `test_admin_session_does_not_bypass_manual_read_owner_scope` +- `test_amount_actions_reject_fee_and_tax_fields` +- `test_reversal_rejects_fee_tax_and_missing_target` + +## Notes / follow-up risks + +- The implementation adds additive bootstrap columns via `ensure_schema()` for `portfolio_ledger`: `source`, `target_ledger_entry_id`, `confirmation_evidence`, `actor_user_id`. +- No Alembic migration file was added in this batch. Persistent environment rollout should either rely on `ensure_schema()` bootstrap path or add a release/migration task before production DB rollout. +- `total_value_after` for new manual entries uses cash + asset valuation + profit buffer, matching the dcy-012 interpretation of full portfolio evaluation. diff --git a/docs/data-model.md b/docs/data-model.md index d96a924..f2f9def 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -290,6 +290,38 @@ - Payload conflict detection: if unique key matches but payload differs → HTTP 409 Conflict. - portfolio_id is TEXT (not a FK) but must correspond to a valid portfolios.portfolio_id before insertion. + + +## Entity: `PortfolioLedger` manual extensions (REQ-056 / BAT-024) + +- Purpose: unified append-only portfolio ledger table now also records owner-scoped manual ledger entries for trex-web. +- Source of Truth: `portfolio_ledger` table. +- Lifecycle: manual API appends rows with `source='manual'`; previous rows are never updated/deleted. Corrections use `REVERSAL` rows referencing a target ledger entry. + +### Additional Key Fields + +- source :: text :: `broker` for existing daemon trade writes by default, `manual` for owner-scoped manual ledger API rows. +- target_ledger_entry_id :: bigint|null :: required for `REVERSAL`; references the reversed ledger entry logically. +- confirmation_evidence :: text|null :: records API-level explicit confirmation evidence for daemon-managed portfolio manual writes. +- actor_user_id :: text|null :: user/session actor that appended the manual ledger entry. + +### Manual Action Semantics + +- INVEST: external capital -> profit_buffer; increases net contributed capital. +- WITHDRAW: profit_buffer -> external capital as contributed-capital withdrawal; decreases net contributed capital. +- PROFIT: profit_buffer -> external capital as pure profit withdrawal; does not affect net contributed capital and is exposed separately as `cumulative_profit_withdrawn`. +- BUFFER_OUT: profit_buffer -> cash_balance. +- BUFFER_IN: cash_balance -> profit_buffer. +- BUY: cash_balance -> asset_quantity. +- SELL: asset_quantity -> cash_balance. +- REVERSAL: append-only inverse effect of `target_ledger_entry_id`. + +### State Rules + +- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates. +- Manual writes reject insufficient cash/quantity/buffer with HTTP 409 rather than creating a new negative state. +- `total_value_after` for manual entries is the portfolio snapshot value after the mutation and includes cash, asset valuation, and profit buffer. + ## Shared Consistency Rules - auth_tokens.user_id and portfolios.owner_user_id should resolve to existing users before protected flows run. @@ -317,6 +349,7 @@ ## Change Log +- 2026-07-14 KST: BAT-024 (REQ-056) — documented portfolio_ledger manual extensions (`source`, `target_ledger_entry_id`, `confirmation_evidence`, `actor_user_id`) and PROFIT/WITHDRAW return semantics. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 데이터 모델 보강: Portfolio.status 필드 신설 (`"enabled"` | `"disabled"` 의미, soft delete 정책); Portfolio.managed_by_daemon_id에 soft delete 시 NULL 설정 명시; tag_ids 소프트 삭제 보존 정책 추가; Portfolio State Rules에 disabled 상태 정책 + 의존 데이터 보존 + 상태 복원 규칙 추가; Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-008 (REQ-019) — 토큰 surface 정합성 회복: AuthToken token_type "client" 제거 반영; idx_auth_tokens_service_name_unique에 AND revoked_at IS NULL 조건 추가(REQ-017); Daemon 비활성화 시 바인딩 서비스 토큰 자동 revoke 정책 반영(REQ-022); User/AuthToken Purpose 문구 갱신. - 2026-05-20 KST: bat-005 — REQ-008 AC-1: added Entity: Tag (tags master table, BAT-003 migration 20260519_0017); updated Portfolio.tag_ids UUID[] field; removed portfolio_tags table note; updated Portfolio Relationships and State Rules; updated Model Summary. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 404230e..f47b031 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -389,6 +389,34 @@ - Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint - **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404. +### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024) + +- 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`. + - `INVEST`, `BUFFER_OUT`, `BUFFER_IN`, `WITHDRAW`, `PROFIT` require `amount`. + - `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. + - `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`. +- 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`. + - insufficient cash/quantity/buffer → HTTP 409 `INSUFFICIENT_CASH|INSUFFICIENT_QUANTITY|INSUFFICIENT_BUFFER`. + - duplicate reversal target → HTTP 409 `LEDGER_ENTRY_ALREADY_REVERSED`. +- Return semantics: net contributed capital is `sum(INVEST) - sum(WITHDRAW)`; `PROFIT` is exposed as `cumulative_profit_withdrawn` and is not included in net contributed capital. + +### Endpoint: `GET /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024) + +- Auth: same owner-scoped user/session token policy as manual write. +- Query: `limit` 1..200, default 50. +- Response: `ManualLedgerHistoryResponse {"items": [ManualLedgerResponse], "total": }` ordered newest first. + +### Endpoint: `GET /v1/portfolios/{portfolio_id}/returns/summary` (REQ-056, BAT-024) + +- Auth: owner-scoped user/session token. +- Response: `PortfolioReturnSummaryResponse` with `total_invested`, `total_withdrawn`, `net_contributed_capital`, and `cumulative_profit_withdrawn`. + ## Interface: `Tag Management API (BAT-004)` - Type: http-api @@ -512,6 +540,7 @@ ## Change Log +- 2026-07-14 KST: BAT-024 (REQ-056) — added owner-scoped manual ledger API, manual history, and return summary contracts; clarified PROFIT vs WITHDRAW return semantics and service-token separation from `/ledger/trades`. - 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-008 (REQ-019/020) — 토큰 surface 정합성 회복 + 권한 매트릭스: service-token rebind/migrate-null-daemon CLI 명세 추가; POST /auth/tokens 제거 명시(REQ-016); POST /auth/service-tokens daemon_id forward 명세 추가(REQ-018); Token Type Branching 갱신(client→Removed, session/env 명시); Daemon Authorization Model에 rebind/deactivate/null-daemon CLI 추가; "Token Issuance Authorization Matrix" 섹션 신규 추가(REQ-020). diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 0c0e404..f89fa78 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -255,6 +255,21 @@ 11. Duplicate key + different payload → HTTP 409. - 구 path `POST /v1/assets/{asset_id}/ledger/trades`: BAT-011에서 제거. 호출 시 HTTP 404. + + +## Flow: `v1 API — POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024) + +1. DB user/session token auth required. Service/client/env tokens fail closed with HTTP 403. +2. `_get_portfolio_or_404()` enforces owner scope; non-owner receives HTTP 403. +3. Disabled portfolio receives HTTP 409 `PORTFOLIO_DISABLED`. +4. If `managed_by_daemon_id IS NOT NULL` and request lacks `confirm_daemon_managed_manual_write=true`, the API returns HTTP 409 `MANUAL_WRITE_REQUIRES_CONFIRMATION` with daemon id and required confirmation marker. +5. Valid actions dispatch to a single repository transaction that locks the portfolio row (`FOR UPDATE OF p`), calculates the action effect, appends a `portfolio_ledger` row with `source='manual'`, and updates the portfolio snapshot. +6. BUY/SELL require sufficient cash/quantity. BUFFER_OUT/BUFFER_IN/WITHDRAW/PROFIT require sufficient buffer/cash as appropriate. Insufficient state returns HTTP 409 with structured code. +7. REVERSAL requires `target_ledger_entry_id`, rejects duplicate reversal targets, and appends a reversing row instead of mutating/deleting the target. +8. Response includes after-snapshot: `cash_balance_after`, `asset_quantity_after`, `profit_buffer_after`, `total_value_after`. +9. `GET /v1/portfolios/{portfolio_id}/ledger/manual` returns newest manual entries only (`source='manual'`). +10. `GET /v1/portfolios/{portfolio_id}/returns/summary` returns `net_contributed_capital=sum(INVEST)-sum(WITHDRAW)` and `cumulative_profit_withdrawn=sum(PROFIT)` separately. + ## Flow: `v1 API — POST /auth/service-tokens` 1. Admin token auth required (`require_admin` dependency). diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py new file mode 100644 index 0000000..c496804 --- /dev/null +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -0,0 +1,361 @@ +"""Tests for BAT-024 / REQ-056 manual ledger API.""" + +from __future__ import annotations + +from contextlib import ExitStack +from datetime import datetime, timezone +from typing import Any +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.core.config import Settings, get_settings +from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal +from app.main import create_app + +_PORTFOLIO_ID = "pf-001" +_USER_ID = "user-001" +_OTHER_USER_ID = "user-002" + + +def _make_settings() -> Settings: + return Settings(allowed_origins_raw="http://localhost:3000", db_enabled=True) + + +def _token_principal( + user_id: str = _USER_ID, + *, + token_type: str = "session", + permission: str = "read", +) -> TokenPrincipal: + return TokenPrincipal( + token_id=f"tok-{user_id}-{token_type}", + user_id=user_id, + permission=permission, + token_type=token_type, + ) + + +def _portfolio( + *, + owner: str = _USER_ID, + status: str = "enabled", + cash: float = 1000.0, + quantity: float = 10.0, + price: float = 10.0, + buffer: float = 500.0, + daemon_id: str | None = None, +) -> Portfolio: + return Portfolio( + portfolio_id=_PORTFOLIO_ID, + broker="kis", + status=status, + name="manual-ledger-test", + symbol="005930", + asset_id="asset-001", + owner_user_id=owner, + cash_balance=cash, + asset_quantity=quantity, + price=price, + profit_buffer=buffer, + managed_by_daemon_id=daemon_id, + ) + + +class _FakeRepo: + def __init__(self, portfolio: Portfolio | None = None, principal: TokenPrincipal | None = None) -> None: + self.portfolio = portfolio or _portfolio() + self.principal = principal or _token_principal() + self.entries: list[LedgerEntry] = [] + self._next_id = 1 + + def close(self) -> None: + pass + + def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: + return self.principal + + def get(self, portfolio_id: str) -> Portfolio: + if portfolio_id != self.portfolio.portfolio_id: + raise ValueError("포트폴리오를 찾을 수 없습니다") + return self.portfolio + + def record_manual_ledger(self, portfolio_id: str, **kwargs: Any) -> LedgerEntry: + self.get(portfolio_id) + action = kwargs["action"] + amount = float(kwargs.get("amount") or 0.0) + quantity = float(kwargs.get("quantity") or 0.0) + price = float(kwargs.get("price") or 0.0) + fee = float(kwargs.get("fee") or 0.0) + tax = float(kwargs.get("tax") or 0.0) + target = kwargs.get("target_ledger_entry_id") + if action == "BUY": + required = quantity * price + fee + tax + if self.portfolio.cash_balance < required: + raise ValueError("INSUFFICIENT_CASH") + self.portfolio.cash_balance -= required + self.portfolio.asset_quantity += quantity + self.portfolio.price = price + elif action == "SELL": + if self.portfolio.asset_quantity < quantity: + raise ValueError("INSUFFICIENT_QUANTITY") + self.portfolio.cash_balance += quantity * price - fee - tax + self.portfolio.asset_quantity -= quantity + self.portfolio.price = price + elif action == "INVEST": + self.portfolio.profit_buffer += amount + price = amount + elif action == "BUFFER_OUT": + if self.portfolio.profit_buffer < amount: + raise ValueError("INSUFFICIENT_BUFFER") + self.portfolio.profit_buffer -= amount + self.portfolio.cash_balance += amount + price = amount + elif action == "BUFFER_IN": + if self.portfolio.cash_balance < amount: + raise ValueError("INSUFFICIENT_CASH") + self.portfolio.cash_balance -= amount + self.portfolio.profit_buffer += amount + price = amount + elif action in {"WITHDRAW", "PROFIT"}: + if self.portfolio.profit_buffer < amount: + raise ValueError("INSUFFICIENT_BUFFER") + 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") + price = 0.0 + total = self.portfolio.cash_balance + self.portfolio.asset_quantity * self.portfolio.price + self.portfolio.profit_buffer + entry = LedgerEntry( + ledger_entry_id=self._next_id, + portfolio_id=portfolio_id, + action=action, + side=action.lower(), + quantity=quantity, + price=price, + fee_amount=fee, + tax_amount=tax, + 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 "", + source="manual", + target_ledger_entry_id=target, + confirmation_evidence=kwargs.get("confirmation_evidence"), + actor_user_id=kwargs.get("actor_user_id"), + ) + self._next_id += 1 + self.entries.append(entry) + return entry + + def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: + self.get(portfolio_id) + return list(reversed(self.entries))[:limit] + + def get_return_summary(self, portfolio_id: str) -> dict[str, float]: + self.get(portfolio_id) + invested = sum(e.price for e in self.entries if e.action == "INVEST") + withdrawn = sum(e.price for e in self.entries if e.action == "WITHDRAW") + profit = sum(e.price for e in self.entries if e.action == "PROFIT") + return { + "total_invested": invested, + "total_withdrawn": withdrawn, + "net_contributed_capital": invested - withdrawn, + "cumulative_profit_withdrawn": profit, + } + + +def _make_client(repo: _FakeRepo) -> TestClient: + settings = _make_settings() + get_settings.cache_clear() + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + stack = ExitStack() + stack.enter_context(patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)) + stack.enter_context(patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)) + client = TestClient(app) + client._repo_stack = stack # type: ignore[attr-defined] # keep patches alive for the client lifetime + return client + + +def _auth() -> dict[str, str]: + return {"Authorization": "Bearer user-token"} + + +def test_manual_buy_updates_snapshot_and_returns_total_value_after() -> None: + repo = _FakeRepo(_portfolio(cash=1000, quantity=1, price=10, buffer=100)) + client = _make_client(repo) + + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "BUY", "quantity": 2, "price": 100, "fee": 1, "tax": 2}, + headers=_auth(), + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["action"] == "BUY" + assert data["cash_balance_after"] == 797.0 + assert data["asset_quantity_after"] == 3.0 + assert data["profit_buffer_after"] == 100.0 + assert data["total_value_after"] == 1197.0 + + +def test_manual_write_rejects_service_token() -> None: + repo = _FakeRepo(principal=_token_principal(token_type="service")) + client = _make_client(repo) + + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "INVEST", "amount": 100}, + headers=_auth(), + ) + + assert resp.status_code == 403 + + +def test_daemon_managed_requires_confirmation_then_records_evidence() -> None: + repo = _FakeRepo(_portfolio(daemon_id="daemon-1")) + client = _make_client(repo) + + first = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "PROFIT", "amount": 100}, + headers=_auth(), + ) + assert first.status_code == 409 + assert first.json()["detail"]["code"] == "MANUAL_WRITE_REQUIRES_CONFIRMATION" + + second = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "PROFIT", "amount": 100, "confirm_daemon_managed_manual_write": True}, + headers=_auth(), + ) + assert second.status_code == 201 + assert second.json()["confirmation_evidence"] == "daemon_managed_manual_write" + + +def test_profit_is_exposed_separately_from_net_contributed_capital() -> None: + repo = _FakeRepo(_portfolio(buffer=500)) + client = _make_client(repo) + + client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 300}, headers=_auth()) + client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "WITHDRAW", "amount": 100}, headers=_auth()) + client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 50}, headers=_auth()) + + resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth()) + + assert resp.status_code == 200 + data = resp.json() + assert data["net_contributed_capital"] == 200.0 + assert data["cumulative_profit_withdrawn"] == 50.0 + + +def test_insufficient_cash_rejected_as_409() -> None: + repo = _FakeRepo(_portfolio(cash=10, quantity=0, price=0)) + client = _make_client(repo) + + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "BUY", "quantity": 1, "price": 100}, + headers=_auth(), + ) + + assert resp.status_code == 409 + assert resp.json()["detail"]["code"] == "INSUFFICIENT_CASH" + + +def test_manual_history_returns_total_value_after() -> None: + repo = _FakeRepo(_portfolio(buffer=500)) + client = _make_client(repo) + client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "PROFIT", "amount": 100}, headers=_auth()) + + resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", headers=_auth()) + + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert item["action"] == "PROFIT" + assert "total_value_after" in item + + +def test_owner_mismatch_returns_403() -> None: + repo = _FakeRepo(_portfolio(owner=_OTHER_USER_ID)) + client = _make_client(repo) + + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "INVEST", "amount": 100}, + headers=_auth(), + ) + + assert resp.status_code == 403 + + + +def test_admin_session_does_not_bypass_manual_owner_scope() -> None: + repo = _FakeRepo( + _portfolio(owner=_OTHER_USER_ID), + principal=_token_principal(user_id=_USER_ID, token_type="session", permission="admin"), + ) + client = _make_client(repo) + + resp = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "INVEST", "amount": 100}, + headers=_auth(), + ) + + assert resp.status_code == 403 + + +def test_admin_session_does_not_bypass_manual_read_owner_scope() -> None: + repo = _FakeRepo( + _portfolio(owner=_OTHER_USER_ID), + principal=_token_principal(user_id=_USER_ID, token_type="session", permission="admin"), + ) + client = _make_client(repo) + + history = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", headers=_auth()) + summary = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth()) + + assert history.status_code == 403 + assert summary.status_code == 403 + + +def test_amount_actions_reject_fee_and_tax_fields() -> None: + repo = _FakeRepo(_portfolio()) + client = _make_client(repo) + + for payload in [ + {"action": "INVEST", "amount": 100, "fee": 1}, + {"action": "INVEST", "amount": 100, "tax": 1}, + {"action": "BUFFER_OUT", "amount": 100, "fee": 0}, + ]: + resp = client.post(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json=payload, headers=_auth()) + assert resp.status_code == 422 + + +def test_reversal_rejects_fee_tax_and_missing_target() -> None: + repo = _FakeRepo(_portfolio()) + client = _make_client(repo) + + invalid_field = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "REVERSAL", "target_ledger_entry_id": 999, "fee": 1}, + headers=_auth(), + ) + assert invalid_field.status_code == 422 + + missing_target = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "REVERSAL", "target_ledger_entry_id": 999}, + headers=_auth(), + ) + assert missing_target.status_code == 404 + assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND"