feat(payouts): admin review, PayoutProvider interface, and ledger audit trail

This commit is contained in:
2026-09-15 20:44:27 +00:00
parent 997a217db5
commit 6ed409f727
2 changed files with 128 additions and 0 deletions
+20
View File
@@ -15,7 +15,9 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus
from app.models.wallet import Transaction, TransactionType
from app.services.purchase import PurchaseService from app.services.purchase import PurchaseService
from app.services.wallet import WalletService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -158,6 +160,24 @@ class PayoutService:
payout.reviewed_by = reviewer_id payout.reviewed_by = reviewer_id
payout.reviewed_at = now payout.reviewed_at = now
# Record immutable ledger audit row
wallet = await WalletService.get_or_create_wallet(db, payout.creator_id, for_update=True)
tx = Transaction(
wallet_id=wallet.id,
type=TransactionType.payout,
amount=-payout.amount,
balance_after=wallet.balance,
reference_type="payout_request",
reference_id=str(payout.id),
idempotency_key=f"payout-audit-{payout.id}",
metadata_json={
"provider_ref": provider_ref,
"reviewed_by": reviewer_id,
"external_ref": payout.external_ref,
},
)
db.add(tx)
await db.commit() await db.commit()
await db.refresh(payout) await db.refresh(payout)
return payout return payout
+108
View File
@@ -0,0 +1,108 @@
"""Integration tests for Payout Admin Review, PayoutProvider, and Ledger Audit Trail.
docs/planning/economy.md US-5 (task b52008923e93).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
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.wallet import Transaction, TransactionType
from app.services.payout import NoopPayoutProvider
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()
@pytest.mark.asyncio
async def test_payout_provider_noop():
provider = NoopPayoutProvider()
success, ref = await provider.pay(user_id=1, amount=500, external_ref="test@example.com")
assert success is True
assert ref.startswith("noop_payout_")
@pytest.mark.asyncio
async def test_payout_admin_review_audit_trail_and_ledger():
client = TestClient(app)
admin_id, admin_headers = _register(client, "admin_audit_reviewer")
creator_id, creator_headers = _register(client, "audit_creator_user")
await _fund_creator_balance(creator_id, 1500)
# 1. Creator requests payout
req_resp = client.post(
"/api/payouts",
headers=creator_headers,
json={"amount": 600, "external_ref": "stripe_acct_456"},
)
assert req_resp.status_code == 201, req_resp.text
payout_id = req_resp.json()["id"]
# 2. Admin approves payout
appr_resp = client.post(
f"/api/payouts/{payout_id}/approve",
headers=admin_headers,
)
assert appr_resp.status_code == 200, appr_resp.text
data = appr_resp.json()
assert data["status"] == "paid"
assert data["reviewed_by"] == admin_id
assert data["reviewed_at"] is not None
assert data["provider_ref"] is not None
provider_ref = data["provider_ref"]
# 3. Verify ledger transaction row was recorded for audit trail
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
stmt = (
select(Transaction)
.where(
Transaction.reference_type == "payout_request",
Transaction.reference_id == str(payout_id),
)
)
tx = (await session.execute(stmt)).scalar_one_or_none()
assert tx is not None
assert tx.type == TransactionType.payout
assert tx.amount == -600
assert tx.metadata_json["provider_ref"] == provider_ref
assert tx.metadata_json["reviewed_by"] == admin_id
assert tx.metadata_json["external_ref"] == "stripe_acct_456"
await engine.dispose()