Implement wallet and transaction ledger models and Alembic migration (task 8343bc0a730f)
This commit is contained in:
@@ -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)
|
||||||
@@ -10,6 +10,7 @@ from app.models.chat import (
|
|||||||
)
|
)
|
||||||
from app.models.groups import Group, GroupMember, GroupRole
|
from app.models.groups import Group, GroupMember, GroupRole
|
||||||
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
|
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
|
||||||
|
from app.models.wallet import Wallet, Transaction, TransactionType
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Session", "World", "Asset", "AvatarLoadout",
|
"User", "Session", "World", "Asset", "AvatarLoadout",
|
||||||
@@ -18,4 +19,5 @@ __all__ = [
|
|||||||
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
||||||
"Group", "GroupMember", "GroupRole",
|
"Group", "GroupMember", "GroupRole",
|
||||||
"Profile", "ProfileFavorite", "ProfileVisibility",
|
"Profile", "ProfileFavorite", "ProfileVisibility",
|
||||||
|
"Wallet", "Transaction", "TransactionType",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -58,6 +58,8 @@ TEST_TABLES = (
|
|||||||
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
|
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
|
||||||
# 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) -- transactions FK wallets FK users
|
||||||
|
"transactions", "wallets",
|
||||||
"sessions", "refresh_tokens", "users",
|
"sessions", "refresh_tokens", "users",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user