diff --git a/backend/app/services/payout.py b/backend/app/services/payout.py index f731a6a..d12f04b 100644 --- a/backend/app/services/payout.py +++ b/backend/app/services/payout.py @@ -15,7 +15,9 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.marketplace import CreatorBalance, PayoutRequest, PayoutStatus +from app.models.wallet import Transaction, TransactionType from app.services.purchase import PurchaseService +from app.services.wallet import WalletService logger = logging.getLogger(__name__) @@ -158,6 +160,24 @@ class PayoutService: payout.reviewed_by = reviewer_id 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.refresh(payout) return payout diff --git a/backend/tests/test_payout_review.py b/backend/tests/test_payout_review.py new file mode 100644 index 0000000..bd29bcb --- /dev/null +++ b/backend/tests/test_payout_review.py @@ -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()