Skip to content

Commit d7eaf21

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:14m28s My own comments: + Created migration - A little mess in models now
1 parent b59f9ba commit d7eaf21

File tree

12 files changed

+661
-67
lines changed

12 files changed

+661
-67
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
applyTo: '**'
3+
---
4+
5+
You are a senior software engineer embedded in the user’s repository. Your job is to produce precise, minimal, and correct code changes that fit the project’s conventions. Be pragmatic, security-minded, and focused.
6+
7+
STRICTLY AND ALWAYS FOLLOW THIS INSTRUCTIONS:
8+
9+
At the end of your work ALWAYS ADD A STEP TO REVIEW for following rules: <general_rules>, <self_reflection>, <python_rules>.
10+
11+
<self_reflection>
12+
13+
1. Before replying, privately evaluate your draft against a 5–7 point rubric (correctness, safety, style consistency, scope focus, testability, clarity, performance). Do NOT reveal this rubric or your internal reasoning.
14+
2. If any rubric area would score <98/100, refine internally until it would pass.
15+
3. Align with the project’s code style and architecture. Do not introduce new patterns when a local precedent exists. ALWAYS Check existing code patterns (folder structure, dependency injection, error handling, logging, naming, async patterns, i18n).
16+
4. If a code change is not aligned with the project’s code style, refine changes internally until it would be aligned.
17+
</self_reflection>
18+
19+
<general_rules>
20+
21+
1. USE the language of USER message
22+
2. NEVER implement tests or write a documentation IF USER DID NOT REQUEST IT.
23+
3. If you run a terminal command, ALWAYS wait for its completion for 10 seconds, then read full output before responding.
24+
4. AVOID GENERAL naming and SHORTHAND like `data`, `info`, `value`, `item`, `i`, `exc` and etc. ALWAYS use SPECIFIC names that reflect the purpose and content of the variable.
25+
5. Keep your changes MINIMAL and FOCUSED on the USER request. Do NOT make unrelated improvements.
26+
6. ALWAYS check code for unused imports, variables, or functions. Remove them if found.
27+
7. BREAK complex logic into helper functions.
28+
8. BE SPECIFIC in handling: Language-level edge cases, Algorithmic complexity, Domain-specific constraints.
29+
9. NO MAGIC NUMBERS: Replace with correctly named constants.
30+
</general_rules>
31+
32+
<python_rules>
33+
34+
## STRONG TYPING RULES
35+
36+
- ALWAYS ADD **explicit type hints** to:
37+
- All function arguments and return values
38+
- All variable declarations where type isn't obvious
39+
- USE BUILT-IN GENERICS:
40+
- `list`, `dict`, `set`, `tuple` instead of `List`, `Dict`, `Set`, `Tuple` etc.
41+
- `type1 | type2` instead of `Union[type1, type2]`
42+
- `type | None` instead of `Optional[type]`
43+
- PREFER PRECISE TYPES over `Any`; AVOID `Any` UNLESS ABSOLUTELY REQUIRED
44+
- USE:
45+
- `Final[...]` for constants. Do NOT USE `list` or `dict` as constants; use `tuple` or `MappingProxyType` instead
46+
- `ClassVar[...]` for class-level variables shared across instances
47+
- `Self` for methods that return an instance of the class
48+
- For complex types, DEFINE CUSTOM TYPE ALIASES using `TypeAlias` for clarity
49+
50+
## CODE STYLE PRINCIPLES
51+
52+
- USE `f-strings` for all string formatting
53+
- PREFER **list/dict/set comprehensions** over manual loops when constructing collections
54+
- ALWAYS USE `with` context managers for file/resource handling
55+
- USE `__` AND `_` prefixes for methods/variables to indicate private/protected scope.
56+
- AVOID DEEP NESTING, prefer early returns and helper functions to flatten logic
57+
- USE Enums (StrEnum, IntEnum) for sets of related constants instead of plain strings/ints
58+
- ORGANIZE imports:
59+
- Standard library imports first
60+
- Third-party imports next
61+
- Local application imports last
62+
- WITHIN each group, SORT alphabetically
63+
- Use `datetime.UTC` instead of `timezone.utc` for UTC timezone
64+
65+
## DOCSTRINGS & COMMENTS
66+
67+
- ADD triple-quoted docstrings to all **public functions and classes**
68+
- USE **Google-style** docstring formatting
69+
- DESCRIBE parameters, return types, and side effects if any
70+
- OMIT OBVIOUS COMMENTS: clean code is self-explanatory
71+
72+
## ERROR HANDLING
73+
74+
- KEEP try/except blocks minimal; wrap a line of code that may throw in function with a clear exception handling strategy
75+
- AVOID bare `except:` blocks — ALWAYS CATCH specific exception types
76+
- AVOID using general exceptions like `Exception` or `BaseException`
77+
- FAIL FAST: Validate inputs and raise `ValueError` / `TypeError` when appropriate
78+
- USE `logging.exception()` to log errors with exception stack traces
79+
80+
## GENERAL INSTRUCTIONS
81+
82+
- DO NOT USE `@staticmethod`, prefer `@classmethod` or functions instead
83+
- USE `@classmethod` for alternative constructors or class-level utilities (no `@staticmethod`)
84+
- ALWAYS USE package managers for dependencies and virtual environments management; If package manager not specified, DEFAULT TO `pip` and `venv`
85+
- FOLLOW the **Zen of Python (PEP 20)** to guide decisions on clarity and simplicity
86+
87+
ENFORCE ALL OF THE ABOVE IN EVERY GENERATED SNIPPET, CLASS, FUNCTION, AND MODULE.
88+
</python_rules>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
Add wallet and transaction models.
3+
4+
Revision ID: 7b2a1c4e3f10
5+
Revises: 1a31ce608336
6+
Create Date: 2025-09-16 06:30:00.000000
7+
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import sqlalchemy as sa
13+
from alembic import op
14+
from sqlalchemy.dialects import postgresql
15+
16+
# revision identifiers, used by Alembic.
17+
revision = "7b2a1c4e3f10"
18+
down_revision = "1a31ce608336"
19+
branch_labels: str | None = None
20+
depends_on: str | None = None
21+
22+
23+
def upgrade() -> None:
24+
"""Upgrade database schema by creating wallet and transaction tables."""
25+
# Ensure uuid extension exists (PostgreSQL)
26+
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
27+
28+
op.create_table(
29+
"wallet",
30+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
31+
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
32+
sa.Column("balance", sa.Numeric(18, 2), nullable=False, server_default="0.00"),
33+
sa.Column("currency", sa.String(length=3), nullable=False),
34+
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
35+
)
36+
op.create_index("ix_wallet_user_id", "wallet", ["user_id"], unique=False)
37+
38+
op.create_table(
39+
"transaction",
40+
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
41+
sa.Column("wallet_id", postgresql.UUID(as_uuid=True), nullable=False),
42+
sa.Column("amount", sa.Numeric(18, 2), nullable=False),
43+
sa.Column("type", sa.String(length=10), nullable=False),
44+
sa.Column(
45+
"timestamp",
46+
sa.TIMESTAMP(timezone=True),
47+
nullable=False,
48+
server_default=sa.text("NOW()"),
49+
),
50+
sa.Column("currency", sa.String(length=3), nullable=False),
51+
sa.ForeignKeyConstraint(["wallet_id"], ["wallet.id"], ondelete="CASCADE"),
52+
)
53+
op.create_index(
54+
"ix_transaction_wallet_id", "transaction", ["wallet_id"], unique=False
55+
)
56+
57+
58+
def downgrade() -> None:
59+
"""Downgrade database schema by dropping new tables."""
60+
op.drop_index("ix_transaction_wallet_id", table_name="transaction")
61+
op.drop_table("transaction")
62+
op.drop_index("ix_wallet_user_id", table_name="wallet")
63+
op.drop_table("wallet")

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: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Wallet management API endpoints."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
from decimal import ROUND_HALF_UP, Decimal
7+
8+
from app.api.deps import CurrentUser, SessionDep
9+
from app.constants import (
10+
BAD_REQUEST_CODE,
11+
CONFLICT_CODE,
12+
CROSS_CURRENCY_FEE_RATE,
13+
DECIMAL_QUANT,
14+
EXCHANGE_RATES,
15+
MAX_WALLETS_PER_USER,
16+
NOT_FOUND_CODE,
17+
Currency,
18+
TransactionType,
19+
)
20+
from app.models import (
21+
Transaction,
22+
TransactionCreate,
23+
TransactionPublic,
24+
Wallet,
25+
WalletCreate,
26+
WalletPublic,
27+
)
28+
from fastapi import APIRouter, HTTPException
29+
from sqlmodel import func, select
30+
31+
router = APIRouter(prefix="/wallets", tags=["wallets"])
32+
33+
34+
def _quantize(amount: Decimal) -> Decimal:
35+
"""Quantize a decimal amount to two decimal places with HALF_UP rounding."""
36+
37+
return amount.quantize(DECIMAL_QUANT, rounding=ROUND_HALF_UP)
38+
39+
40+
def _convert_amount(
41+
amount: Decimal,
42+
from_currency: Currency,
43+
to_currency: Currency,
44+
) -> tuple[Decimal, bool]:
45+
"""Convert amount between currencies using fixed rates.
46+
47+
Args:
48+
amount: Original amount in from_currency.
49+
from_currency: Source currency.
50+
to_currency: Target currency (wallet currency).
51+
52+
Returns:
53+
Tuple of (converted_amount_in_target_currency, is_cross_currency)
54+
"""
55+
56+
if from_currency == to_currency:
57+
return (_quantize(amount), False)
58+
rate = EXCHANGE_RATES.get((from_currency, to_currency))
59+
if rate is None:
60+
raise HTTPException(
61+
status_code=BAD_REQUEST_CODE,
62+
detail="Unsupported currency conversion",
63+
)
64+
converted = _quantize(amount * rate)
65+
return (converted, True)
66+
67+
68+
@router.post("/")
69+
def create_wallet(
70+
*,
71+
session: SessionDep,
72+
current_user: CurrentUser,
73+
wallet_in: WalletCreate,
74+
) -> WalletPublic:
75+
"""Create a new wallet for the current user.
76+
77+
Enforces a maximum of three wallets per user and initializes balance to 0.00.
78+
"""
79+
80+
# Enforce wallet limit per user
81+
count_statement = (
82+
select(func.count())
83+
.select_from(Wallet)
84+
.where(Wallet.user_id == current_user.id)
85+
)
86+
user_wallet_count = session.exec(count_statement).one()
87+
if user_wallet_count >= MAX_WALLETS_PER_USER:
88+
raise HTTPException(
89+
status_code=CONFLICT_CODE,
90+
detail="User has reached the maximum number of wallets",
91+
)
92+
93+
db_wallet = Wallet(
94+
user_id=current_user.id,
95+
balance=Decimal("0.00"),
96+
currency=wallet_in.currency,
97+
)
98+
session.add(db_wallet)
99+
session.commit()
100+
session.refresh(db_wallet)
101+
return WalletPublic.model_validate(db_wallet)
102+
103+
104+
def _ensure_wallet_access(wallet: Wallet | None, current_user: CurrentUser) -> Wallet:
105+
if not wallet:
106+
raise HTTPException(status_code=NOT_FOUND_CODE, detail="Wallet not found")
107+
if not current_user.is_superuser and wallet.user_id != current_user.id:
108+
raise HTTPException(
109+
status_code=BAD_REQUEST_CODE,
110+
detail="Not enough permissions",
111+
)
112+
return wallet
113+
114+
115+
@router.get("/{wallet_id}")
116+
def read_wallet(
117+
session: SessionDep,
118+
current_user: CurrentUser,
119+
wallet_id: uuid.UUID,
120+
) -> WalletPublic:
121+
"""Retrieve wallet details, including current balance."""
122+
123+
db_wallet = session.get(Wallet, wallet_id)
124+
_ensure_wallet_access(db_wallet, current_user)
125+
return WalletPublic.model_validate(db_wallet)
126+
127+
128+
@router.post("/{wallet_id}/transactions")
129+
def create_transaction(
130+
*,
131+
session: SessionDep,
132+
current_user: CurrentUser,
133+
wallet_id: uuid.UUID,
134+
tx_in: TransactionCreate,
135+
) -> TransactionPublic:
136+
"""Create a credit or debit transaction for a wallet.
137+
138+
- Credits add to the wallet balance.
139+
- Debits subtract from the wallet balance (cannot go negative).
140+
- Cross-currency transactions are converted and charged a fee.
141+
"""
142+
143+
if tx_in.amount <= Decimal("0"):
144+
raise HTTPException(
145+
status_code=BAD_REQUEST_CODE,
146+
detail="Amount must be greater than zero",
147+
)
148+
149+
db_wallet = session.get(Wallet, wallet_id)
150+
wallet = _ensure_wallet_access(db_wallet, current_user)
151+
152+
# Convert amount to wallet currency if needed
153+
converted_amount, is_cross = _convert_amount(
154+
amount=tx_in.amount,
155+
from_currency=tx_in.currency,
156+
to_currency=wallet.currency,
157+
)
158+
159+
# Apply cross-currency fee on the converted amount
160+
if is_cross and CROSS_CURRENCY_FEE_RATE > 0:
161+
fee_amount = _quantize(converted_amount * CROSS_CURRENCY_FEE_RATE)
162+
net_amount = _quantize(converted_amount - fee_amount)
163+
else:
164+
net_amount = converted_amount
165+
166+
# Adjust balance based on transaction type
167+
if tx_in.type == TransactionType.CREDIT:
168+
new_balance = _quantize(wallet.balance + net_amount)
169+
else: # DEBIT
170+
new_balance = _quantize(wallet.balance - net_amount)
171+
if new_balance < Decimal("0"):
172+
raise HTTPException(
173+
status_code=BAD_REQUEST_CODE,
174+
detail="Insufficient funds for debit transaction",
175+
)
176+
177+
wallet.balance = new_balance
178+
session.add(wallet)
179+
180+
db_tx = Transaction(
181+
wallet_id=wallet.id,
182+
amount=_quantize(tx_in.amount), # store original amount
183+
type=tx_in.type,
184+
currency=tx_in.currency,
185+
)
186+
session.add(db_tx)
187+
session.commit()
188+
session.refresh(db_tx)
189+
return TransactionPublic.model_validate(db_tx)

0 commit comments

Comments
 (0)