feat(economy): creator payout request models, migration, and API
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user