Compare commits

..

4 Commits

Author SHA1 Message Date
daddy 15ac91c42f feat(godot): add EconomyApiClient with wallet, marketplace, and payout helpers 2026-09-15 20:48:10 +00:00
daddy 70564ff799 Merge branch 'agent/4ba24a178c1b-marketplace-moderation-agegate' into main
checkpoint / checkpoint (push) Successful in 5m55s
2026-09-15 20:47:00 +00:00
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
6 changed files with 547 additions and 1 deletions
+95
View File
@@ -27,6 +27,7 @@ from app.services.purchase import (
InvalidEscrowStateError,
ListingNotAvailableError,
ListingNotFoundError,
MatureContentRestrictedError,
PurchaseNotFoundError,
PurchaseService,
SelfPurchaseError,
@@ -39,6 +40,16 @@ router = APIRouter()
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 ──
class CatalogItemCreate(BaseModel):
@@ -123,6 +134,15 @@ class RefundRequest(BaseModel):
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 ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
@@ -386,6 +406,7 @@ async def purchase_listing(
db=db,
buyer_id=current_user.id,
listing_id=listing_id,
buyer_is_adult=current_user.is_adult,
idempotency_key=idempotency_key,
)
except ListingNotFoundError as exc:
@@ -394,6 +415,8 @@ async def purchase_listing(
raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as 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.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)
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))
+72
View File
@@ -53,6 +53,29 @@ class InvalidEscrowStateError(Exception):
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:
@staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
@@ -93,6 +116,7 @@ class PurchaseService:
db: AsyncSession,
buyer_id: int,
listing_id: int,
buyer_is_adult: bool = True,
idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]:
@@ -123,6 +147,11 @@ class PurchaseService:
if catalog_item is None:
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
price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price)
@@ -247,5 +276,48 @@ class PurchaseService:
await db.refresh(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()
@@ -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
+4 -1
View File
@@ -138,7 +138,8 @@ func request(
path: String,
body: String = "",
content_type: String = "application/json",
require_auth: bool = false
require_auth: bool = false,
custom_headers: PackedStringArray = PackedStringArray()
) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = timeout_seconds
@@ -152,6 +153,8 @@ func request(
headers.append("Content-Type: " + content_type)
if require_auth and has_token():
headers.append("Authorization: " + bearer_token)
for ch in custom_headers:
headers.append(ch)
var url := _base_url + path
var err := http.request(url, headers, method, body)
+142
View File
@@ -0,0 +1,142 @@
extends RefCounted
class_name EconomyApiClient
## Helper class for AGP Economy REST endpoints (Wallet, Payments, Marketplace, Payouts).
##
## docs/planning/economy.md US-6 (task cc09c1428892).
static func get_api_client(node: Node) -> Node:
if node.has_node("/root/ApiClient"):
return node.get_node("/root/ApiClient")
return null
static func generate_idempotency_key() -> String:
return "%d-%d" % [Time.get_unix_time_from_system(), randi() % 1000000]
# ── Wallet & Currency ──
static func get_wallet(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/wallet", "", "application/json", true)
static func get_wallet_transactions(api: Node, limit: int = 50, offset: int = 0) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/wallet/transactions?limit=%d&offset=%d" % [limit, offset], "", "application/json", true)
# ── Premium Purchases ──
static func purchase_credits(api: Node, credits: int, idempotency_key: String = "") -> Dictionary:
var custom_headers := PackedStringArray()
var key := idempotency_key if idempotency_key != "" else generate_idempotency_key()
custom_headers.append("Idempotency-Key: " + key)
var sess_res: Dictionary = await api.request(
HTTPClient.METHOD_POST,
"/api/payments/purchase",
JSON.stringify({"credits": credits}),
"application/json",
true,
custom_headers
)
if not sess_res.ok:
return sess_res
var sess_id: String = str(sess_res.data.get("session_id", ""))
if sess_id == "":
return {"ok": false, "status": 0, "data": null, "detail": "Invalid session returned"}
return await api.request(
HTTPClient.METHOD_POST,
"/api/payments/confirm",
JSON.stringify({"session_id": sess_id}),
"application/json",
true
)
# ── Catalog Items & Listings ──
static func get_catalog_items(api: Node, mine: bool = false, item_type: String = "", limit: int = 50, offset: int = 0) -> Dictionary:
var q := "/api/marketplace/items?limit=%d&offset=%d" % [limit, offset]
if mine:
q += "&mine=true"
if item_type != "":
q += "&item_type=" + item_type.uri_encode()
return await api.request(HTTPClient.METHOD_GET, q, "", "application/json", true)
static func create_catalog_item(api: Node, name: String, item_type: String, description: String = "", metadata_json: Dictionary = {}) -> Dictionary:
var payload := {
"name": name,
"item_type": item_type,
"description": description,
"metadata_json": metadata_json
}
return await api.request(HTTPClient.METHOD_POST, "/api/marketplace/items", JSON.stringify(payload), "application/json", true)
static func create_listing(api: Node, catalog_item_id: int, price: int) -> Dictionary:
var payload := {
"catalog_item_id": catalog_item_id,
"price": price
}
return await api.request(HTTPClient.METHOD_POST, "/api/marketplace/listings", JSON.stringify(payload), "application/json", true)
static func search_listings(api: Node, query: String = "", item_type: String = "", min_price: int = -1, max_price: int = -1, limit: int = 50, offset: int = 0) -> Dictionary:
var q := "/api/marketplace/listings?limit=%d&offset=%d" % [limit, offset]
if query != "":
q += "&q=" + query.uri_encode()
if item_type != "":
q += "&item_type=" + item_type.uri_encode()
if min_price >= 0:
q += "&min_price=%d" % min_price
if max_price >= 0:
q += "&max_price=%d" % max_price
return await api.request(HTTPClient.METHOD_GET, q, "", "application/json", false)
static func get_listing(api: Node, listing_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/marketplace/listings/%d" % listing_id, "", "application/json", false)
static func update_listing(api: Node, listing_id: int, price: int = -1, status: String = "") -> Dictionary:
var payload := {}
if price > 0:
payload["price"] = price
if status != "":
payload["status"] = status
return await api.request(HTTPClient.METHOD_PATCH, "/api/marketplace/listings/%d" % listing_id, JSON.stringify(payload), "application/json", true)
static func delist_listing(api: Node, listing_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_DELETE, "/api/marketplace/listings/%d" % listing_id, "", "application/json", true)
# ── Purchases & Escrow ──
static func purchase_listing(api: Node, listing_id: int, idempotency_key: String = "") -> Dictionary:
var custom_headers := PackedStringArray()
var key := idempotency_key if idempotency_key != "" else generate_idempotency_key()
custom_headers.append("Idempotency-Key: " + key)
return await api.request(
HTTPClient.METHOD_POST,
"/api/marketplace/listings/%d/purchase" % listing_id,
"",
"application/json",
true,
custom_headers
)
static func get_purchases(api: Node, role: String = "all", limit: int = 50, offset: int = 0) -> Dictionary:
return await api.request(
HTTPClient.METHOD_GET,
"/api/marketplace/purchases?role=%s&limit=%d&offset=%d" % [role.uri_encode(), limit, offset],
"",
"application/json",
true
)
# ── Creator Balances & Payouts ──
static func get_creator_balance(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/marketplace/creator/balance", "", "application/json", true)
static func request_payout(api: Node, amount: int, external_ref: String = "") -> Dictionary:
var payload := {"amount": amount}
if external_ref != "":
payload["external_ref"] = external_ref
return await api.request(HTTPClient.METHOD_POST, "/api/payouts", JSON.stringify(payload), "application/json", true)
static func get_payouts(api: Node, limit: int = 50, offset: int = 0) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/payouts?limit=%d&offset=%d" % [limit, offset], "", "application/json", true)
+44
View File
@@ -0,0 +1,44 @@
extends SceneTree
## Headless smoke test for EconomyApiClient.
func _init() -> void:
print("[SMOKE] Starting EconomyApiClient smoke test...")
var api_script = load("res://scripts/api_client.gd")
if not api_script:
printerr("[FAIL] Could not load api_client.gd")
quit(1)
return
var api: Node = api_script.new()
root.add_child(api)
# Verify idempotency key generation
var key1: String = EconomyApiClient.generate_idempotency_key()
var key2: String = EconomyApiClient.generate_idempotency_key()
assert(key1 != "", "Key should not be empty")
assert(key1 != key2, "Keys should be unique")
print("[PASS] Idempotency key generation: ", key1)
# Verify method existence on EconomyApiClient
var client = EconomyApiClient.new()
assert(client.has_method("get_wallet"), "Missing get_wallet")
assert(client.has_method("get_wallet_transactions"), "Missing get_wallet_transactions")
assert(client.has_method("purchase_credits"), "Missing purchase_credits")
assert(client.has_method("get_catalog_items"), "Missing get_catalog_items")
assert(client.has_method("create_catalog_item"), "Missing create_catalog_item")
assert(client.has_method("create_listing"), "Missing create_listing")
assert(client.has_method("search_listings"), "Missing search_listings")
assert(client.has_method("get_listing"), "Missing get_listing")
assert(client.has_method("update_listing"), "Missing update_listing")
assert(client.has_method("delist_listing"), "Missing delist_listing")
assert(client.has_method("purchase_listing"), "Missing purchase_listing")
assert(client.has_method("get_purchases"), "Missing get_purchases")
assert(client.has_method("get_creator_balance"), "Missing get_creator_balance")
assert(client.has_method("request_payout"), "Missing request_payout")
assert(client.has_method("get_payouts"), "Missing get_payouts")
print("[PASS] All EconomyApiClient methods verified")
api.queue_free()
print("[SMOKE] EconomyApiClient smoke test PASSED")
quit(0)