Skip to content

Commit 2cdcc54

Browse files
committed
# Implemented backend task
Created a backend endpoints which implements following functionality: - Introduced a new entity Wallet and Transaction. - Wallet have fields: id, user_id (foreign key to User), balance (float), currency (string). - Available currencies: USD, EUR, RUB. - Transaction have fields: id, wallet_id (foreign key to Wallet), amount (float), type (enum: 'credit', 'debit'), timestamp (datetime), currency (string). - Implemented endpoint to create a wallet for a user. - Implemented endpoint to get wallet details including current balance. - Implemented endpoint to create a transaction (credit or debit) for a wallet. # Rules for wallet - A user can have three wallets. - Wallet balance should start at 0.0. - Arithmetic operations on balance should be precise up to two decimal places. # Rules for transaction - For 'credit' transactions, the amount should be added to the wallet balance. - For 'debit' transactions, the amount should be subtracted from the wallet balance. - Ensure that the wallet balance cannot go negative. If a debit transaction would cause the balance to go negative, the transaction should be rejected with an appropriate error message. - Transaction between wallets with different currencies must be converted using a fixed exchange rate (you can hardcode some exchange rates for simplicity) and fees should be applied. Duration: 15m 22s + Migration created - Business logic is delegated to db layer - Added tests but did not asked
1 parent b59f9ba commit 2cdcc54

12 files changed

Lines changed: 647 additions & 69 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Add wallet and transaction tables.
3+
4+
Revision ID: 20250915_add_wallets_transactions
5+
Revises: 1a31ce608336
6+
Create Date: 2025-09-15
7+
8+
"""
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
from sqlalchemy.dialects import postgresql
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "20250915_add_wallets_transactions"
16+
down_revision = "1a31ce608336"
17+
branch_labels: str | None = None
18+
depends_on: str | None = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"wallet",
24+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
25+
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
26+
sa.Column("currency", sa.String(length=255), nullable=False),
27+
sa.Column("balance", sa.Numeric(18, 2), nullable=False, server_default="0.00"),
28+
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
29+
sa.PrimaryKeyConstraint("id"),
30+
sa.UniqueConstraint("user_id", "currency", name="uq_wallet_user_currency"),
31+
)
32+
33+
transaction_type = sa.Enum("credit", "debit", name="transaction_type")
34+
transaction_type.create(op.get_bind(), checkfirst=True)
35+
36+
op.create_table(
37+
"transaction",
38+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
39+
sa.Column("wallet_id", postgresql.UUID(as_uuid=True), nullable=False),
40+
sa.Column("amount", sa.Numeric(18, 2), nullable=False),
41+
sa.Column("type", transaction_type, nullable=False),
42+
sa.Column(
43+
"timestamp",
44+
sa.DateTime(timezone=True),
45+
nullable=False,
46+
server_default=sa.text("now()"),
47+
),
48+
sa.Column("currency", sa.String(length=255), nullable=False),
49+
sa.ForeignKeyConstraint(["wallet_id"], ["wallet.id"], ondelete="CASCADE"),
50+
sa.PrimaryKeyConstraint("id"),
51+
)
52+
53+
54+
def downgrade() -> None:
55+
op.drop_table("transaction")
56+
op.drop_table("wallet")
57+
sa.Enum(name="transaction_type").drop(op.get_bind(), checkfirst=True)

backend/app/api/main.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
"""API router configuration."""
22

3-
from fastapi import APIRouter
4-
5-
from app.api.routes import items, login, misc, private, users
3+
from app.api.routes import items, login, misc, private, users, wallets
64
from app.core.config import settings
5+
from fastapi import APIRouter
76

87
api_router = APIRouter()
98
api_router.include_router(login.router)
109
api_router.include_router(users.router)
1110
api_router.include_router(misc.router)
1211
api_router.include_router(items.router)
12+
api_router.include_router(wallets.router)
1313

1414

1515
if settings.ENVIRONMENT == "local":

backend/app/api/routes/wallets.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Wallet and Transaction management endpoints."""
2+
3+
import uuid
4+
from decimal import Decimal
5+
6+
from fastapi import APIRouter, HTTPException
7+
from sqlmodel import func, select
8+
9+
from app.api.deps import CurrentUser, SessionDep
10+
from app.constants import BAD_REQUEST_CODE, CONFLICT_CODE, NOT_FOUND_CODE
11+
from typing import cast
12+
13+
from app.core.currency import Currency, apply_fee, convert, quantize_money
14+
from app.models import (
15+
Transaction,
16+
TransactionCreate,
17+
TransactionPublic,
18+
Wallet,
19+
WalletCreate,
20+
WalletPublic,
21+
)
22+
23+
router = APIRouter(prefix="/wallets", tags=["wallets"])
24+
25+
26+
@router.post("/")
27+
def create_wallet(
28+
*, session: SessionDep, current_user: CurrentUser, wallet_in: WalletCreate
29+
) -> WalletPublic:
30+
"""Create a wallet for the current user in the given currency.
31+
32+
Rules:
33+
- Max 3 wallets per user
34+
- One wallet per currency
35+
- Balance starts at 0.00
36+
"""
37+
count_stmt = (
38+
select(func.count())
39+
.select_from(Wallet)
40+
.where(Wallet.user_id == current_user.id)
41+
)
42+
current_count = session.exec(count_stmt).one()
43+
if current_count >= 3:
44+
raise HTTPException(
45+
status_code=BAD_REQUEST_CODE, detail="User wallet limit reached"
46+
)
47+
48+
existing_stmt = select(Wallet).where(
49+
Wallet.user_id == current_user.id, Wallet.currency == wallet_in.currency
50+
)
51+
if session.exec(existing_stmt).first():
52+
raise HTTPException(
53+
status_code=CONFLICT_CODE, detail="Wallet for this currency already exists"
54+
)
55+
56+
db_wallet = Wallet(
57+
user_id=current_user.id,
58+
currency=wallet_in.currency,
59+
balance=Decimal("0.00"),
60+
)
61+
session.add(db_wallet)
62+
session.commit()
63+
session.refresh(db_wallet)
64+
return WalletPublic.model_validate(db_wallet)
65+
66+
67+
@router.get("/{wallet_id}")
68+
def read_wallet(
69+
*, session: SessionDep, current_user: CurrentUser, wallet_id: uuid.UUID
70+
) -> WalletPublic:
71+
db_wallet = session.get(Wallet, wallet_id)
72+
if not db_wallet:
73+
raise HTTPException(status_code=NOT_FOUND_CODE, detail="Wallet not found")
74+
if not current_user.is_superuser and db_wallet.user_id != current_user.id:
75+
raise HTTPException(
76+
status_code=BAD_REQUEST_CODE, detail="Not enough permissions"
77+
)
78+
return WalletPublic.model_validate(db_wallet)
79+
80+
81+
@router.post("/{wallet_id}/transactions")
82+
def create_transaction(
83+
*,
84+
session: SessionDep,
85+
current_user: CurrentUser,
86+
wallet_id: uuid.UUID,
87+
txn_in: TransactionCreate,
88+
) -> TransactionPublic:
89+
"""Create a credit/debit transaction on a wallet with currency conversion and fees.
90+
91+
- Credit: add to balance
92+
- Debit: subtract from balance, cannot go negative
93+
- If txn currency != wallet currency: convert and apply fee (on converted amount)
94+
"""
95+
db_wallet = session.get(Wallet, wallet_id)
96+
if not db_wallet:
97+
raise HTTPException(status_code=NOT_FOUND_CODE, detail="Wallet not found")
98+
if not current_user.is_superuser and db_wallet.user_id != current_user.id:
99+
raise HTTPException(
100+
status_code=BAD_REQUEST_CODE, detail="Not enough permissions"
101+
)
102+
103+
# Normalize amount to 2 decimals
104+
amount = quantize_money(Decimal(txn_in.amount))
105+
if amount <= 0:
106+
raise HTTPException(
107+
status_code=BAD_REQUEST_CODE, detail="Amount must be positive"
108+
)
109+
110+
# Convert if currencies differ
111+
cross = txn_in.currency != db_wallet.currency
112+
effective_amount = (
113+
convert(
114+
amount, cast(Currency, txn_in.currency), cast(Currency, db_wallet.currency)
115+
)
116+
if cross
117+
else amount
118+
)
119+
effective_amount = apply_fee(effective_amount, cross_currency=cross)
120+
121+
new_balance = Decimal(db_wallet.balance)
122+
if txn_in.type == "credit":
123+
new_balance = quantize_money(new_balance + effective_amount)
124+
else: # debit
125+
if new_balance - effective_amount < Decimal("0.00"):
126+
raise HTTPException(
127+
status_code=BAD_REQUEST_CODE,
128+
detail="Insufficient funds",
129+
)
130+
new_balance = quantize_money(new_balance - effective_amount)
131+
132+
# Persist transaction and update balance atomically
133+
db_txn = Transaction(
134+
wallet_id=db_wallet.id,
135+
amount=amount, # store original amount in original currency for audit
136+
type=txn_in.type,
137+
currency=txn_in.currency,
138+
)
139+
db_wallet.balance = new_balance
140+
session.add(db_txn)
141+
session.add(db_wallet)
142+
session.commit()
143+
session.refresh(db_txn)
144+
return TransactionPublic.model_validate(db_txn)

backend/app/core/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,15 @@ class Settings(BaseSettings): # type: ignore[explicit-any]
7878
FIRST_SUPERUSER: EmailStr
7979
FIRST_SUPERUSER_PASSWORD: str
8080

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

89-
@computed_field # type: ignore[prop-decorator]
89+
@computed_field # type: ignore[prop-decorator]
9090
@property
9191
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: # noqa: N802
9292
"""Build database URI from configuration."""
@@ -99,7 +99,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: # noqa: N802
9999
path=self.POSTGRES_DB,
100100
)
101101

102-
@computed_field # type: ignore[prop-decorator]
102+
@computed_field # type: ignore[prop-decorator]
103103
@property
104104
def emails_enabled(self) -> bool:
105105
"""Check if email configuration is enabled."""

backend/app/core/currency.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Currency conversion utilities with fixed exchange rates and fees."""
2+
3+
from decimal import ROUND_HALF_UP, Decimal
4+
from typing import Literal
5+
6+
Currency = Literal["USD", "EUR", "RUB"]
7+
8+
# Fixed exchange rates relative to USD for simplicity
9+
RATES: dict[tuple[Currency, Currency], Decimal] = {
10+
("USD", "USD"): Decimal("1.0"),
11+
("USD", "EUR"): Decimal("0.90"),
12+
("USD", "RUB"): Decimal("90.0"),
13+
("EUR", "USD"): Decimal("1.1111111111"), # 1/0.90
14+
("EUR", "EUR"): Decimal("1.0"),
15+
("EUR", "RUB"): Decimal("100.0"),
16+
("RUB", "USD"): Decimal("0.011"),
17+
("RUB", "EUR"): Decimal("0.010"),
18+
("RUB", "RUB"): Decimal("1.0"),
19+
}
20+
21+
FEE_RATE = Decimal("0.01") # 1% fee on cross-currency operations
22+
23+
24+
def quantize_money(value: Decimal) -> Decimal:
25+
"""Quantize Decimal to two places using bankers' rounding policy."""
26+
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
27+
28+
29+
def convert(amount: Decimal, from_currency: Currency, to_currency: Currency) -> Decimal:
30+
"""Convert amount between currencies using fixed rates, quantized to 2 decimals."""
31+
rate = RATES[(from_currency, to_currency)]
32+
return quantize_money(amount * rate)
33+
34+
35+
def apply_fee(amount: Decimal, *, cross_currency: bool) -> Decimal:
36+
"""Apply a percentage fee if cross-currency conversion is used."""
37+
if not cross_currency:
38+
return quantize_money(amount)
39+
fee = quantize_money(amount * FEE_RATE)
40+
return quantize_money(amount - fee)

backend/app/models/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
UserPublic,
1111
UsersPublic,
1212
)
13-
from app.models.db_models import Item, User
13+
from app.models.db_models import Item, Transaction, User, Wallet
1414

1515
# Item models
1616
from app.models.item_models import (
@@ -30,6 +30,13 @@
3030
UserUpdate,
3131
UserUpdateMe,
3232
)
33+
from app.models.wallet_models import (
34+
TransactionCreate,
35+
TransactionPublic,
36+
WalletCreate,
37+
WalletPublic,
38+
WalletsPublic,
39+
)
3340

3441
__all__ = [
3542
# API models
@@ -42,6 +49,8 @@
4249
# Database models
4350
"Item",
4451
"User",
52+
"Wallet",
53+
"Transaction",
4554
# Item models
4655
"ItemBase",
4756
"ItemCreate",
@@ -55,4 +64,10 @@
5564
"UserRegister",
5665
"UserUpdate",
5766
"UserUpdateMe",
67+
# Wallet models
68+
"WalletCreate",
69+
"WalletPublic",
70+
"WalletsPublic",
71+
"TransactionCreate",
72+
"TransactionPublic",
5873
]

0 commit comments

Comments
 (0)