Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
189 lines
5.3 KiB
Python
189 lines
5.3 KiB
Python
"""
|
|
Admin endpoints for Yield payouts (ledger).
|
|
|
|
Premium constraints:
|
|
- No placeholder payouts
|
|
- No currency mixing
|
|
- Idempotent generation per (user, currency, period)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import and_, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_user, get_db
|
|
from app.models.user import User
|
|
from app.models.yield_domain import YieldPayout, YieldTransaction
|
|
from app.services.telemetry import track_event
|
|
from app.services.yield_payouts import generate_payouts_for_period
|
|
|
|
|
|
router = APIRouter(prefix="/yield", tags=["yield-admin"])
|
|
|
|
|
|
class PayoutGenerateRequest(BaseModel):
|
|
period_start: datetime
|
|
period_end: datetime
|
|
|
|
|
|
class GeneratedPayout(BaseModel):
|
|
id: int
|
|
user_id: int
|
|
amount: Decimal
|
|
currency: str
|
|
period_start: datetime
|
|
period_end: datetime
|
|
transaction_count: int
|
|
status: str
|
|
created_at: datetime
|
|
|
|
|
|
class PayoutGenerateResponse(BaseModel):
|
|
created: list[GeneratedPayout]
|
|
skipped_existing: int = 0
|
|
|
|
|
|
class PayoutCompleteRequest(BaseModel):
|
|
payment_method: str | None = Field(default=None, max_length=50)
|
|
payment_reference: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
class PayoutCompleteResponse(BaseModel):
|
|
payout_id: int
|
|
transactions_marked_paid: int
|
|
completed_at: datetime
|
|
|
|
|
|
def _require_admin(current_user: User) -> None:
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
|
|
|
|
@router.post("/payouts/generate", response_model=PayoutGenerateResponse)
|
|
async def generate_payouts(
|
|
payload: PayoutGenerateRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Create YieldPayout rows for confirmed, unpaid transactions in the period.
|
|
|
|
This does NOT mark payouts as completed. It only assigns transactions to a payout via payout_id.
|
|
Completion is a separate step once payment is executed.
|
|
"""
|
|
_require_admin(current_user)
|
|
|
|
if payload.period_end <= payload.period_start:
|
|
raise HTTPException(status_code=400, detail="period_end must be after period_start")
|
|
|
|
created_count, skipped_existing = await generate_payouts_for_period(
|
|
db,
|
|
period_start=payload.period_start,
|
|
period_end=payload.period_end,
|
|
)
|
|
|
|
payouts = (
|
|
await db.execute(
|
|
select(YieldPayout)
|
|
.where(
|
|
and_(
|
|
YieldPayout.period_start == payload.period_start,
|
|
YieldPayout.period_end == payload.period_end,
|
|
)
|
|
)
|
|
.order_by(YieldPayout.created_at.desc())
|
|
)
|
|
).scalars().all()
|
|
|
|
created = [
|
|
GeneratedPayout(
|
|
id=p.id,
|
|
user_id=p.user_id,
|
|
amount=p.amount,
|
|
currency=p.currency,
|
|
period_start=p.period_start,
|
|
period_end=p.period_end,
|
|
transaction_count=p.transaction_count,
|
|
status=p.status,
|
|
created_at=p.created_at,
|
|
)
|
|
for p in payouts
|
|
]
|
|
|
|
# created_count is still returned implicitly via list length; we keep it for logs later
|
|
_ = created_count
|
|
return PayoutGenerateResponse(created=created, skipped_existing=skipped_existing)
|
|
|
|
|
|
@router.post("/payouts/{payout_id}/complete", response_model=PayoutCompleteResponse)
|
|
async def complete_payout(
|
|
payout_id: int,
|
|
payload: PayoutCompleteRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Mark a payout as completed and mark assigned transactions as paid.
|
|
"""
|
|
_require_admin(current_user)
|
|
|
|
payout = (
|
|
await db.execute(select(YieldPayout).where(YieldPayout.id == payout_id))
|
|
).scalar_one_or_none()
|
|
if not payout:
|
|
raise HTTPException(status_code=404, detail="Payout not found")
|
|
|
|
if payout.status == "completed":
|
|
raise HTTPException(status_code=400, detail="Payout already completed")
|
|
|
|
payout.status = "completed"
|
|
payout.completed_at = datetime.utcnow()
|
|
payout.payment_method = payload.payment_method
|
|
payout.payment_reference = payload.payment_reference
|
|
|
|
txs = (
|
|
await db.execute(
|
|
select(YieldTransaction).where(YieldTransaction.payout_id == payout.id)
|
|
)
|
|
).scalars().all()
|
|
|
|
marked = 0
|
|
for tx in txs:
|
|
if tx.status != "paid":
|
|
tx.status = "paid"
|
|
tx.paid_at = payout.completed_at
|
|
marked += 1
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="payout_paid",
|
|
request=None,
|
|
user_id=payout.user_id,
|
|
is_authenticated=None,
|
|
source="admin",
|
|
domain=None,
|
|
yield_domain_id=None,
|
|
metadata={
|
|
"payout_id": payout.id,
|
|
"currency": payout.currency,
|
|
"amount": float(payout.amount),
|
|
"transaction_count": payout.transaction_count,
|
|
"payment_method": payout.payment_method,
|
|
},
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
return PayoutCompleteResponse(
|
|
payout_id=payout.id,
|
|
transactions_marked_paid=marked,
|
|
completed_at=payout.completed_at,
|
|
)
|
|
|