From 0b7f407ad2c08945a71f1a17a94c7130d92feb64 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 19:58:32 +0000 Subject: [PATCH] Implement CatalogItem, MarketplaceListing, Purchase, CreatorBalance models and Alembic migration 0013 (task 166836d55365) --- .../versions/0013_add_marketplace_tables.py | 146 +++++++++++++++ backend/app/models/__init__.py | 6 + backend/app/models/marketplace.py | 155 ++++++++++++++++ backend/tests/conftest.py | 3 +- backend/tests/test_marketplace_models.py | 175 ++++++++++++++++++ 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0013_add_marketplace_tables.py create mode 100644 backend/app/models/marketplace.py create mode 100644 backend/tests/test_marketplace_models.py diff --git a/backend/alembic/versions/0013_add_marketplace_tables.py b/backend/alembic/versions/0013_add_marketplace_tables.py new file mode 100644 index 0000000..e222183 --- /dev/null +++ b/backend/alembic/versions/0013_add_marketplace_tables.py @@ -0,0 +1,146 @@ +"""add marketplace tables: catalog_items, marketplace_listings, purchases, creator_balances + +Revision ID: 0013_add_marketplace_tables +Revises: 0012_add_wallet_and_transactions +Create Date: 2026-09-15 00:00:00.000000 + +docs/planning/economy.md US-3, US-4 (task 166836d55365). +""" +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 = "0013_add_marketplace_tables" +down_revision: Union[str, None] = "0012_add_wallet_and_transactions" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +ITEM_TYPES = ("asset", "avatar_item", "experience_pass") +LISTING_STATUSES = ("active", "sold", "delisted", "removed_by_moderation") +PURCHASE_STATUSES = ("pending_escrow", "completed", "refunded", "reversed") + + +def upgrade() -> None: + bind = op.get_bind() + is_pg = bind.dialect.name == "postgresql" + + # Enums + if is_pg: + item_type_enum = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False) + item_type_enum.create(bind, checkfirst=True) + item_type_col = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False) + + listing_status_enum = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False) + listing_status_enum.create(bind, checkfirst=True) + listing_status_col = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False) + + purchase_status_enum = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False) + purchase_status_enum.create(bind, checkfirst=True) + purchase_status_col = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False) + else: + item_type_col = sa.Enum(*ITEM_TYPES, name="catalog_item_type") + listing_status_col = sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status") + purchase_status_col = sa.Enum(*PURCHASE_STATUSES, name="purchase_status") + + # 1. catalog_items + op.create_table( + "catalog_items", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("asset_id", sa.Integer(), nullable=True), + sa.Column("item_type", item_type_col, nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=2000), nullable=True), + sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if is_pg else sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_catalog_items_owner_id"), "catalog_items", ["owner_id"], unique=False) + op.create_index(op.f("ix_catalog_items_asset_id"), "catalog_items", ["asset_id"], unique=False) + + # 2. marketplace_listings + op.create_table( + "marketplace_listings", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("catalog_item_id", sa.BigInteger(), nullable=False), + sa.Column("seller_id", sa.Integer(), nullable=False), + sa.Column("price", sa.BigInteger(), nullable=False), + sa.Column("status", listing_status_col, server_default="active", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("price > 0", name="check_listing_price_positive"), + sa.ForeignKeyConstraint(["catalog_item_id"], ["catalog_items.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_marketplace_listings_catalog_item_id"), "marketplace_listings", ["catalog_item_id"], unique=False) + op.create_index(op.f("ix_marketplace_listings_seller_id"), "marketplace_listings", ["seller_id"], unique=False) + + # 3. purchases + op.create_table( + "purchases", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("listing_id", sa.BigInteger(), nullable=True), + sa.Column("buyer_id", sa.Integer(), nullable=False), + sa.Column("seller_id", sa.Integer(), nullable=False), + sa.Column("price", sa.BigInteger(), nullable=False), + sa.Column("platform_fee", sa.BigInteger(), nullable=False), + sa.Column("seller_net", sa.BigInteger(), nullable=False), + sa.Column("status", purchase_status_col, server_default="pending_escrow", nullable=False), + sa.Column("escrow_hold_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["listing_id"], ["marketplace_listings.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["buyer_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_purchases_listing_id"), "purchases", ["listing_id"], unique=False) + op.create_index(op.f("ix_purchases_buyer_id"), "purchases", ["buyer_id"], unique=False) + op.create_index(op.f("ix_purchases_seller_id"), "purchases", ["seller_id"], unique=False) + + # 4. creator_balances + op.create_table( + "creator_balances", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("available", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("pending", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("lifetime_earned", sa.BigInteger(), server_default="0", nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("available >= 0", name="check_creator_available_non_negative"), + sa.CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"), + sa.CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", name="uq_creator_balances_user_id"), + ) + op.create_index(op.f("ix_creator_balances_user_id"), "creator_balances", ["user_id"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_creator_balances_user_id"), table_name="creator_balances") + op.drop_table("creator_balances") + + op.drop_index(op.f("ix_purchases_seller_id"), table_name="purchases") + op.drop_index(op.f("ix_purchases_buyer_id"), table_name="purchases") + op.drop_index(op.f("ix_purchases_listing_id"), table_name="purchases") + op.drop_table("purchases") + + op.drop_index(op.f("ix_marketplace_listings_seller_id"), table_name="marketplace_listings") + op.drop_index(op.f("ix_marketplace_listings_catalog_item_id"), table_name="marketplace_listings") + op.drop_table("marketplace_listings") + + op.drop_index(op.f("ix_catalog_items_asset_id"), table_name="catalog_items") + op.drop_index(op.f("ix_catalog_items_owner_id"), table_name="catalog_items") + op.drop_table("catalog_items") + + bind = op.get_bind() + if bind.dialect.name == "postgresql": + sa.Enum(*PURCHASE_STATUSES, name="purchase_status").drop(bind, checkfirst=True) + sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status").drop(bind, checkfirst=True) + sa.Enum(*ITEM_TYPES, name="catalog_item_type").drop(bind, checkfirst=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 86e58f9..9e3f09b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,10 @@ from app.models.chat import ( from app.models.groups import Group, GroupMember, GroupRole from app.models.profile import Profile, ProfileFavorite, ProfileVisibility from app.models.wallet import Wallet, Transaction, TransactionType +from app.models.marketplace import ( + CatalogItem, MarketplaceListing, Purchase, CreatorBalance, + ItemType, ListingStatus, PurchaseStatus, +) __all__ = [ "User", "Session", "World", "Asset", "AvatarLoadout", @@ -20,4 +24,6 @@ __all__ = [ "Group", "GroupMember", "GroupRole", "Profile", "ProfileFavorite", "ProfileVisibility", "Wallet", "Transaction", "TransactionType", + "CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance", + "ItemType", "ListingStatus", "PurchaseStatus", ] diff --git a/backend/app/models/marketplace.py b/backend/app/models/marketplace.py new file mode 100644 index 0000000..69f9ac5 --- /dev/null +++ b/backend/app/models/marketplace.py @@ -0,0 +1,155 @@ +"""Marketplace models: catalog items, listings, purchases, and creator balances. + +docs/planning/economy.md US-3, US-4 (task 166836d55365). +""" + +from datetime import datetime, timezone +import enum + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Column, + DateTime, + Enum, + ForeignKey, + Integer, + String, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.types import JSON +from sqlalchemy.orm import relationship + +from app.database import Base + + +class ItemType(str, enum.Enum): + asset = "asset" + avatar_item = "avatar_item" + experience_pass = "experience_pass" + + +class ListingStatus(str, enum.Enum): + active = "active" + sold = "sold" + delisted = "delisted" + removed_by_moderation = "removed_by_moderation" + + +class PurchaseStatus(str, enum.Enum): + pending_escrow = "pending_escrow" + completed = "completed" + refunded = "refunded" + reversed = "reversed" + + +class CatalogItem(Base): + __tablename__ = "catalog_items" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True) + item_type = Column(Enum(ItemType, name="catalog_item_type"), nullable=False) + name = Column(String(100), nullable=False) + description = Column(String(2000), nullable=True) + metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True) + created_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) + + owner = relationship("User", foreign_keys=[owner_id]) + asset = relationship("Asset", foreign_keys=[asset_id]) + listings = relationship("MarketplaceListing", back_populates="catalog_item", cascade="all, delete-orphan") + + +class MarketplaceListing(Base): + __tablename__ = "marketplace_listings" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + catalog_item_id = Column(BigInteger, ForeignKey("catalog_items.id", ondelete="CASCADE"), nullable=False, index=True) + seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + price = Column(BigInteger, nullable=False) + status = Column( + Enum(ListingStatus, name="marketplace_listing_status"), + nullable=False, + default=ListingStatus.active, + server_default=ListingStatus.active.value, + ) + created_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) + updated_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) + + __table_args__ = ( + CheckConstraint("price > 0", name="check_listing_price_positive"), + ) + + catalog_item = relationship("CatalogItem", back_populates="listings") + seller = relationship("User", foreign_keys=[seller_id]) + + +class Purchase(Base): + __tablename__ = "purchases" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + listing_id = Column(BigInteger, ForeignKey("marketplace_listings.id", ondelete="SET NULL"), nullable=True, index=True) + buyer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + price = Column(BigInteger, nullable=False) + platform_fee = Column(BigInteger, nullable=False) + seller_net = Column(BigInteger, nullable=False) + status = Column( + Enum(PurchaseStatus, name="purchase_status"), + nullable=False, + default=PurchaseStatus.pending_escrow, + server_default=PurchaseStatus.pending_escrow.value, + ) + escrow_hold_until = Column(DateTime(timezone=True), nullable=True) + created_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) + + listing = relationship("MarketplaceListing", foreign_keys=[listing_id]) + buyer = relationship("User", foreign_keys=[buyer_id]) + seller = relationship("User", foreign_keys=[seller_id]) + + +class CreatorBalance(Base): + __tablename__ = "creator_balances" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True) + available = Column(BigInteger, nullable=False, default=0, server_default="0") + pending = Column(BigInteger, nullable=False, default=0, server_default="0") + lifetime_earned = Column(BigInteger, nullable=False, default=0, server_default="0") + updated_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + server_default=func.now(), + ) + + __table_args__ = ( + CheckConstraint("available >= 0", name="check_creator_available_non_negative"), + CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"), + CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"), + ) + + user = relationship("User", foreign_keys=[user_id]) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3b356e7..9087071 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -58,7 +58,8 @@ TEST_TABLES = ( # (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached # by cascading a truncate of users; list it explicitly for clarity too. "group_members", "groups", - # Economy (app/models/wallet.py) -- transactions FK wallets FK users + # Economy (app/models/wallet.py, app/models/marketplace.py) + "purchases", "marketplace_listings", "catalog_items", "creator_balances", "transactions", "wallets", "sessions", "refresh_tokens", "users", ) diff --git a/backend/tests/test_marketplace_models.py b/backend/tests/test_marketplace_models.py new file mode 100644 index 0000000..6fddd56 --- /dev/null +++ b/backend/tests/test_marketplace_models.py @@ -0,0 +1,175 @@ +"""Model-level integration tests for Marketplace tables. + +docs/planning/economy.md US-3, US-4 (task 166836d55365). +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +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 ( + CatalogItem, + CreatorBalance, + ItemType, + ListingStatus, + MarketplaceListing, + Purchase, + PurchaseStatus, +) + + +def _register(client: TestClient, username: str) -> int: + 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 + headers = {"Authorization": f"Bearer {resp.json()['access_token']}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"] + + +def _session_factory(): + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +@pytest.mark.asyncio +async def test_catalog_item_and_listing_creation(): + client = TestClient(app) + seller_id = _register(client, "market_seller_1") + engine, sf = _session_factory() + try: + async with sf() as session: + item = CatalogItem( + owner_id=seller_id, + item_type=ItemType.avatar_item, + name="Cyber Wings", + description="Glow in the dark wings", + metadata_json={"slot": "back"}, + ) + session.add(item) + await session.commit() + await session.refresh(item) + + assert item.id is not None + assert item.name == "Cyber Wings" + + listing = MarketplaceListing( + catalog_item_id=item.id, + seller_id=seller_id, + price=250, + status=ListingStatus.active, + ) + session.add(listing) + await session.commit() + await session.refresh(listing) + + assert listing.id is not None + assert listing.price == 250 + assert listing.status == ListingStatus.active + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_listing_price_positive_constraint(): + client = TestClient(app) + seller_id = _register(client, "market_seller_2") + engine, sf = _session_factory() + try: + async with sf() as session: + item = CatalogItem( + owner_id=seller_id, + item_type=ItemType.asset, + name="Free Prop", + ) + session.add(item) + await session.commit() + await session.refresh(item) + + bad_listing = MarketplaceListing( + catalog_item_id=item.id, + seller_id=seller_id, + price=0, # Must be > 0 + ) + session.add(bad_listing) + with pytest.raises(IntegrityError): + await session.commit() + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_creator_balance_uniqueness_and_constraints(): + client = TestClient(app) + creator_id = _register(client, "market_creator_1") + engine, sf = _session_factory() + try: + async with sf() as session: + cb1 = CreatorBalance(user_id=creator_id, available=100, pending=50, lifetime_earned=150) + session.add(cb1) + await session.commit() + await session.refresh(cb1) + + assert cb1.available == 100 + assert cb1.pending == 50 + assert cb1.lifetime_earned == 150 + + # Unique user_id constraint + async with sf() as session: + cb2 = CreatorBalance(user_id=creator_id) + session.add(cb2) + with pytest.raises(IntegrityError): + await session.commit() + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_purchase_record_creation(): + client = TestClient(app) + seller_id = _register(client, "market_seller_3") + buyer_id = _register(client, "market_buyer_3") + engine, sf = _session_factory() + try: + async with sf() as session: + item = CatalogItem(owner_id=seller_id, item_type=ItemType.asset, name="Sword Prop") + session.add(item) + await session.commit() + await session.refresh(item) + + listing = MarketplaceListing(catalog_item_id=item.id, seller_id=seller_id, price=100) + session.add(listing) + await session.commit() + await session.refresh(listing) + + purchase = Purchase( + listing_id=listing.id, + buyer_id=buyer_id, + seller_id=seller_id, + price=100, + platform_fee=30, + seller_net=70, + status=PurchaseStatus.pending_escrow, + ) + session.add(purchase) + await session.commit() + await session.refresh(purchase) + + assert purchase.id is not None + assert purchase.seller_net == 70 + assert purchase.status == PurchaseStatus.pending_escrow + finally: + await engine.dispose()