Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa9a9e6da | |||
| a4aa50edb9 | |||
| 30ff63f3fd | |||
| 5c4a45f3c9 | |||
| 15ac91c42f | |||
| 70564ff799 | |||
| 397200818d | |||
| 33621a8e60 | |||
| 6ed409f727 | |||
| 997a217db5 | |||
| cf17f29799 | |||
| 71e741b5f9 |
@@ -0,0 +1,68 @@
|
|||||||
|
"""add payout_requests table and payout_status enum
|
||||||
|
|
||||||
|
Revision ID: 0014_add_payout_requests
|
||||||
|
Revises: 0013_add_marketplace_tables
|
||||||
|
Create Date: 2026-09-15 00:00:00.000000
|
||||||
|
|
||||||
|
docs/planning/economy.md US-5 (task f5b4684f2e65).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_add_payout_requests"
|
||||||
|
down_revision: Union[str, None] = "0013_add_marketplace_tables"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
PAYOUT_STATUSES = ("pending", "approved", "paid", "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
is_pg = bind.dialect.name == "postgresql"
|
||||||
|
|
||||||
|
if is_pg:
|
||||||
|
payout_status_enum = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
|
||||||
|
payout_status_enum.create(bind, checkfirst=True)
|
||||||
|
payout_status_col = postgresql.ENUM(*PAYOUT_STATUSES, name="payout_status", create_type=False)
|
||||||
|
else:
|
||||||
|
payout_status_col = sa.Enum(*PAYOUT_STATUSES, name="payout_status")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"payout_requests",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("creator_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("amount", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("status", payout_status_col, server_default="pending", nullable=False),
|
||||||
|
sa.Column("external_ref", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("provider_ref", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("reviewed_by", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["creator_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["requested_by"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.CheckConstraint("amount > 0", name="check_payout_amount_positive"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_payout_requests_creator_id", "payout_requests", ["creator_id"], unique=False)
|
||||||
|
op.create_index("ix_payout_requests_requested_by", "payout_requests", ["requested_by"], unique=False)
|
||||||
|
op.create_index("ix_payout_requests_reviewed_by", "payout_requests", ["reviewed_by"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
is_pg = bind.dialect.name == "postgresql"
|
||||||
|
|
||||||
|
op.drop_index("ix_payout_requests_reviewed_by", table_name="payout_requests")
|
||||||
|
op.drop_index("ix_payout_requests_requested_by", table_name="payout_requests")
|
||||||
|
op.drop_index("ix_payout_requests_creator_id", table_name="payout_requests")
|
||||||
|
op.drop_table("payout_requests")
|
||||||
|
|
||||||
|
if is_pg:
|
||||||
|
op.execute("DROP TYPE IF EXISTS payout_status")
|
||||||
+2
-1
@@ -9,7 +9,7 @@ from app.config import settings
|
|||||||
from app.routers import (
|
from app.routers import (
|
||||||
auth, users, worlds, assets, avatar, world_content, internal,
|
auth, users, worlds, assets, avatar, world_content, internal,
|
||||||
presence, groups, social_friends, profiles, chat_dm, chat_reports,
|
presence, groups, social_friends, profiles, chat_dm, chat_reports,
|
||||||
chat_world, wallet, payments, marketplace,
|
chat_world, wallet, payments, marketplace, payouts,
|
||||||
)
|
)
|
||||||
from app.gateway import ws
|
from app.gateway import ws
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
|
|||||||
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
|
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
|
||||||
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
|
||||||
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
|
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
|
||||||
|
app.include_router(payouts.router, prefix="/api/payouts", tags=["payouts"])
|
||||||
app.include_router(ws.router, prefix="/api", tags=["websocket"])
|
app.include_router(ws.router, prefix="/api", tags=["websocket"])
|
||||||
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
|
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
|
||||||
|
|
||||||
|
|||||||
@@ -153,3 +153,43 @@ class CreatorBalance(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
approved = "approved"
|
||||||
|
paid = "paid"
|
||||||
|
rejected = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutRequest(Base):
|
||||||
|
__tablename__ = "payout_requests"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
creator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
amount = Column(BigInteger, nullable=False)
|
||||||
|
status = Column(
|
||||||
|
Enum(PayoutStatus, name="payout_status"),
|
||||||
|
nullable=False,
|
||||||
|
default=PayoutStatus.pending,
|
||||||
|
server_default=PayoutStatus.pending.value,
|
||||||
|
)
|
||||||
|
external_ref = Column(String(255), nullable=True)
|
||||||
|
provider_ref = Column(String(255), nullable=True)
|
||||||
|
requested_by = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("amount > 0", name="check_payout_amount_positive"),
|
||||||
|
)
|
||||||
|
|
||||||
|
creator = relationship("User", foreign_keys=[creator_id])
|
||||||
|
requester = relationship("User", foreign_keys=[requested_by])
|
||||||
|
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Creator payouts router: requests, listings, and admin review.
|
||||||
|
|
||||||
|
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.marketplace import PayoutRequest, PayoutStatus
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.payout import (
|
||||||
|
InsufficientCreatorBalanceError,
|
||||||
|
InvalidPayoutAmountError,
|
||||||
|
InvalidPayoutStateError,
|
||||||
|
PayoutNotFoundError,
|
||||||
|
payout_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_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_")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutCreate(BaseModel):
|
||||||
|
amount: int = Field(..., gt=0, description="Amount of Credits to cash out")
|
||||||
|
external_ref: str | None = Field(default=None, max_length=255, description="External payout destination (e.g. PayPal email / IBAN)")
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
creator_id: int
|
||||||
|
amount: int
|
||||||
|
status: PayoutStatus
|
||||||
|
external_ref: str | None
|
||||||
|
provider_ref: str | None
|
||||||
|
requested_by: int
|
||||||
|
reviewed_by: int | None
|
||||||
|
reviewed_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutRejectRequest(BaseModel):
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PayoutResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def request_payout(
|
||||||
|
payload: PayoutCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Request a payout of earned creator balance."""
|
||||||
|
try:
|
||||||
|
req = await payout_service.request_payout(
|
||||||
|
db=db,
|
||||||
|
creator_id=current_user.id,
|
||||||
|
amount=payload.amount,
|
||||||
|
external_ref=payload.external_ref,
|
||||||
|
requested_by=current_user.id,
|
||||||
|
)
|
||||||
|
return req
|
||||||
|
except InvalidPayoutAmountError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
except InsufficientCreatorBalanceError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[PayoutResponse])
|
||||||
|
async def list_payouts(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
creator_id: int | None = None,
|
||||||
|
status_filter: PayoutStatus | None = None,
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
"""List payout requests. Regular creators can only view their own."""
|
||||||
|
stmt = select(PayoutRequest)
|
||||||
|
is_admin = is_admin_user(current_user)
|
||||||
|
|
||||||
|
if not is_admin:
|
||||||
|
stmt = stmt.where(PayoutRequest.creator_id == current_user.id)
|
||||||
|
else:
|
||||||
|
if creator_id is not None:
|
||||||
|
stmt = stmt.where(PayoutRequest.creator_id == creator_id)
|
||||||
|
|
||||||
|
if status_filter is not None:
|
||||||
|
stmt = stmt.where(PayoutRequest.status == status_filter)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(PayoutRequest.created_at.desc(), PayoutRequest.id.desc()).offset(offset).limit(limit)
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
return list(res.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{payout_id}", response_model=PayoutResponse)
|
||||||
|
async def get_payout(
|
||||||
|
payout_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Get details of a single payout request."""
|
||||||
|
payout = await db.get(PayoutRequest, payout_id)
|
||||||
|
if payout is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Payout request not found")
|
||||||
|
|
||||||
|
is_admin = is_admin_user(current_user)
|
||||||
|
if payout.creator_id != current_user.id and not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to view this payout request")
|
||||||
|
|
||||||
|
return payout
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{payout_id}/approve", response_model=PayoutResponse)
|
||||||
|
async def approve_payout(
|
||||||
|
payout_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Admin review: approve payout and disburse funds via provider."""
|
||||||
|
if not is_admin_user(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin authorization required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payout = await payout_service.approve_and_pay(
|
||||||
|
db=db,
|
||||||
|
payout_id=payout_id,
|
||||||
|
reviewer_id=current_user.id,
|
||||||
|
)
|
||||||
|
return payout
|
||||||
|
except PayoutNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except InvalidPayoutStateError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{payout_id}/reject", response_model=PayoutResponse)
|
||||||
|
async def reject_payout(
|
||||||
|
payout_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
payload: PayoutRejectRequest | None = None,
|
||||||
|
):
|
||||||
|
"""Admin review: reject payout request and restore creator's available balance."""
|
||||||
|
if not is_admin_user(current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin authorization required")
|
||||||
|
|
||||||
|
reason = payload.reason if payload else None
|
||||||
|
try:
|
||||||
|
payout = await payout_service.reject_payout(
|
||||||
|
db=db,
|
||||||
|
payout_id=payout_id,
|
||||||
|
reviewer_id=current_user.id,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
return payout
|
||||||
|
except PayoutNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc))
|
||||||
|
except InvalidPayoutStateError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Creator Payout Service: requests, review, pluggable PayoutProvider, and audit trail.
|
||||||
|
|
||||||
|
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
from typing import Protocol
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
MIN_PAYOUT_AMOUNT = 100
|
||||||
|
MAX_PAYOUT_AMOUNT = 100_000
|
||||||
|
LARGE_PAYOUT_THRESHOLD = 5000
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientCreatorBalanceError(Exception):
|
||||||
|
"""Raised when creator available balance is less than requested payout amount."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPayoutAmountError(ValueError):
|
||||||
|
"""Raised when requested payout amount is out of valid bounds."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutNotFoundError(Exception):
|
||||||
|
"""Raised when payout request is not found."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidPayoutStateError(Exception):
|
||||||
|
"""Raised when payout request is in invalid status for the requested transition."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutProvider(Protocol):
|
||||||
|
"""Pluggable creator payout provider protocol (e.g. Stripe Connect, PayPal, Noop)."""
|
||||||
|
|
||||||
|
async def pay(
|
||||||
|
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Execute payout to creator. Returns (success, provider_ref)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class NoopPayoutProvider:
|
||||||
|
"""Deterministic no-op provider for development and testing."""
|
||||||
|
|
||||||
|
async def pay(
|
||||||
|
self, user_id: int, amount: int, external_ref: str | None = None, idempotency_key: str | None = None
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
ref = f"noop_payout_{uuid.uuid4().hex[:12]}"
|
||||||
|
logger.info(
|
||||||
|
f"[NoopPayoutProvider] Simulated payout of {amount} CR to user {user_id} (ref={ref})"
|
||||||
|
)
|
||||||
|
return True, ref
|
||||||
|
|
||||||
|
|
||||||
|
default_payout_provider: PayoutProvider = NoopPayoutProvider()
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutService:
|
||||||
|
def __init__(self, provider: PayoutProvider | None = None):
|
||||||
|
self.provider = provider or default_payout_provider
|
||||||
|
|
||||||
|
async def request_payout(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
creator_id: int,
|
||||||
|
amount: int,
|
||||||
|
external_ref: str | None = None,
|
||||||
|
requested_by: int | None = None,
|
||||||
|
) -> PayoutRequest:
|
||||||
|
"""Create a new payout request after reserving funds from available creator balance."""
|
||||||
|
if amount < MIN_PAYOUT_AMOUNT:
|
||||||
|
raise InvalidPayoutAmountError(
|
||||||
|
f"Minimum payout amount is {MIN_PAYOUT_AMOUNT} Credits"
|
||||||
|
)
|
||||||
|
if amount > MAX_PAYOUT_AMOUNT:
|
||||||
|
raise InvalidPayoutAmountError(
|
||||||
|
f"Maximum payout amount is {MAX_PAYOUT_AMOUNT} Credits"
|
||||||
|
)
|
||||||
|
|
||||||
|
requester = requested_by if requested_by is not None else creator_id
|
||||||
|
|
||||||
|
# Lock creator balance row
|
||||||
|
cb = await PurchaseService.get_or_create_creator_balance(db, creator_id, for_update=True)
|
||||||
|
if cb.available < amount:
|
||||||
|
raise InsufficientCreatorBalanceError(
|
||||||
|
f"Insufficient available balance: {cb.available} CR available, {amount} CR requested"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reserve funds: move from available to pending
|
||||||
|
cb.available -= amount
|
||||||
|
cb.pending += amount
|
||||||
|
cb.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
payout = PayoutRequest(
|
||||||
|
creator_id=creator_id,
|
||||||
|
amount=amount,
|
||||||
|
status=PayoutStatus.pending,
|
||||||
|
external_ref=external_ref,
|
||||||
|
requested_by=requester,
|
||||||
|
)
|
||||||
|
db.add(payout)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payout)
|
||||||
|
return payout
|
||||||
|
|
||||||
|
async def approve_and_pay(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
payout_id: int,
|
||||||
|
reviewer_id: int,
|
||||||
|
provider: PayoutProvider | None = None,
|
||||||
|
) -> PayoutRequest:
|
||||||
|
"""Admin approves and executes payout via provider."""
|
||||||
|
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
payout = res.scalar_one_or_none()
|
||||||
|
if payout is None:
|
||||||
|
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
|
||||||
|
|
||||||
|
if payout.status != PayoutStatus.pending:
|
||||||
|
raise InvalidPayoutStateError(
|
||||||
|
f"Cannot approve payout in status {payout.status.value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
active_provider = provider or self.provider
|
||||||
|
success, provider_ref = await active_provider.pay(
|
||||||
|
user_id=payout.creator_id,
|
||||||
|
amount=payout.amount,
|
||||||
|
external_ref=payout.external_ref,
|
||||||
|
idempotency_key=f"payout-{payout.id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise RuntimeError("Payout provider rejected transaction")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
|
||||||
|
cb.pending = max(0, cb.pending - payout.amount)
|
||||||
|
cb.updated_at = now
|
||||||
|
|
||||||
|
payout.status = PayoutStatus.paid
|
||||||
|
payout.provider_ref = provider_ref
|
||||||
|
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
|
||||||
|
|
||||||
|
async def reject_payout(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
payout_id: int,
|
||||||
|
reviewer_id: int,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> PayoutRequest:
|
||||||
|
"""Admin rejects payout and returns reserved funds to creator's available balance."""
|
||||||
|
stmt = select(PayoutRequest).where(PayoutRequest.id == payout_id).with_for_update()
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
payout = res.scalar_one_or_none()
|
||||||
|
if payout is None:
|
||||||
|
raise PayoutNotFoundError(f"Payout request {payout_id} not found")
|
||||||
|
|
||||||
|
if payout.status != PayoutStatus.pending:
|
||||||
|
raise InvalidPayoutStateError(
|
||||||
|
f"Cannot reject payout in status {payout.status.value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cb = await PurchaseService.get_or_create_creator_balance(db, payout.creator_id, for_update=True)
|
||||||
|
cb.pending = max(0, cb.pending - payout.amount)
|
||||||
|
cb.available += payout.amount
|
||||||
|
cb.updated_at = now
|
||||||
|
|
||||||
|
payout.status = PayoutStatus.rejected
|
||||||
|
payout.reviewed_by = reviewer_id
|
||||||
|
payout.reviewed_at = now
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payout)
|
||||||
|
return payout
|
||||||
|
|
||||||
|
|
||||||
|
payout_service = PayoutService()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ TEST_TABLES = (
|
|||||||
# by cascading a truncate of users; list it explicitly for clarity too.
|
# by cascading a truncate of users; list it explicitly for clarity too.
|
||||||
"group_members", "groups",
|
"group_members", "groups",
|
||||||
# Economy (app/models/wallet.py, app/models/marketplace.py)
|
# Economy (app/models/wallet.py, app/models/marketplace.py)
|
||||||
"purchases", "marketplace_listings", "catalog_items", "creator_balances",
|
"payout_requests", "purchases", "marketplace_listings", "catalog_items", "creator_balances",
|
||||||
"transactions", "wallets",
|
"transactions", "wallets",
|
||||||
"sessions", "refresh_tokens", "users",
|
"sessions", "refresh_tokens", "users",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Integration tests for Creator Payouts: requests, list, admin review, and balance accounting.
|
||||||
|
|
||||||
|
docs/planning/economy.md US-5 (tasks f5b4684f2e65, b52008923e93).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
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.user import User
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_admin(user_id: 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:
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
assert user is not None
|
||||||
|
user.is_admin = True
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_payout_request_and_validation():
|
||||||
|
client = TestClient(app)
|
||||||
|
uid, headers = _register(client, "payout_creator_1")
|
||||||
|
_, other_headers = _register(client, "payout_other_user")
|
||||||
|
|
||||||
|
# Min threshold is 100 CR
|
||||||
|
resp = client.post(
|
||||||
|
"/api/payouts",
|
||||||
|
headers=headers,
|
||||||
|
json={"amount": 50},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
|
||||||
|
# Without balance, payout of 200 CR fails
|
||||||
|
resp = client.post(
|
||||||
|
"/api/payouts",
|
||||||
|
headers=headers,
|
||||||
|
json={"amount": 200},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Insufficient available balance" in resp.text
|
||||||
|
|
||||||
|
# Seed 500 CR available creator balance
|
||||||
|
await _fund_creator_balance(uid, 500)
|
||||||
|
|
||||||
|
# Valid payout request of 200 CR
|
||||||
|
resp = client.post(
|
||||||
|
"/api/payouts",
|
||||||
|
headers=headers,
|
||||||
|
json={"amount": 200, "external_ref": "creator@paypal.com"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
payout_data = resp.json()
|
||||||
|
assert payout_data["amount"] == 200
|
||||||
|
assert payout_data["status"] == "pending"
|
||||||
|
assert payout_data["external_ref"] == "creator@paypal.com"
|
||||||
|
payout_id = payout_data["id"]
|
||||||
|
|
||||||
|
# Check creator balance: 500 - 200 = 300 available, 200 pending
|
||||||
|
cb_resp = client.get("/api/marketplace/creator/balance", headers=headers)
|
||||||
|
assert cb_resp.status_code == 200
|
||||||
|
cb = cb_resp.json()
|
||||||
|
assert cb["available"] == 300
|
||||||
|
assert cb["pending"] == 200
|
||||||
|
|
||||||
|
# List payouts: creator sees own request
|
||||||
|
list_resp = client.get("/api/payouts", headers=headers)
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
my_payouts = list_resp.json()
|
||||||
|
assert len(my_payouts) == 1
|
||||||
|
assert my_payouts[0]["id"] == payout_id
|
||||||
|
|
||||||
|
# Other user sees empty list
|
||||||
|
other_list = client.get("/api/payouts", headers=other_headers)
|
||||||
|
assert other_list.status_code == 200
|
||||||
|
assert len(other_list.json()) == 0
|
||||||
|
|
||||||
|
# Other user cannot view detail -> 403
|
||||||
|
det_resp = client.get(f"/api/payouts/{payout_id}", headers=other_headers)
|
||||||
|
assert det_resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_payout_admin_approve_and_reject():
|
||||||
|
client = TestClient(app)
|
||||||
|
admin_id, admin_headers = _register(client, "admin_payout_reviewer")
|
||||||
|
|
||||||
|
creator_id, creator_headers = _register(client, "payout_creator_2")
|
||||||
|
await _fund_creator_balance(creator_id, 1000)
|
||||||
|
|
||||||
|
# 1. Request payout of 300 CR
|
||||||
|
p1_resp = client.post(
|
||||||
|
"/api/payouts",
|
||||||
|
headers=creator_headers,
|
||||||
|
json={"amount": 300, "external_ref": "iban_123"},
|
||||||
|
)
|
||||||
|
assert p1_resp.status_code == 201
|
||||||
|
p1_id = p1_resp.json()["id"]
|
||||||
|
|
||||||
|
# 2. Non-admin cannot approve -> 403
|
||||||
|
non_admin_appr = client.post(f"/api/payouts/{p1_id}/approve", headers=creator_headers)
|
||||||
|
assert non_admin_appr.status_code == 403
|
||||||
|
|
||||||
|
# 3. Admin approves payout 1
|
||||||
|
appr_resp = client.post(f"/api/payouts/{p1_id}/approve", headers=admin_headers)
|
||||||
|
assert appr_resp.status_code == 200, appr_resp.text
|
||||||
|
p1_data = appr_resp.json()
|
||||||
|
assert p1_data["status"] == "paid"
|
||||||
|
assert p1_data["reviewed_by"] == admin_id
|
||||||
|
assert p1_data["provider_ref"] is not None
|
||||||
|
|
||||||
|
# Balance check: available was 700, pending was 300 -> now pending = 0
|
||||||
|
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
|
||||||
|
assert cb_resp.json()["available"] == 700
|
||||||
|
assert cb_resp.json()["pending"] == 0
|
||||||
|
|
||||||
|
# 4. Request payout 2 of 200 CR
|
||||||
|
p2_resp = client.post(
|
||||||
|
"/api/payouts",
|
||||||
|
headers=creator_headers,
|
||||||
|
json={"amount": 200},
|
||||||
|
)
|
||||||
|
assert p2_resp.status_code == 201
|
||||||
|
p2_id = p2_resp.json()["id"]
|
||||||
|
|
||||||
|
# available was 700 - 200 = 500, pending = 200
|
||||||
|
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
|
||||||
|
assert cb_resp.json()["available"] == 500
|
||||||
|
assert cb_resp.json()["pending"] == 200
|
||||||
|
|
||||||
|
# 5. Admin rejects payout 2
|
||||||
|
rej_resp = client.post(
|
||||||
|
f"/api/payouts/{p2_id}/reject",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"reason": "Incorrect payment details"},
|
||||||
|
)
|
||||||
|
assert rej_resp.status_code == 200, rej_resp.text
|
||||||
|
p2_data = rej_resp.json()
|
||||||
|
assert p2_data["status"] == "rejected"
|
||||||
|
assert p2_data["reviewed_by"] == admin_id
|
||||||
|
|
||||||
|
# Creator balance refunded: 500 + 200 = 700 available, 0 pending
|
||||||
|
cb_resp = client.get("/api/marketplace/creator/balance", headers=creator_headers)
|
||||||
|
assert cb_resp.json()["available"] == 700
|
||||||
|
assert cb_resp.json()["pending"] == 0
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/marketplace_ui.gd" id="1_mpu"]
|
||||||
|
|
||||||
|
[node name="MarketplaceUI" 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_mpu")
|
||||||
|
|
||||||
|
[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 = 12
|
||||||
|
|
||||||
|
[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 = "Marketplace"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="BalanceLabel" type="Label" parent="VBox/HeaderHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
theme_override_colors/font_color = Color(1, 0.84, 0, 1)
|
||||||
|
text = "Wallet: 0 CR"
|
||||||
|
|
||||||
|
[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"]
|
||||||
|
custom_minimum_size = Vector2(100, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Refresh"
|
||||||
|
|
||||||
|
[node name="FilterBar" type="HBoxContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="SearchEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(200, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
placeholder_text = "Search items by name or description..."
|
||||||
|
|
||||||
|
[node name="TypeOption" type="OptionButton" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(140, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Min CR"
|
||||||
|
|
||||||
|
[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
placeholder_text = "Max CR"
|
||||||
|
|
||||||
|
[node name="SearchBtn" type="Button" parent="VBox/FilterBar"]
|
||||||
|
custom_minimum_size = Vector2(90, 36)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Search"
|
||||||
|
|
||||||
|
[node name="StatusLabel" type="Label" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="VBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="ListingsGrid" type="GridContainer" parent="VBox/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
theme_override_constants/h_separation = 16
|
||||||
|
theme_override_constants/v_separation = 16
|
||||||
|
columns = 4
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
extends Control
|
||||||
|
class_name MarketplaceUI
|
||||||
|
## Marketplace Browse & Buy UI panel.
|
||||||
|
##
|
||||||
|
## Displays:
|
||||||
|
## - Search bar with keyword, item type, and price filters
|
||||||
|
## - Marketplace active listings cards
|
||||||
|
## - Item details and direct atomic purchase flow
|
||||||
|
## - Wallet balance and real-time status feedback
|
||||||
|
##
|
||||||
|
## docs/planning/economy.md US-6 (task 9797a0cfd9e6).
|
||||||
|
|
||||||
|
signal back_requested
|
||||||
|
signal item_purchased(listing_id: int)
|
||||||
|
|
||||||
|
@onready var back_button: Button = $VBox/HeaderHBox/BackButton
|
||||||
|
@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton
|
||||||
|
@onready var balance_label: Label = $VBox/HeaderHBox/BalanceLabel
|
||||||
|
@onready var status_label: Label = $VBox/StatusLabel
|
||||||
|
|
||||||
|
# Filter inputs
|
||||||
|
@onready var search_edit: LineEdit = $VBox/FilterBar/SearchEdit
|
||||||
|
@onready var type_option: OptionButton = $VBox/FilterBar/TypeOption
|
||||||
|
@onready var min_price_edit: LineEdit = $VBox/FilterBar/MinPriceEdit
|
||||||
|
@onready var max_price_edit: LineEdit = $VBox/FilterBar/MaxPriceEdit
|
||||||
|
@onready var search_btn: Button = $VBox/FilterBar/SearchBtn
|
||||||
|
|
||||||
|
# Listings container
|
||||||
|
@onready var listings_container: GridContainer = $VBox/Scroll/ListingsGrid
|
||||||
|
|
||||||
|
var _api_client: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_api_client = _get_api()
|
||||||
|
_setup_type_options()
|
||||||
|
_wire_signals()
|
||||||
|
if _api_client != null and _api_client.has_token():
|
||||||
|
refresh_balance()
|
||||||
|
search_listings()
|
||||||
|
|
||||||
|
|
||||||
|
func _get_api() -> Node:
|
||||||
|
if has_node("/root/ApiClient"):
|
||||||
|
return get_node("/root/ApiClient")
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_type_options() -> void:
|
||||||
|
if not type_option:
|
||||||
|
return
|
||||||
|
type_option.clear()
|
||||||
|
type_option.add_item("All Types", 0)
|
||||||
|
type_option.add_item("Avatar Item", 1)
|
||||||
|
type_option.add_item("Asset", 2)
|
||||||
|
type_option.add_item("Experience Pass", 3)
|
||||||
|
|
||||||
|
|
||||||
|
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 search_btn and not search_btn.pressed.is_connected(search_listings):
|
||||||
|
search_btn.pressed.connect(search_listings)
|
||||||
|
if search_edit and not search_edit.text_submitted.is_connected(func(_txt): search_listings()):
|
||||||
|
search_edit.text_submitted.connect(func(_txt): search_listings())
|
||||||
|
|
||||||
|
|
||||||
|
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_balance()
|
||||||
|
search_listings()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_balance() -> 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 = "Wallet: %d CR" % balance
|
||||||
|
|
||||||
|
|
||||||
|
func search_listings() -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
|
||||||
|
var q: String = search_edit.text.strip_edges() if search_edit else ""
|
||||||
|
var item_type: String = ""
|
||||||
|
if type_option and type_option.selected > 0:
|
||||||
|
match type_option.selected:
|
||||||
|
1: item_type = "avatar_item"
|
||||||
|
2: item_type = "asset"
|
||||||
|
3: item_type = "experience_pass"
|
||||||
|
|
||||||
|
var min_price: int = -1
|
||||||
|
if min_price_edit and min_price_edit.text.is_valid_int():
|
||||||
|
min_price = min_price_edit.text.to_int()
|
||||||
|
|
||||||
|
var max_price: int = -1
|
||||||
|
if max_price_edit and max_price_edit.text.is_valid_int():
|
||||||
|
max_price = max_price_edit.text.to_int()
|
||||||
|
|
||||||
|
show_status("Loading listings...")
|
||||||
|
var res: Dictionary = await EconomyApiClient.search_listings(api, q, item_type, min_price, max_price)
|
||||||
|
if res.ok and res.data is Dictionary:
|
||||||
|
var items: Array = res.data.get("listings", [])
|
||||||
|
render_listings(items)
|
||||||
|
show_status("Found %d active listings" % items.size())
|
||||||
|
else:
|
||||||
|
show_status("Search failed: " + res.detail, true)
|
||||||
|
|
||||||
|
|
||||||
|
func render_listings(listings: Array) -> void:
|
||||||
|
if not listings_container:
|
||||||
|
return
|
||||||
|
|
||||||
|
for c in listings_container.get_children():
|
||||||
|
listings_container.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
|
||||||
|
if listings.is_empty():
|
||||||
|
var empty_lbl := Label.new()
|
||||||
|
empty_lbl.text = "No listings matching your search."
|
||||||
|
empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
listings_container.add_child(empty_lbl)
|
||||||
|
return
|
||||||
|
|
||||||
|
for l in listings:
|
||||||
|
if not l is Dictionary:
|
||||||
|
continue
|
||||||
|
var card := _create_listing_card(l)
|
||||||
|
listings_container.add_child(card)
|
||||||
|
|
||||||
|
|
||||||
|
func _create_listing_card(listing: Dictionary) -> Control:
|
||||||
|
var card := PanelContainer.new()
|
||||||
|
card.custom_minimum_size = Vector2(240, 200)
|
||||||
|
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 12)
|
||||||
|
margin.add_theme_constant_override("margin_top", 12)
|
||||||
|
margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 12)
|
||||||
|
card.add_child(margin)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 8)
|
||||||
|
margin.add_child(vbox)
|
||||||
|
|
||||||
|
var cat_item: Dictionary = listing.get("catalog_item", {})
|
||||||
|
var title_lbl := Label.new()
|
||||||
|
title_lbl.text = str(cat_item.get("name", "Unnamed Item"))
|
||||||
|
title_lbl.add_theme_font_size_override("font_size", 16)
|
||||||
|
vbox.add_child(title_lbl)
|
||||||
|
|
||||||
|
var type_lbl := Label.new()
|
||||||
|
var itype: String = str(cat_item.get("item_type", "")).replace("_", " ").capitalize()
|
||||||
|
type_lbl.text = itype
|
||||||
|
type_lbl.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
|
||||||
|
type_lbl.add_theme_font_size_override("font_size", 12)
|
||||||
|
vbox.add_child(type_lbl)
|
||||||
|
|
||||||
|
var seller_lbl := Label.new()
|
||||||
|
var seller_name: String = str(listing.get("seller_username", "Unknown"))
|
||||||
|
seller_lbl.text = "By " + seller_name
|
||||||
|
seller_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6))
|
||||||
|
seller_lbl.add_theme_font_size_override("font_size", 12)
|
||||||
|
vbox.add_child(seller_lbl)
|
||||||
|
|
||||||
|
var desc_lbl := Label.new()
|
||||||
|
var desc: String = str(cat_item.get("description", ""))
|
||||||
|
if desc.length() > 60:
|
||||||
|
desc = desc.substr(0, 57) + "..."
|
||||||
|
desc_lbl.text = desc
|
||||||
|
desc_lbl.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
vbox.add_child(desc_lbl)
|
||||||
|
|
||||||
|
var bottom_hbox := HBoxContainer.new()
|
||||||
|
vbox.add_child(bottom_hbox)
|
||||||
|
|
||||||
|
var price: int = int(listing.get("price", 0))
|
||||||
|
var price_lbl := Label.new()
|
||||||
|
price_lbl.text = "%d CR" % price
|
||||||
|
price_lbl.add_theme_font_size_override("font_size", 16)
|
||||||
|
price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2))
|
||||||
|
price_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
bottom_hbox.add_child(price_lbl)
|
||||||
|
|
||||||
|
var buy_btn := Button.new()
|
||||||
|
buy_btn.text = "Buy"
|
||||||
|
buy_btn.custom_minimum_size = Vector2(80, 32)
|
||||||
|
var listing_id: int = int(listing.get("id", 0))
|
||||||
|
buy_btn.pressed.connect(_on_buy_clicked.bind(listing_id, str(cat_item.get("name", "Item")), price))
|
||||||
|
bottom_hbox.add_child(buy_btn)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
func _on_buy_clicked(listing_id: int, item_name: String, price: int) -> void:
|
||||||
|
var api = _get_api()
|
||||||
|
if not api or not api.has_token():
|
||||||
|
show_status("Please login to purchase items", true)
|
||||||
|
return
|
||||||
|
|
||||||
|
show_status("Purchasing '%s' for %d CR..." % [item_name, price])
|
||||||
|
var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id)
|
||||||
|
if res.ok:
|
||||||
|
show_status("Successfully purchased '%s'! Item added to inventory." % item_name)
|
||||||
|
item_purchased.emit(listing_id)
|
||||||
|
refresh_all()
|
||||||
|
else:
|
||||||
|
show_status("Purchase failed: " + res.detail, true)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless smoke test for MarketplaceUI scene and script.
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
print("[SMOKE] Starting MarketplaceUI smoke test...")
|
||||||
|
|
||||||
|
var scene = load("res://scenes/marketplace_ui.tscn")
|
||||||
|
if not scene:
|
||||||
|
printerr("[FAIL] Could not load scenes/marketplace_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.status_label != null, "Missing status_label")
|
||||||
|
assert(ui.search_edit != null, "Missing search_edit")
|
||||||
|
assert(ui.type_option != null, "Missing type_option")
|
||||||
|
assert(ui.min_price_edit != null, "Missing min_price_edit")
|
||||||
|
assert(ui.max_price_edit != null, "Missing max_price_edit")
|
||||||
|
assert(ui.search_btn != null, "Missing search_btn")
|
||||||
|
assert(ui.listings_container != null, "Missing listings_container")
|
||||||
|
|
||||||
|
# Verify type options populated
|
||||||
|
assert(ui.type_option.item_count >= 4, "Type options not populated")
|
||||||
|
|
||||||
|
# Test rendering empty listings
|
||||||
|
ui.render_listings([])
|
||||||
|
assert(ui.listings_container.get_child_count() == 1, "Should show empty notice")
|
||||||
|
|
||||||
|
# Test rendering sample listings
|
||||||
|
var sample_listings = [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"price": 200,
|
||||||
|
"seller_username": "merchant_bob",
|
||||||
|
"catalog_item": {
|
||||||
|
"name": "Wizard Robe",
|
||||||
|
"item_type": "avatar_item",
|
||||||
|
"description": "Flowing velvet robe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"price": 500,
|
||||||
|
"seller_username": "dungeon_master",
|
||||||
|
"catalog_item": {
|
||||||
|
"name": "Castle Map",
|
||||||
|
"item_type": "asset",
|
||||||
|
"description": "Detailed 3D fortress map"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ui.render_listings(sample_listings)
|
||||||
|
assert(ui.listings_container.get_child_count() == 2, "Should render 2 listing cards")
|
||||||
|
|
||||||
|
# Verify signals
|
||||||
|
assert(ui.has_signal("back_requested"), "Missing back_requested signal")
|
||||||
|
assert(ui.has_signal("item_purchased"), "Missing item_purchased signal")
|
||||||
|
|
||||||
|
ui.queue_free()
|
||||||
|
print("[SMOKE] MarketplaceUI smoke test PASSED")
|
||||||
|
quit(0)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user