Compare commits

...

4 Commits

Author SHA1 Message Date
daddy 397200818d feat(marketplace): moderation endpoints and age gate enforcement 2026-09-15 20:46:50 +00:00
daddy 33621a8e60 Merge branch 'agent/b52008923e93-payout-review-audit' into main
checkpoint / checkpoint (push) Successful in 5m47s
2026-09-15 20:44:34 +00:00
daddy 6ed409f727 feat(payouts): admin review, PayoutProvider interface, and ledger audit trail 2026-09-15 20:44:27 +00:00
daddy 997a217db5 Merge branch 'agent/f5b4684f2e65-payout-requests-api' into main
checkpoint / checkpoint (push) Successful in 5m43s
2026-09-15 20:43:35 +00:00
5 changed files with 485 additions and 0 deletions
+95
View File
@@ -27,6 +27,7 @@ from app.services.purchase import (
InvalidEscrowStateError, InvalidEscrowStateError,
ListingNotAvailableError, ListingNotAvailableError,
ListingNotFoundError, ListingNotFoundError,
MatureContentRestrictedError,
PurchaseNotFoundError, PurchaseNotFoundError,
PurchaseService, PurchaseService,
SelfPurchaseError, SelfPurchaseError,
@@ -39,6 +40,16 @@ router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)] DbDep = Annotated[AsyncSession, Depends(get_db)]
def is_moderator_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_")
)
# ── Schemas ── # ── Schemas ──
class CatalogItemCreate(BaseModel): class CatalogItemCreate(BaseModel):
@@ -123,6 +134,15 @@ class RefundRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500) reason: str | None = Field(default=None, max_length=500)
class ListingModerateRequest(BaseModel):
action: str = Field(..., pattern="^(delist|remove)$")
reason: str = Field(..., min_length=1, max_length=500)
class PurchaseReversalRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
# ── Catalog Items ── # ── Catalog Items ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED) @router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
@@ -386,6 +406,7 @@ async def purchase_listing(
db=db, db=db,
buyer_id=current_user.id, buyer_id=current_user.id,
listing_id=listing_id, listing_id=listing_id,
buyer_is_adult=current_user.is_adult,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
) )
except ListingNotFoundError as exc: except ListingNotFoundError as exc:
@@ -394,6 +415,8 @@ async def purchase_listing(
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as exc: except InsufficientFundsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
except MatureContentRestrictedError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
resp = PurchaseResponse.model_validate(purchase) resp = PurchaseResponse.model_validate(purchase)
resp.granted_item_id = granted_item.id resp.granted_item_id = granted_item.id
@@ -510,3 +533,75 @@ async def get_my_creator_balance(
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id) cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
return cb return cb
# ── Moderation ──
@router.post("/listings/{listing_id}/moderate", response_model=ListingResponse)
async def moderate_listing(
listing_id: int,
payload: ListingModerateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator action to delist or remove a listing with reason."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
if payload.action == "remove":
listing.status = ListingStatus.removed_by_moderation
else:
listing.status = ListingStatus.delisted
listing.updated_at = datetime.now()
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.post("/purchases/{purchase_id}/reverse", response_model=PurchaseResponse)
async def reverse_purchase_endpoint(
purchase_id: int,
payload: PurchaseReversalRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Moderator/admin audited reversal of purchase."""
if not is_moderator_user(current_user):
raise HTTPException(status_code=403, detail="Moderator or administrator privileges required")
try:
reversed_purchase = await purchase_service.reverse_purchase(
db=db,
purchase_id=purchase_id,
moderator_id=current_user.id,
reason=payload.reason,
)
return reversed_purchase
except PurchaseNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
+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
+72
View File
@@ -53,6 +53,29 @@ class InvalidEscrowStateError(Exception):
pass pass
class MatureContentRestrictedError(Exception):
"""Raised when an unverified minor attempts to purchase mature content."""
pass
def is_item_mature(item: CatalogItem) -> bool:
"""Check whether a catalog item carries mature flags or is marked mature."""
if not item.metadata_json or not isinstance(item.metadata_json, dict):
return False
if item.metadata_json.get("is_mature") or item.metadata_json.get("mature"):
return True
flags = item.metadata_json.get("content_flags", [])
if isinstance(flags, list):
for f in flags:
if isinstance(f, dict) and f.get("mature"):
return True
if isinstance(f, str) and any(
m in f for m in ("gore", "sexual", "gambling", "hate", "explicit")
):
return True
return False
class PurchaseService: class PurchaseService:
@staticmethod @staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]: def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
@@ -93,6 +116,7 @@ class PurchaseService:
db: AsyncSession, db: AsyncSession,
buyer_id: int, buyer_id: int,
listing_id: int, listing_id: int,
buyer_is_adult: bool = True,
idempotency_key: str | None = None, idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS, escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]: ) -> tuple[Purchase, CatalogItem]:
@@ -123,6 +147,11 @@ class PurchaseService:
if catalog_item is None: if catalog_item is None:
raise ListingNotFoundError("Catalog item referenced by listing does not exist") raise ListingNotFoundError("Catalog item referenced by listing does not exist")
if is_item_mature(catalog_item) and not buyer_is_adult:
raise MatureContentRestrictedError(
"MATURE_CONTENT_REQUIRES_VERIFICATION: Buyer must be 18+ to purchase mature items"
)
# 3. Calculate fee split # 3. Calculate fee split
price = listing.price price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price) platform_fee, seller_net = cls.compute_fee_split(price)
@@ -247,5 +276,48 @@ class PurchaseService:
await db.refresh(purchase) await db.refresh(purchase)
return purchase return purchase
@classmethod
async def reverse_purchase(
cls,
db: AsyncSession,
purchase_id: int,
moderator_id: int,
reason: str,
) -> Purchase:
"""Moderator/admin audited reversal of purchase."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status in (PurchaseStatus.reversed, PurchaseStatus.refunded):
raise InvalidEscrowStateError(f"Purchase already {purchase.status.value}")
# Debit seller creator balance (from pending if in escrow, else available)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
if purchase.status == PurchaseStatus.pending_escrow:
cb.pending = max(0, cb.pending - purchase.seller_net)
else:
cb.available = max(0, cb.available - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Refund buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_reversal",
reference_id=str(purchase.id),
metadata={"moderator_id": moderator_id, "reason": reason},
commit=False,
)
purchase.status = PurchaseStatus.reversed
await db.commit()
await db.refresh(purchase)
return purchase
purchase_service = PurchaseService() purchase_service = PurchaseService()
@@ -0,0 +1,190 @@
"""Integration tests for Marketplace Moderation, Content Flags, and Age Gate Enforcement.
docs/planning/economy.md US-3, Task 9 (task 4ba24a178c1b).
"""
from datetime import date
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(
client: TestClient, username: str, dob: str = "1990-01-01"
) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": dob,
},
)
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 _set_user_dob(user_id: int, dob: date):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.models.user import User
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.date_of_birth = dob
await session.commit()
await engine.dispose()
@pytest.mark.asyncio
async def test_marketplace_age_gate_enforcement():
client = TestClient(app)
seller_id, seller_headers = _register(client, "mature_seller_1")
# Register minor and set DOB in DB to 2015
minor_id, minor_headers = _register(client, "minor_buyer_1")
await _set_user_dob(minor_id, date(2015, 6, 1))
# Adult user
adult_id, adult_headers = _register(client, "adult_buyer_1")
# Fund minor and adult with 500 Credits
for h in (minor_headers, adult_headers):
s_resp = client.post("/api/payments/purchase", headers=h, json={"credits": 500})
client.post("/api/payments/confirm", headers=h, json={"session_id": s_resp.json()["session_id"]})
# Seller creates mature item
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={
"name": "Blood Sword",
"item_type": "avatar_item",
"metadata_json": {"is_mature": True, "content_flags": ["violence.gore"]},
},
)
assert item_resp.status_code == 201
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": item_id, "price": 100},
)
assert list_resp.status_code == 201
listing_id = list_resp.json()["id"]
# 1. Minor attempts to purchase mature item -> 403 Forbidden
minor_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=minor_headers,
)
assert minor_buy.status_code == 403
assert "MATURE_CONTENT_REQUIRES_VERIFICATION" in minor_buy.text
# 2. Adult purchases mature item -> 201 Created
adult_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=adult_headers,
)
assert adult_buy.status_code == 201, adult_buy.text
assert adult_buy.json()["status"] == "pending_escrow"
def test_moderator_delist_remove_and_reversal():
client = TestClient(app)
mod_id, mod_headers = _register(client, "admin_mkt_mod_1", dob="1980-01-01")
seller_id, seller_headers = _register(client, "seller_mod_test", dob="1990-01-01")
buyer_id, buyer_headers = _register(client, "buyer_mod_test", dob="1990-01-01")
# Fund buyer
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
# Create listing 1
i1 = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Violating Item", "item_type": "avatar_item"},
).json()
l1 = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": i1["id"], "price": 100},
).json()
l1_id = l1["id"]
# Non-moderator cannot moderate listing -> 403
unauth = client.post(
f"/api/marketplace/listings/{l1_id}/moderate",
headers=buyer_headers,
json={"action": "remove", "reason": "Copyright infringement"},
)
assert unauth.status_code == 403
# Moderator removes listing
mod_resp = client.post(
f"/api/marketplace/listings/{l1_id}/moderate",
headers=mod_headers,
json={"action": "remove", "reason": "Copyright infringement"},
)
assert mod_resp.status_code == 200, mod_resp.text
assert mod_resp.json()["status"] == "removed_by_moderation"
# Removed listing cannot be purchased
buy_fail = client.post(
f"/api/marketplace/listings/{l1_id}/purchase",
headers=buyer_headers,
)
assert buy_fail.status_code == 400
# Create listing 2 and purchase it
i2 = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Contested Item", "item_type": "avatar_item"},
).json()
l2 = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": i2["id"], "price": 150},
).json()
p2 = client.post(
f"/api/marketplace/listings/{l2['id']}/purchase",
headers=buyer_headers,
).json()
p2_id = p2["id"]
# Buyer balance is 350
w = client.get("/api/wallet", headers=buyer_headers).json()
assert w["balance"] == 350
# Non-moderator cannot reverse purchase -> 403
unauth_rev = client.post(
f"/api/marketplace/purchases/{p2_id}/reverse",
headers=buyer_headers,
json={"reason": "Buyer regret"},
)
assert unauth_rev.status_code == 403
# Moderator reverses purchase
rev_resp = client.post(
f"/api/marketplace/purchases/{p2_id}/reverse",
headers=mod_headers,
json={"reason": "Fraudulent listing detected"},
)
assert rev_resp.status_code == 200, rev_resp.text
assert rev_resp.json()["status"] == "reversed"
# Buyer balance restored to 500
w_after = client.get("/api/wallet", headers=buyer_headers).json()
assert w_after["balance"] == 500
+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()