Compare commits

..

8 Commits

11 changed files with 1105 additions and 1 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()
+136
View File
@@ -0,0 +1,136 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/wallet_ui.gd" id="1_wui"]
[node name="WalletUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_wui")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 16
[node name="HeaderHBox" type="HBoxContainer" parent="VBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBox/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "Wallet & Credits"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusLabel" type="Label" parent="VBox"]
layout_mode = 2
horizontal_alignment = 1
[node name="BalanceCard" type="PanelContainer" parent="VBox"]
layout_mode = 2
[node name="Margin" type="MarginContainer" parent="VBox/BalanceCard"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 16
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 16
[node name="HBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="BalTitle" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Available Balance:"
[node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "0 CR"
[node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
size_flags_horizontal = 3
[node name="PacksHBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin/HBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2
text = "+100 CR ($0.99)"
[node name="Buy500Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2
text = "+500 CR ($4.99)"
[node name="Buy1000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2
text = "+1,000 CR ($9.99)"
[node name="Buy5000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"]
layout_mode = 2
text = "+5,000 CR ($44.99)"
[node name="LedgerSection" type="VBoxContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="LedgerTitle" type="Label" parent="VBox/LedgerSection"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Ledger History"
[node name="Scroll" type="ScrollContainer" parent="VBox/LedgerSection"]
layout_mode = 2
size_flags_vertical = 3
[node name="TxList" type="VBoxContainer" parent="VBox/LedgerSection/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="PagingHBox" type="HBoxContainer" parent="VBox/LedgerSection"]
layout_mode = 2
alignment = 1
theme_override_constants/separation = 16
[node name="PrevPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"]
custom_minimum_size = Vector2(80, 32)
layout_mode = 2
text = "Previous"
[node name="PageLabel" type="Label" parent="VBox/LedgerSection/PagingHBox"]
layout_mode = 2
text = "Page 1 of 1"
[node name="NextPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"]
custom_minimum_size = Vector2(80, 32)
layout_mode = 2
text = "Next"
+4 -1
View File
@@ -138,7 +138,8 @@ func request(
path: String, path: String,
body: String = "", body: String = "",
content_type: String = "application/json", content_type: String = "application/json",
require_auth: bool = false require_auth: bool = false,
custom_headers: PackedStringArray = PackedStringArray()
) -> Dictionary: ) -> Dictionary:
var http := HTTPRequest.new() var http := HTTPRequest.new()
http.timeout = timeout_seconds http.timeout = timeout_seconds
@@ -152,6 +153,8 @@ func request(
headers.append("Content-Type: " + content_type) headers.append("Content-Type: " + content_type)
if require_auth and has_token(): if require_auth and has_token():
headers.append("Authorization: " + bearer_token) headers.append("Authorization: " + bearer_token)
for ch in custom_headers:
headers.append(ch)
var url := _base_url + path var url := _base_url + path
var err := http.request(url, headers, method, body) 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)
+72
View File
@@ -0,0 +1,72 @@
extends SceneTree
## Headless smoke test for WalletUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("[SMOKE] Starting WalletUI smoke test...")
var scene = load("res://scenes/wallet_ui.tscn")
if not scene:
printerr("[FAIL] Could not load scenes/wallet_ui.tscn")
quit(1)
return
var ui: Control = scene.instantiate()
root.add_child(ui)
await process_frame
# Verify onready nodes
assert(ui.back_button != null, "Missing back_button")
assert(ui.refresh_button != null, "Missing refresh_button")
assert(ui.balance_label != null, "Missing balance_label")
assert(ui.buy_100_btn != null, "Missing buy_100_btn")
assert(ui.buy_500_btn != null, "Missing buy_500_btn")
assert(ui.buy_1000_btn != null, "Missing buy_1000_btn")
assert(ui.buy_5000_btn != null, "Missing buy_5000_btn")
assert(ui.tx_container != null, "Missing tx_container")
assert(ui.prev_page_btn != null, "Missing prev_page_btn")
assert(ui.next_page_btn != null, "Missing next_page_btn")
assert(ui.page_label != null, "Missing page_label")
# Test rendering empty transactions
ui.render_transactions([])
assert(ui.tx_container.get_child_count() == 1, "Should have 1 empty notice child")
# Test rendering sample transactions
var sample_txs = [
{
"id": 1,
"created_at": "2026-09-15T12:00:00Z",
"type": "credit_purchase",
"amount": 500,
"balance_after": 500,
"reference_type": "payment",
"reference_id": "sess_123"
},
{
"id": 2,
"created_at": "2026-09-15T12:15:00Z",
"type": "marketplace_purchase",
"amount": -200,
"balance_after": 300,
"reference_type": "marketplace_listing",
"reference_id": "45"
}
]
ui.render_transactions(sample_txs)
assert(ui.tx_container.get_child_count() == 2, "Should have 2 transaction rows")
# Test paging logic
ui.total_tx_count = 45
ui.current_page = 0
ui._update_paging_controls()
assert(ui.prev_page_btn.disabled == true, "Prev should be disabled on page 0")
assert(ui.next_page_btn.disabled == false, "Next should be enabled on page 0")
assert("Page 1 of 3" in ui.page_label.text, "Page label text mismatch")
ui.queue_free()
print("[SMOKE] WalletUI smoke test PASSED")
quit(0)
+222
View File
@@ -0,0 +1,222 @@
extends Control
class_name WalletUI
## Wallet & Transaction History UI panel.
##
## Displays:
## - Current Credit balance
## - Credit pack quick purchases (100, 500, 1000, 5000 CR)
## - Paged ledger transaction history with status/amounts/references
##
## docs/planning/economy.md US-6 (task 34c725431645).
signal back_requested
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBox/StatusLabel
@onready var balance_label: Label = $VBox/BalanceCard/Margin/HBox/BalanceLabel
@onready var buy_100_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy100Btn
@onready var buy_500_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy500Btn
@onready var buy_1000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy1000Btn
@onready var buy_5000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy5000Btn
@onready var tx_container: VBoxContainer = $VBox/LedgerSection/Scroll/TxList
@onready var prev_page_btn: Button = $VBox/LedgerSection/PagingHBox/PrevPageBtn
@onready var next_page_btn: Button = $VBox/LedgerSection/PagingHBox/NextPageBtn
@onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel
var _api_client: Node = null
var current_page: int = 0
const PAGE_SIZE: int = 20
var total_tx_count: int = 0
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
refresh_all()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _wire_signals() -> void:
if back_button and not back_button.pressed.is_connected(_on_back_pressed):
back_button.pressed.connect(_on_back_pressed)
if refresh_button and not refresh_button.pressed.is_connected(refresh_all):
refresh_button.pressed.connect(refresh_all)
if buy_100_btn and not buy_100_btn.pressed.is_connected(_buy_pack.bind(100)):
buy_100_btn.pressed.connect(_buy_pack.bind(100))
if buy_500_btn and not buy_500_btn.pressed.is_connected(_buy_pack.bind(500)):
buy_500_btn.pressed.connect(_buy_pack.bind(500))
if buy_1000_btn and not buy_1000_btn.pressed.is_connected(_buy_pack.bind(1000)):
buy_1000_btn.pressed.connect(_buy_pack.bind(1000))
if buy_5000_btn and not buy_5000_btn.pressed.is_connected(_buy_pack.bind(5000)):
buy_5000_btn.pressed.connect(_buy_pack.bind(5000))
if prev_page_btn and not prev_page_btn.pressed.is_connected(_on_prev_page):
prev_page_btn.pressed.connect(_on_prev_page)
if next_page_btn and not next_page_btn.pressed.is_connected(_on_next_page):
next_page_btn.pressed.connect(_on_next_page)
func _on_back_pressed() -> void:
back_requested.emit()
func show_status(text: String, is_error: bool = false) -> void:
if not status_label:
return
status_label.text = text
if is_error:
status_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
else:
status_label.add_theme_color_override("font_color", Color(0.3, 0.9, 0.3))
func refresh_all() -> void:
refresh_wallet()
refresh_transactions()
func refresh_wallet() -> void:
var api = _get_api()
if not api or not api.has_token():
return
var res: Dictionary = await EconomyApiClient.get_wallet(api)
if res.ok and res.data is Dictionary:
var balance: int = int(res.data.get("balance", 0))
if balance_label:
balance_label.text = "%d CR" % balance
else:
show_status("Failed to fetch wallet: " + res.detail, true)
func refresh_transactions() -> void:
var api = _get_api()
if not api or not api.has_token():
return
var offset: int = current_page * PAGE_SIZE
var res: Dictionary = await EconomyApiClient.get_wallet_transactions(api, PAGE_SIZE, offset)
if res.ok and res.data is Dictionary:
var items: Array = res.data.get("items", [])
total_tx_count = int(res.data.get("total", 0))
render_transactions(items)
_update_paging_controls()
else:
show_status("Failed to fetch transactions: " + res.detail, true)
func _buy_pack(credits: int) -> void:
var api = _get_api()
if not api or not api.has_token():
show_status("Please login to purchase Credits", true)
return
show_status("Processing purchase of %d Credits..." % credits)
var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits)
if res.ok and res.data is Dictionary:
var new_bal: int = int(res.data.get("new_balance", 0))
if balance_label:
balance_label.text = "%d CR" % new_bal
show_status("Successfully purchased %d Credits! Balance: %d CR" % [credits, new_bal])
refresh_transactions()
else:
show_status("Purchase failed: " + res.detail, true)
func render_transactions(tx_list: Array) -> void:
if not tx_container:
return
for c in tx_container.get_children():
tx_container.remove_child(c)
c.queue_free()
if tx_list.is_empty():
var empty_lbl := Label.new()
empty_lbl.text = "No transactions found."
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
tx_container.add_child(empty_lbl)
return
for tx in tx_list:
if not tx is Dictionary:
continue
var row := PanelContainer.new()
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
row.add_child(hbox)
var date_str: String = str(tx.get("created_at", ""))
if date_str.length() > 19:
date_str = date_str.substr(0, 19).replace("T", " ")
var date_lbl := Label.new()
date_lbl.text = date_str
date_lbl.custom_minimum_size = Vector2(140, 0)
date_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
hbox.add_child(date_lbl)
var type_lbl := Label.new()
type_lbl.text = str(tx.get("type", "")).replace("_", " ").capitalize()
type_lbl.custom_minimum_size = Vector2(160, 0)
hbox.add_child(type_lbl)
var amount: int = int(tx.get("amount", 0))
var amt_lbl := Label.new()
if amount > 0:
amt_lbl.text = "+%d CR" % amount
amt_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2))
else:
amt_lbl.text = "%d CR" % amount
amt_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3))
amt_lbl.custom_minimum_size = Vector2(100, 0)
hbox.add_child(amt_lbl)
var bal_lbl := Label.new()
bal_lbl.text = "Bal: %s CR" % str(tx.get("balance_after", 0))
bal_lbl.custom_minimum_size = Vector2(100, 0)
bal_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
hbox.add_child(bal_lbl)
var ref_lbl := Label.new()
var ref_type: String = str(tx.get("reference_type", ""))
var ref_id: String = str(tx.get("reference_id", ""))
if ref_type != "":
ref_lbl.text = "%s #%s" % [ref_type, ref_id]
else:
ref_lbl.text = "-"
ref_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
ref_lbl.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
hbox.add_child(ref_lbl)
tx_container.add_child(row)
func _update_paging_controls() -> void:
var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE)))
if total_pages < 1:
total_pages = 1
if page_label:
page_label.text = "Page %d of %d" % [current_page + 1, total_pages]
if prev_page_btn:
prev_page_btn.disabled = current_page <= 0
if next_page_btn:
next_page_btn.disabled = current_page >= total_pages - 1
func _on_prev_page() -> void:
if current_page > 0:
current_page -= 1
refresh_transactions()
func _on_next_page() -> void:
var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE)))
if current_page < total_pages - 1:
current_page += 1
refresh_transactions()