Merge branch 'agent/8343bc0a730f-wallet-models' into main
checkpoint / checkpoint (push) Successful in 5m16s
checkpoint / checkpoint (push) Successful in 5m16s
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.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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
# 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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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