From cf17f29799de8b9567bce76c42ea15493a24da98 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 20:43:22 +0000 Subject: [PATCH] feat(economy): creator payout request models, migration, and API --- .../versions/0014_add_payout_requests.py | 68 ++++++ backend/app/main.py | 3 +- backend/app/models/marketplace.py | 40 ++++ backend/app/routers/payouts.py | 178 ++++++++++++++++ backend/app/services/payout.py | 199 ++++++++++++++++++ backend/tests/conftest.py | 2 +- backend/tests/test_payouts_api.py | 186 ++++++++++++++++ 7 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0014_add_payout_requests.py create mode 100644 backend/app/routers/payouts.py create mode 100644 backend/app/services/payout.py create mode 100644 backend/tests/test_payouts_api.py diff --git a/backend/alembic/versions/0014_add_payout_requests.py b/backend/alembic/versions/0014_add_payout_requests.py new file mode 100644 index 0000000..2e60b76 --- /dev/null +++ b/backend/alembic/versions/0014_add_payout_requests.py @@ -0,0 +1,68 @@ +"""add payout_requests table and payout_status enum + +Revision ID: 0014_add_payout_requests +Revises: 0013_add_marketplace_tables +Create Date: 2026-09-15 00:00:00.000000 + +docs/planning/economy.md US-5 (task f5b4684f2e65). +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0014_add_payout_requests" +down_revision: Union[str, None] = "0013_add_marketplace_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +PAYOUT_STATUSES = ("pending", "approved", "paid", "rejected") + + +def upgrade() -> None: + bind = op.get_bind() + is_pg = bind.dialect.name == "postgresql" + + if is_pg: + payout_status_enum = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False) + payout_status_enum.create(bind, checkfirst=True) + payout_status_col = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False) + else: + payout_status_col = sa.Enum(*PAYOUT_STATUSES, name="payout_status") + + op.create_table( + "payout_requests", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("creator_id", sa.Integer(), nullable=False), + sa.Column("amount", sa.BigInteger(), nullable=False), + sa.Column("status", payout_status_col, server_default="pending", nullable=False), + sa.Column("external_ref", sa.String(length=255), nullable=True), + sa.Column("provider_ref", sa.String(length=255), nullable=True), + sa.Column("requested_by", sa.Integer(), nullable=False), + sa.Column("reviewed_by", sa.Integer(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["requested_by"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"), + sa.CheckConstraint("amount > 0", name="check_payout_amount_positive"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_payout_requests_creator_id", "payout_requests", ["creator_id"], unique=False) + op.create_index("ix_payout_requests_requested_by", "payout_requests", ["requested_by"], unique=False) + op.create_index("ix_payout_requests_reviewed_by", "payout_requests", ["reviewed_by"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + is_pg = bind.dialect.name == "postgresql" + + op.drop_index("ix_payout_requests_reviewed_by", table_name="payout_requests") + op.drop_index("ix_payout_requests_requested_by", table_name="payout_requests") + op.drop_index("ix_payout_requests_creator_id", table_name="payout_requests") + op.drop_table("payout_requests") + + if is_pg: + op.execute("DROP TYPE IF EXISTS payout_status") diff --git a/backend/app/main.py b/backend/app/main.py index 699ffd9..e817b08 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from app.config import settings from app.routers import ( auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports, - chat_world, wallet, payments, marketplace, + chat_world, wallet, payments, marketplace, payouts, ) from app.gateway import ws @@ -61,6 +61,7 @@ app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"]) app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"]) app.include_router(payments.router, prefix="/api/payments", tags=["payments"]) app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"]) +app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"]) app.include_router(ws.router, prefix="/api", tags=["websocket"]) app.include_router(ws.router, prefix="/v1", tags=["websocket"]) diff --git a/backend/app/models/marketplace.py b/backend/app/models/marketplace.py index 69f9ac5..84e3db5 100644 --- a/backend/app/models/marketplace.py +++ b/backend/app/models/marketplace.py @@ -153,3 +153,43 @@ class CreatorBalance(Base): ) user = relationship("User", foreign_keys=[user_id]) + + +class PayoutStatus(str, enum.Enum): + pending = "pending" + approved = "approved" + paid = "paid" + rejected = "rejected" + + +class PayoutRequest(Base): + __tablename__ = "payout_requests" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + creator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + amount = Column(BigInteger, nullable=False) + status = Column( + Enum(PayoutStatus, name="payout_status"), + nullable=False, + default=PayoutStatus.pending, + server_default=PayoutStatus.pending.value, + ) + external_ref = Column(String(255), nullable=True) + provider_ref = Column(String(255), nullable=True) + requested_by = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + reviewed_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) + + __table_args__ = ( + CheckConstraint("amount > 0", name="check_payout_amount_positive"), + ) + + creator = relationship("User", foreign_keys=[creator_id]) + requester = relationship("User", foreign_keys=[requested_by]) + reviewer = relationship("User", foreign_keys=[reviewed_by]) diff --git a/backend/app/routers/payouts.py b/backend/app/routers/payouts.py new file mode 100644 index 0000000..b869403 --- /dev/null +++ b/backend/app/routers/payouts.py @@ -0,0 +1,178 @@ +"""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)) diff --git a/backend/app/services/payout.py b/backend/app/services/payout.py new file mode 100644 index 0000000..f731a6a --- /dev/null +++ b/backend/app/services/payout.py @@ -0,0 +1,199 @@ +"""Creator Payout Service: requests, review, pluggable PayoutProvider, and audit trail. + +docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import logging +from typing import Protocol +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus +from app.services.purchase import PurchaseService + +logger = logging.getLogger(__name__) + +MIN_PAYOUT_AMOUNT = 100 +MAX_PAYOUT_AMOUNT = 100_000 +LARGE_PAYOUT_THRESHOLD = 5000 + + +class InsufficientCreatorBalanceError(Exception): + """Raised when creator available balance is less than requested payout amount.""" + pass + + +class InvalidPayoutAmountError(ValueError): + """Raised when requested payout amount is out of valid bounds.""" + pass + + +class PayoutNotFoundError(Exception): + """Raised when payout request is not found.""" + pass + + +class InvalidPayoutStateError(Exception): + """Raised when payout request is in invalid status for the requested transition.""" + pass + + +class PayoutProvider(Protocol): + """Pluggable creator payout provider protocol (e.g. Stripe Connect, PayPal, Noop).""" + + async def pay( + self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None + ) -> tuple[bool, str]: + """Execute payout to creator. Returns (success, provider_ref).""" + ... + + +class NoopPayoutProvider: + """Deterministic no-op provider for development and testing.""" + + async def pay( + self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None + ) -> tuple[bool, str]: + ref = f"noop_payout_{uuid.uuid4().hex[:12]}" + logger.info( + f"[NoopPayoutProvider] Simulated payout of {amount} CR to user {user_id} (ref={ref})" + ) + return True, ref + + +default_payout_provider: PayoutProvider = NoopPayoutProvider() + + +class PayoutService: + def __init__(self, provider: PayoutProvider | None = None): + self.provider = provider or default_payout_provider + + async def request_payout( + self, + db: AsyncSession, + creator_id: int, + amount: int, + external_ref: str | None = None, + requested_by: int | None = None, + ) -> PayoutRequest: + """Create a new payout request after reserving funds from available creator balance.""" + if amount < MIN_PAYOUT_AMOUNT: + raise InvalidPayoutAmountError( + f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits" + ) + if amount > MAX_PAYOUT_AMOUNT: + raise InvalidPayoutAmountError( + f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Credits" + ) + + requester = requested_by if requested_by is not None else creator_id + + # Lock creator balance row + cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True) + if cb.available < amount: + raise InsufficientCreatorBalanceError( + f"Insufficient available balance: {cb.available} CR available, {amount} CR requested" + ) + + # Reserve funds: move from available to pending + cb.available -= amount + cb.pending += amount + cb.updated_at = datetime.now(timezone.utc) + + payout = PayoutRequest( + creator_id=creator_id, + amount=amount, + status=PayoutStatus.pending, + external_ref=external_ref, + requested_by=requester, + ) + db.add(payout) + await db.commit() + await db.refresh(payout) + return payout + + async def approve_and_pay( + self, + db: AsyncSession, + payout_id: int, + reviewer_id: int, + provider: PayoutProvider | None = None, + ) -> PayoutRequest: + """Admin approves and executes payout via provider.""" + stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update() + res = await db.execute(stmt) + payout = res.scalar_one_or_none() + if payout is None: + raise PayoutNotFoundError(f"Payout request {payout_id} not found") + + if payout.status != PayoutStatus.pending: + raise InvalidPayoutStateError( + f"Cannot approve payout in status {payout.status.value}" + ) + + active_provider = provider or self.provider + success, provider_ref = await active_provider.pay( + user_id=payout.creator_id, + amount=payout.amount, + external_ref=payout.external_ref, + idempotency_key=f"payout-{payout.id}", + ) + + if not success: + raise RuntimeError("Payout provider rejected transaction") + + now = datetime.now(timezone.utc) + cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True) + cb.pending = max(0, cb.pending - payout.amount) + cb.updated_at = now + + payout.status = PayoutStatus.paid + payout.provider_ref = provider_ref + payout.reviewed_by = reviewer_id + payout.reviewed_at = now + + await db.commit() + await db.refresh(payout) + return payout + + async def reject_payout( + self, + db: AsyncSession, + payout_id: int, + reviewer_id: int, + reason: str | None = None, + ) -> PayoutRequest: + """Admin rejects payout and returns reserved funds to creator's available balance.""" + stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update() + res = await db.execute(stmt) + payout = res.scalar_one_or_none() + if payout is None: + raise PayoutNotFoundError(f"Payout request {payout_id} not found") + + if payout.status != PayoutStatus.pending: + raise InvalidPayoutStateError( + f"Cannot reject payout in status {payout.status.value}" + ) + + now = datetime.now(timezone.utc) + cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True) + cb.pending = max(0, cb.pending - payout.amount) + cb.available += payout.amount + cb.updated_at = now + + payout.status = PayoutStatus.rejected + payout.reviewed_by = reviewer_id + payout.reviewed_at = now + + await db.commit() + await db.refresh(payout) + return payout + + +payout_service = PayoutService() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9087071..f891b5d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -59,7 +59,7 @@ TEST_TABLES = ( # by cascading a truncate of users; list it explicitly for clarity too. "group_members", "groups", # Economy (app/models/wallet.py, app/models/marketplace.py) - "purchases", "marketplace_listings", "catalog_items", "creator_balances", + "payout_requests", "purchases", "marketplace_listings", "catalog_items", "creator_balances", "transactions", "wallets", "sessions", "refresh_tokens", "users", ) diff --git a/backend/tests/test_payouts_api.py b/backend/tests/test_payouts_api.py new file mode 100644 index 0000000..463ae72 --- /dev/null +++ b/backend/tests/test_payouts_api.py @@ -0,0 +1,186 @@ +"""Integration tests for Creator Payouts: requests, list, admin review, and balance accounting. + +docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93). +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.main import app +from app.models.marketplace import CreatorBalance +from app.models.user import User + + +def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"], headers + + +async def _fund_creator_balance(user_id: int, available_amount: int): + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + cb = CreatorBalance( + user_id=user_id, + available=available_amount, + pending=0, + lifetime_earned=available_amount, + ) + session.add(cb) + await session.commit() + await engine.dispose() + + +async def _make_admin(user_id: int): + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + user = await session.get(User, user_id) + assert user is not None + user.is_admin = True + await session.commit() + await engine.dispose() + + +@pytest.mark.asyncio +async def test_payout_request_and_validation(): + client = TestClient(app) + uid, headers = _register(client, "payout_creator_1") + _, other_headers = _register(client, "payout_other_user") + + # Min threshold is 100 CR + resp = client.post( + "/api/payouts", + headers=headers, + json={"amount": 50}, + ) + assert resp.status_code == 422, resp.text + + # Without balance, payout of 200 CR fails + resp = client.post( + "/api/payouts", + headers=headers, + json={"amount": 200}, + ) + assert resp.status_code == 400 + assert "Insufficient available balance" in resp.text + + # Seed 500 CR available creator balance + await _fund_creator_balance(uid, 500) + + # Valid payout request of 200 CR + resp = client.post( + "/api/payouts", + headers=headers, + json={"amount": 200, "external_ref": "creator@paypal.com"}, + ) + assert resp.status_code == 201, resp.text + payout_data = resp.json() + assert payout_data["amount"] == 200 + assert payout_data["status"] == "pending" + assert payout_data["external_ref"] == "creator@paypal.com" + payout_id = payout_data["id"] + + # Check creator balance: 500 - 200 = 300 available, 200 pending + cb_resp = client.get("/api/marketplace/creator/balance", headers=headers) + assert cb_resp.status_code == 200 + cb = cb_resp.json() + assert cb["available"] == 300 + assert cb["pending"] == 200 + + # List payouts: creator sees own request + list_resp = client.get("/api/payouts", headers=headers) + assert list_resp.status_code == 200 + my_payouts = list_resp.json() + assert len(my_payouts) == 1 + assert my_payouts[0]["id"] == payout_id + + # Other user sees empty list + other_list = client.get("/api/payouts", headers=other_headers) + assert other_list.status_code == 200 + assert len(other_list.json()) == 0 + + # Other user cannot view detail -> 403 + det_resp = client.get(f"/api/payouts/{payout_id}", headers=other_headers) + assert det_resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_payout_admin_approve_and_reject(): + client = TestClient(app) + admin_id, admin_headers = _register(client, "admin_payout_reviewer") + + creator_id, creator_headers = _register(client, "payout_creator_2") + await _fund_creator_balance(creator_id, 1000) + + # 1. Request payout of 300 CR + p1_resp = client.post( + "/api/payouts", + headers=creator_headers, + json={"amount": 300, "external_ref": "iban_123"}, + ) + assert p1_resp.status_code == 201 + p1_id = p1_resp.json()["id"] + + # 2. Non-admin cannot approve -> 403 + non_admin_appr = client.post(f"/api/payouts/{p1_id}/approve", headers=creator_headers) + assert non_admin_appr.status_code == 403 + + # 3. Admin approves payout 1 + appr_resp = client.post(f"/api/payouts/{p1_id}/approve", headers=admin_headers) + assert appr_resp.status_code == 200, appr_resp.text + p1_data = appr_resp.json() + assert p1_data["status"] == "paid" + assert p1_data["reviewed_by"] == admin_id + assert p1_data["provider_ref"] is not None + + # Balance check: available was 700, pending was 300 -> now pending = 0 + cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers) + assert cb_resp.json()["available"] == 700 + assert cb_resp.json()["pending"] == 0 + + # 4. Request payout 2 of 200 CR + p2_resp = client.post( + "/api/payouts", + headers=creator_headers, + json={"amount": 200}, + ) + assert p2_resp.status_code == 201 + p2_id = p2_resp.json()["id"] + + # available was 700 - 200 = 500, pending = 200 + cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers) + assert cb_resp.json()["available"] == 500 + assert cb_resp.json()["pending"] == 200 + + # 5. Admin rejects payout 2 + rej_resp = client.post( + f"/api/payouts/{p2_id}/reject", + headers=admin_headers, + json={"reason": "Incorrect payment details"}, + ) + assert rej_resp.status_code == 200, rej_resp.text + p2_data = rej_resp.json() + assert p2_data["status"] == "rejected" + assert p2_data["reviewed_by"] == admin_id + + # Creator balance refunded: 500 + 200 = 700 available, 0 pending + cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers) + assert cb_resp.json()["available"] == 700 + assert cb_resp.json()["pending"] == 0