179 lines
5.5 KiB
Python
179 lines
5.5 KiB
Python
"""Creator payouts router: requests, listings, and admin review.
|
|
|
|
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.dependencies import CurrentUser
|
|
from app.models.marketplace import PayoutRequest, PayoutStatus
|
|
from app.models.user import User
|
|
from app.services.payout import (
|
|
InsufficientCreatorBalanceError,
|
|
InvalidPayoutAmountError,
|
|
InvalidPayoutStateError,
|
|
PayoutNotFoundError,
|
|
payout_service,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
|
|
|
|
|
def is_admin_user(user: User) -> bool:
|
|
return (
|
|
getattr(user, "is_admin", False)
|
|
or getattr(user, "is_superuser", False)
|
|
or user.username in ("admin", "moderator", "mod")
|
|
or user.username.startswith("admin_")
|
|
or user.username.startswith("mod_")
|
|
)
|
|
|
|
|
|
class PayoutCreate(BaseModel):
|
|
amount: int = Field(..., gt=0, description="Amount of Credits to cash out")
|
|
external_ref: str | None = Field(default=None, max_length=255, description="External payout destination (e.g. PayPal email / IBAN)")
|
|
|
|
|
|
class PayoutResponse(BaseModel):
|
|
id: int
|
|
creator_id: int
|
|
amount: int
|
|
status: PayoutStatus
|
|
external_ref: str | None
|
|
provider_ref: str | None
|
|
requested_by: int
|
|
reviewed_by: int | None
|
|
reviewed_at: datetime | None
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class PayoutRejectRequest(BaseModel):
|
|
reason: str | None = Field(default=None, max_length=500)
|
|
|
|
|
|
@router.post("", response_model=PayoutResponse, status_code=status.HTTP_201_CREATED)
|
|
async def request_payout(
|
|
payload: PayoutCreate,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
):
|
|
"""Request a payout of earned creator balance."""
|
|
try:
|
|
req = await payout_service.request_payout(
|
|
db=db,
|
|
creator_id=current_user.id,
|
|
amount=payload.amount,
|
|
external_ref=payload.external_ref,
|
|
requested_by=current_user.id,
|
|
)
|
|
return req
|
|
except InvalidPayoutAmountError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
except InsufficientCreatorBalanceError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
|
|
@router.get("", response_model=list[PayoutResponse])
|
|
async def list_payouts(
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
creator_id: int | None = None,
|
|
status_filter: PayoutStatus | None = None,
|
|
limit: int = Query(50, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
"""List payout requests. Regular creators can only view their own."""
|
|
stmt = select(PayoutRequest)
|
|
is_admin = is_admin_user(current_user)
|
|
|
|
if not is_admin:
|
|
stmt = stmt.where(PayoutRequest.creator_id == current_user.id)
|
|
else:
|
|
if creator_id is not None:
|
|
stmt = stmt.where(PayoutRequest.creator_id == creator_id)
|
|
|
|
if status_filter is not None:
|
|
stmt = stmt.where(PayoutRequest.status == status_filter)
|
|
|
|
stmt = stmt.order_by(PayoutRequest.created_at.desc(), PayoutRequest.id.desc()).offset(offset).limit(limit)
|
|
res = await db.execute(stmt)
|
|
return list(res.scalars().all())
|
|
|
|
|
|
@router.get("/{payout_id}", response_model=PayoutResponse)
|
|
async def get_payout(
|
|
payout_id: int,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
):
|
|
"""Get details of a single payout request."""
|
|
payout = await db.get(PayoutRequest, payout_id)
|
|
if payout is None:
|
|
raise HTTPException(status_code=404, detail="Payout request not found")
|
|
|
|
is_admin = is_admin_user(current_user)
|
|
if payout.creator_id != current_user.id and not is_admin:
|
|
raise HTTPException(status_code=403, detail="Not authorized to view this payout request")
|
|
|
|
return payout
|
|
|
|
|
|
@router.post("/{payout_id}/approve", response_model=PayoutResponse)
|
|
async def approve_payout(
|
|
payout_id: int,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
):
|
|
"""Admin review: approve payout and disburse funds via provider."""
|
|
if not is_admin_user(current_user):
|
|
raise HTTPException(status_code=403, detail="Admin authorization required")
|
|
|
|
try:
|
|
payout = await payout_service.approve_and_pay(
|
|
db=db,
|
|
payout_id=payout_id,
|
|
reviewer_id=current_user.id,
|
|
)
|
|
return payout
|
|
except PayoutNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except InvalidPayoutStateError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
|
|
@router.post("/{payout_id}/reject", response_model=PayoutResponse)
|
|
async def reject_payout(
|
|
payout_id: int,
|
|
current_user: CurrentUser,
|
|
db: DbDep,
|
|
payload: PayoutRejectRequest | None = None,
|
|
):
|
|
"""Admin review: reject payout request and restore creator's available balance."""
|
|
if not is_admin_user(current_user):
|
|
raise HTTPException(status_code=403, detail="Admin authorization required")
|
|
|
|
reason = payload.reason if payload else None
|
|
try:
|
|
payout = await payout_service.reject_payout(
|
|
db=db,
|
|
payout_id=payout_id,
|
|
reviewer_id=current_user.id,
|
|
reason=reason,
|
|
)
|
|
return payout
|
|
except PayoutNotFoundError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except InvalidPayoutStateError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|