From 64dd8b5f09e484345ca7e483636989e59795328e Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 19:50:53 +0000 Subject: [PATCH] Implement wallet and transaction ledger models and Alembic migration (task 8343bc0a730f) --- .../0012_add_wallet_and_transactions.py | 90 +++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/wallet.py | 104 +++++++++++++ backend/tests/conftest.py | 2 + backend/tests/test_wallet_models.py | 141 ++++++++++++++++++ 5 files changed, 339 insertions(+) create mode 100644 backend/alembic/versions/0012_add_wallet_and_transactions.py create mode 100644 backend/app/models/wallet.py create mode 100644 backend/tests/test_wallet_models.py diff --git a/backend/alembic/versions/0012_add_wallet_and_transactions.py b/backend/alembic/versions/0012_add_wallet_and_transactions.py new file mode 100644 index 0000000..1a0b789 --- /dev/null +++ b/backend/alembic/versions/0012_add_wallet_and_transactions.py @@ -0,0 +1,90 @@ +"""add wallets and transactions ledger tables + +Revision ID: 0012_add_wallet_and_transactions +Revises: 0011_add_dm_last_read +Create Date: 2026-09-15 00:00:00.000000 + +docs/planning/economy.md US-1 (task 8343bc0a730f). +""" +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 = "0012_add_wallet_and_transactions" +down_revision: Union[str, None] = "0011_add_dm_last_read" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +TRANSACTION_TYPES = ( + "credit_purchase", + "marketplace_purchase", + "marketplace_sale", + "escrow_release", + "payout", + "refund", + "admin_adjustment", +) + + +def upgrade() -> None: + # 1. Create transaction_type enum for Postgres + bind = op.get_bind() + if bind.dialect.name == "postgresql": + tx_type_enum = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False) + tx_type_enum.create(bind, checkfirst=True) + tx_type_col = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False) + else: + tx_type_col = sa.Enum(*TRANSACTION_TYPES, name="transaction_type") + + # 2. Create wallets table + op.create_table( + "wallets", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("balance", sa.BigInteger(), server_default="0", 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("balance >= 0", name="check_wallet_balance_non_negative"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", name="uq_wallets_user_id"), + ) + op.create_index(op.f("ix_wallets_user_id"), "wallets", ["user_id"], unique=True) + + # 3. Create transactions table + op.create_table( + "transactions", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("wallet_id", sa.BigInteger(), nullable=False), + sa.Column("type", tx_type_col, nullable=False), + sa.Column("amount", sa.BigInteger(), nullable=False), + sa.Column("balance_after", sa.BigInteger(), nullable=False), + sa.Column("reference_type", sa.String(), nullable=True), + sa.Column("reference_id", sa.String(), nullable=True), + sa.Column("idempotency_key", sa.String(), nullable=True), + sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if bind.dialect.name == "postgresql" else sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("idempotency_key", name="uq_transactions_idempotency_key"), + ) + op.create_index(op.f("ix_transactions_wallet_id"), "transactions", ["wallet_id"], unique=False) + op.create_index(op.f("ix_transactions_idempotency_key"), "transactions", ["idempotency_key"], unique=True) + op.create_index(op.f("ix_transactions_created_at"), "transactions", ["created_at"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_transactions_created_at"), table_name="transactions") + op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions") + op.drop_index(op.f("ix_transactions_wallet_id"), table_name="transactions") + op.drop_table("transactions") + + op.drop_index(op.f("ix_wallets_user_id"), table_name="wallets") + op.drop_table("wallets") + + bind = op.get_bind() + if bind.dialect.name == "postgresql": + sa.Enum(*TRANSACTION_TYPES, name="transaction_type").drop(bind, checkfirst=True) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7b77152..86e58f9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ 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 __all__ = [ "User", "Session", "World", "Asset", "AvatarLoadout", @@ -18,4 +19,5 @@ __all__ = [ "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", "Group", "GroupMember", "GroupRole", "Profile", "ProfileFavorite", "ProfileVisibility", + "Wallet", "Transaction", "TransactionType", ] diff --git a/backend/app/models/wallet.py b/backend/app/models/wallet.py new file mode 100644 index 0000000..4eecc7c --- /dev/null +++ b/backend/app/models/wallet.py @@ -0,0 +1,104 @@ +"""Wallet and Transaction ledger models for Credits virtual currency. + +docs/planning/economy.md US-1. +""" + +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 TransactionType(str, enum.Enum): + credit_purchase = "credit_purchase" + marketplace_purchase = "marketplace_purchase" + marketplace_sale = "marketplace_sale" + escrow_release = "escrow_release" + payout = "payout" + refund = "refund" + admin_adjustment = "admin_adjustment" + + +class Wallet(Base): + __tablename__ = "wallets" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + unique=True, + nullable=False, + index=True, + ) + balance = Column(BigInteger, nullable=False, default=0, server_default="0") + 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("balance >= 0", name="check_wallet_balance_non_negative"), + ) + + user = relationship("User", backref="wallet", uselist=False) + transactions = relationship( + "Transaction", + back_populates="wallet", + cascade="all, delete-orphan", + order_by="Transaction.created_at.desc()", + ) + + +class Transaction(Base): + __tablename__ = "transactions" + + id = Column(BigInteger, primary_key=True, autoincrement=True) + wallet_id = Column( + BigInteger, + ForeignKey("wallets.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + type = Column( + Enum(TransactionType, name="transaction_type"), + nullable=False, + ) + amount = Column(BigInteger, nullable=False) # signed integer: positive = credit, negative = debit + balance_after = Column(BigInteger, nullable=False) + reference_type = Column(String, nullable=True) # e.g. "purchase", "sale", "payout_request" + reference_id = Column(String, nullable=True) + idempotency_key = Column(String, unique=True, nullable=True, index=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(), + index=True, + ) + + wallet = relationship("Wallet", back_populates="transactions") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index de30262..3b356e7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -58,6 +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 + "transactions", "wallets", "sessions", "refresh_tokens", "users", ) diff --git a/backend/tests/test_wallet_models.py b/backend/tests/test_wallet_models.py new file mode 100644 index 0000000..085b41a --- /dev/null +++ b/backend/tests/test_wallet_models.py @@ -0,0 +1,141 @@ +"""Model-level integration tests for Wallets and Transaction ledger. + +docs/planning/economy.md US-1 (task 8343bc0a730f). +""" + +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.wallet import Wallet, Transaction, TransactionType +from app.models.user import User + + +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_wallet_creation_and_defaults(): + client = TestClient(app) + uid = _register(client, "wallet_user_1") + engine, sf = _session_factory() + try: + async with sf() as session: + wallet = Wallet(user_id=uid) + session.add(wallet) + await session.commit() + await session.refresh(wallet) + + assert wallet.id is not None + assert wallet.user_id == uid + assert wallet.balance == 0 + assert wallet.created_at is not None + assert wallet.updated_at is not None + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_wallet_unique_user_id(): + client = TestClient(app) + uid = _register(client, "wallet_user_2") + engine, sf = _session_factory() + try: + async with sf() as session: + w1 = Wallet(user_id=uid) + session.add(w1) + await session.commit() + + async with sf() as session: + w2 = Wallet(user_id=uid) + session.add(w2) + with pytest.raises(IntegrityError): + await session.commit() + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_wallet_non_negative_balance_constraint(): + client = TestClient(app) + uid = _register(client, "wallet_user_3") + engine, sf = _session_factory() + try: + async with sf() as session: + w = Wallet(user_id=uid, balance=-50) + session.add(w) + with pytest.raises(IntegrityError): + await session.commit() + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_transaction_ledger_and_idempotency(): + client = TestClient(app) + uid = _register(client, "wallet_user_4") + engine, sf = _session_factory() + try: + async with sf() as session: + wallet = Wallet(user_id=uid, balance=500) + session.add(wallet) + await session.commit() + await session.refresh(wallet) + + tx1 = Transaction( + wallet_id=wallet.id, + type=TransactionType.credit_purchase, + amount=500, + balance_after=500, + reference_type="purchase_session", + reference_id="sess_123", + idempotency_key="idemp_key_001", + metadata_json={"payment_method": "simulated"}, + ) + session.add(tx1) + await session.commit() + await session.refresh(tx1) + + assert tx1.id is not None + assert tx1.type == TransactionType.credit_purchase + assert tx1.amount == 500 + assert tx1.balance_after == 500 + + # Duplicate idempotency_key must raise IntegrityError + async with sf() as session: + tx2 = Transaction( + wallet_id=wallet.id, + type=TransactionType.credit_purchase, + amount=500, + balance_after=1000, + idempotency_key="idemp_key_001", + ) + session.add(tx2) + with pytest.raises(IntegrityError): + await session.commit() + finally: + await engine.dispose()