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)
# ---------------------------------------------------------------------------
+4
View File
@@ -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:
+20
View File
@@ -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: ...
+237
View File
@@ -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:
+93 -1
View File
@@ -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)
# ---------------------------------------------------------------------------
@@ -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`
## 비가역 결재 게이트
@@ -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.
+33
View File
@@ -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.
+29
View File
@@ -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": <int>}` 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).
+15
View File
@@ -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).
+361
View File
@@ -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"