feat(api): add owner-scoped manual ledger endpoint

This commit is contained in:
2026-07-14 19:49:03 +09:00
parent 762cf11376
commit b3447f699e
11 changed files with 1022 additions and 16 deletions
+153
View File
@@ -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)
# ---------------------------------------------------------------------------