feat(api): add owner-scoped manual ledger endpoint
This commit is contained in:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user