Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions backend/app/alembic/versions/20250915_add_wallets_transactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
Add wallet and transaction tables.

Revision ID: 20250915_add_wallets_transactions
Revises: 1a31ce608336
Create Date: 2025-09-15

"""

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "20250915_add_wallets_transactions"
down_revision = "1a31ce608336"
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
op.create_table(
"wallet",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("currency", sa.String(length=255), nullable=False),
sa.Column("balance", sa.Numeric(18, 2), nullable=False, server_default="0.00"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "currency", name="uq_wallet_user_currency"),
)

transaction_type = sa.Enum("credit", "debit", name="transaction_type")
transaction_type.create(op.get_bind(), checkfirst=True)

op.create_table(
"transaction",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("wallet_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("amount", sa.Numeric(18, 2), nullable=False),
sa.Column("type", transaction_type, nullable=False),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("currency", sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallet.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)


def downgrade() -> None:
op.drop_table("transaction")
op.drop_table("wallet")
sa.Enum(name="transaction_type").drop(op.get_bind(), checkfirst=True)
6 changes: 3 additions & 3 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
"""API router configuration."""

from fastapi import APIRouter

from app.api.routes import items, login, misc, private, users
from app.api.routes import items, login, misc, private, users, wallets
from app.core.config import settings
from fastapi import APIRouter

api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(misc.router)
api_router.include_router(items.router)
api_router.include_router(wallets.router)


if settings.ENVIRONMENT == "local":
Expand Down
144 changes: 144 additions & 0 deletions backend/app/api/routes/wallets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Wallet and Transaction management endpoints."""

import uuid
from decimal import Decimal

from fastapi import APIRouter, HTTPException
from sqlmodel import func, select

from app.api.deps import CurrentUser, SessionDep
from app.constants import BAD_REQUEST_CODE, CONFLICT_CODE, NOT_FOUND_CODE
from typing import cast
Comment on lines +5 to +11
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The typing import should be grouped with other standard library imports at the top. Move this import to line 4 with the other imports from the typing module.

Suggested change
from fastapi import APIRouter, HTTPException
from sqlmodel import func, select
from app.api.deps import CurrentUser, SessionDep
from app.constants import BAD_REQUEST_CODE, CONFLICT_CODE, NOT_FOUND_CODE
from typing import cast
from typing import cast
from fastapi import APIRouter, HTTPException
from sqlmodel import func, select
from app.api.deps import CurrentUser, SessionDep
from app.constants import BAD_REQUEST_CODE, CONFLICT_CODE, NOT_FOUND_CODE

Copilot uses AI. Check for mistakes.


from app.core.currency import Currency, apply_fee, convert, quantize_money
from app.models import (
Transaction,
TransactionCreate,
TransactionPublic,
Wallet,
WalletCreate,
WalletPublic,
)

router = APIRouter(prefix="/wallets", tags=["wallets"])


@router.post("/")
def create_wallet(
*, session: SessionDep, current_user: CurrentUser, wallet_in: WalletCreate
) -> WalletPublic:
"""Create a wallet for the current user in the given currency.

Rules:
- Max 3 wallets per user
- One wallet per currency
- Balance starts at 0.00
"""
count_stmt = (
select(func.count())
.select_from(Wallet)
.where(Wallet.user_id == current_user.id)
)
current_count = session.exec(count_stmt).one()
if current_count >= 3:
raise HTTPException(
status_code=BAD_REQUEST_CODE, detail="User wallet limit reached"
)

existing_stmt = select(Wallet).where(
Wallet.user_id == current_user.id, Wallet.currency == wallet_in.currency
)
if session.exec(existing_stmt).first():
raise HTTPException(
status_code=CONFLICT_CODE, detail="Wallet for this currency already exists"
)

db_wallet = Wallet(
user_id=current_user.id,
currency=wallet_in.currency,
balance=Decimal("0.00"),
)
session.add(db_wallet)
session.commit()
session.refresh(db_wallet)
return WalletPublic.model_validate(db_wallet)


@router.get("/{wallet_id}")
def read_wallet(
*, session: SessionDep, current_user: CurrentUser, wallet_id: uuid.UUID
) -> WalletPublic:
db_wallet = session.get(Wallet, wallet_id)
if not db_wallet:
raise HTTPException(status_code=NOT_FOUND_CODE, detail="Wallet not found")
if not current_user.is_superuser and db_wallet.user_id != current_user.id:
raise HTTPException(
status_code=BAD_REQUEST_CODE, detail="Not enough permissions"
)
return WalletPublic.model_validate(db_wallet)


@router.post("/{wallet_id}/transactions")
def create_transaction(
*,
session: SessionDep,
current_user: CurrentUser,
wallet_id: uuid.UUID,
txn_in: TransactionCreate,
) -> TransactionPublic:
"""Create a credit/debit transaction on a wallet with currency conversion and fees.

- Credit: add to balance
- Debit: subtract from balance, cannot go negative
- If txn currency != wallet currency: convert and apply fee (on converted amount)
"""
db_wallet = session.get(Wallet, wallet_id)
if not db_wallet:
raise HTTPException(status_code=NOT_FOUND_CODE, detail="Wallet not found")
if not current_user.is_superuser and db_wallet.user_id != current_user.id:
raise HTTPException(
status_code=BAD_REQUEST_CODE, detail="Not enough permissions"
)

# Normalize amount to 2 decimals
amount = quantize_money(Decimal(txn_in.amount))
if amount <= 0:
raise HTTPException(
status_code=BAD_REQUEST_CODE, detail="Amount must be positive"
)

# Convert if currencies differ
cross = txn_in.currency != db_wallet.currency
effective_amount = (
convert(
amount, cast(Currency, txn_in.currency), cast(Currency, db_wallet.currency)
Comment on lines +112 to +114
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using cast() to force type compatibility suggests a potential type safety issue. Consider validating that txn_in.currency and db_wallet.currency are valid Currency values before conversion, or redesign the type system to ensure type safety without casting.

Suggested change
effective_amount = (
convert(
amount, cast(Currency, txn_in.currency), cast(Currency, db_wallet.currency)
try:
txn_currency = Currency(txn_in.currency)
wallet_currency = Currency(db_wallet.currency)
except ValueError:
raise HTTPException(
status_code=BAD_REQUEST_CODE,
detail="Invalid currency value"
)
effective_amount = (
convert(
amount, txn_currency, wallet_currency

Copilot uses AI. Check for mistakes.

)
if cross
else amount
)
effective_amount = apply_fee(effective_amount, cross_currency=cross)

new_balance = Decimal(db_wallet.balance)
if txn_in.type == "credit":
new_balance = quantize_money(new_balance + effective_amount)
else: # debit
if new_balance - effective_amount < Decimal("0.00"):
raise HTTPException(
status_code=BAD_REQUEST_CODE,
detail="Insufficient funds",
)
new_balance = quantize_money(new_balance - effective_amount)

# Persist transaction and update balance atomically
db_txn = Transaction(
wallet_id=db_wallet.id,
amount=amount, # store original amount in original currency for audit
type=txn_in.type,
currency=txn_in.currency,
)
db_wallet.balance = new_balance
session.add(db_txn)
session.add(db_wallet)
session.commit()
session.refresh(db_txn)
return TransactionPublic.model_validate(db_txn)
6 changes: 3 additions & 3 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ class Settings(BaseSettings): # type: ignore[explicit-any]
FIRST_SUPERUSER: EmailStr
FIRST_SUPERUSER_PASSWORD: str

@computed_field # type: ignore[prop-decorator]
@computed_field # type: ignore[prop-decorator]
@property
def all_cors_origins(self) -> list[str]:
"""Get all CORS origins."""
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
self.FRONTEND_HOST,
]

@computed_field # type: ignore[prop-decorator]
@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: # noqa: N802
"""Build database URI from configuration."""
Expand All @@ -99,7 +99,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: # noqa: N802
path=self.POSTGRES_DB,
)

@computed_field # type: ignore[prop-decorator]
@computed_field # type: ignore[prop-decorator]
@property
def emails_enabled(self) -> bool:
"""Check if email configuration is enabled."""
Expand Down
40 changes: 40 additions & 0 deletions backend/app/core/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Currency conversion utilities with fixed exchange rates and fees."""

from decimal import ROUND_HALF_UP, Decimal
from typing import Literal

Currency = Literal["USD", "EUR", "RUB"]

# Fixed exchange rates relative to USD for simplicity
RATES: dict[tuple[Currency, Currency], Decimal] = {
("USD", "USD"): Decimal("1.0"),
("USD", "EUR"): Decimal("0.90"),
("USD", "RUB"): Decimal("90.0"),
("EUR", "USD"): Decimal("1.1111111111"), # 1/0.90
("EUR", "EUR"): Decimal("1.0"),
("EUR", "RUB"): Decimal("100.0"),
("RUB", "USD"): Decimal("0.011"),
("RUB", "EUR"): Decimal("0.010"),
("RUB", "RUB"): Decimal("1.0"),
}

FEE_RATE = Decimal("0.01") # 1% fee on cross-currency operations


def quantize_money(value: Decimal) -> Decimal:
"""Quantize Decimal to two places using bankers' rounding policy."""
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def convert(amount: Decimal, from_currency: Currency, to_currency: Currency) -> Decimal:
"""Convert amount between currencies using fixed rates, quantized to 2 decimals."""
rate = RATES[(from_currency, to_currency)]
return quantize_money(amount * rate)


def apply_fee(amount: Decimal, *, cross_currency: bool) -> Decimal:
"""Apply a percentage fee if cross-currency conversion is used."""
if not cross_currency:
return quantize_money(amount)
fee = quantize_money(amount * FEE_RATE)
return quantize_money(amount - fee)
17 changes: 16 additions & 1 deletion backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
UserPublic,
UsersPublic,
)
from app.models.db_models import Item, User
from app.models.db_models import Item, Transaction, User, Wallet

# Item models
from app.models.item_models import (
Expand All @@ -30,6 +30,13 @@
UserUpdate,
UserUpdateMe,
)
from app.models.wallet_models import (
TransactionCreate,
TransactionPublic,
WalletCreate,
WalletPublic,
WalletsPublic,
)

__all__ = [
# API models
Expand All @@ -42,6 +49,8 @@
# Database models
"Item",
"User",
"Wallet",
"Transaction",
# Item models
"ItemBase",
"ItemCreate",
Expand All @@ -55,4 +64,10 @@
"UserRegister",
"UserUpdate",
"UserUpdateMe",
# Wallet models
"WalletCreate",
"WalletPublic",
"WalletsPublic",
"TransactionCreate",
"TransactionPublic",
]
Loading