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
88 changes: 88 additions & 0 deletions .github/instructions/general.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
applyTo: '**'
---

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.

STRICTLY AND ALWAYS FOLLOW THIS INSTRUCTIONS:

At the end of your work ALWAYS ADD A STEP TO REVIEW for following rules: <general_rules>, <self_reflection>, <python_rules>.

<self_reflection>

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.
2. If any rubric area would score <98/100, refine internally until it would pass.
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).
4. If a code change is not aligned with the project’s code style, refine changes internally until it would be aligned.
</self_reflection>

<general_rules>

1. USE the language of USER message
2. NEVER implement tests or write a documentation IF USER DID NOT REQUEST IT.
3. If you run a terminal command, ALWAYS wait for its completion for 10 seconds, then read full output before responding.
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.
5. Keep your changes MINIMAL and FOCUSED on the USER request. Do NOT make unrelated improvements.
6. ALWAYS check code for unused imports, variables, or functions. Remove them if found.
7. BREAK complex logic into helper functions.
8. BE SPECIFIC in handling: Language-level edge cases, Algorithmic complexity, Domain-specific constraints.
9. NO MAGIC NUMBERS: Replace with correctly named constants.
</general_rules>

<python_rules>

## STRONG TYPING RULES

- ALWAYS ADD **explicit type hints** to:
- All function arguments and return values
- All variable declarations where type isn't obvious
- USE BUILT-IN GENERICS:
- `list`, `dict`, `set`, `tuple` instead of `List`, `Dict`, `Set`, `Tuple` etc.
- `type1 | type2` instead of `Union[type1, type2]`
- `type | None` instead of `Optional[type]`
- PREFER PRECISE TYPES over `Any`; AVOID `Any` UNLESS ABSOLUTELY REQUIRED
- USE:
- `Final[...]` for constants. Do NOT USE `list` or `dict` as constants; use `tuple` or `MappingProxyType` instead
- `ClassVar[...]` for class-level variables shared across instances
- `Self` for methods that return an instance of the class
- For complex types, DEFINE CUSTOM TYPE ALIASES using `TypeAlias` for clarity

## CODE STYLE PRINCIPLES

- USE `f-strings` for all string formatting
- PREFER **list/dict/set comprehensions** over manual loops when constructing collections
- ALWAYS USE `with` context managers for file/resource handling
- USE `__` AND `_` prefixes for methods/variables to indicate private/protected scope.
- AVOID DEEP NESTING, prefer early returns and helper functions to flatten logic
- USE Enums (StrEnum, IntEnum) for sets of related constants instead of plain strings/ints
- ORGANIZE imports:
- Standard library imports first
- Third-party imports next
- Local application imports last
- WITHIN each group, SORT alphabetically
- Use `datetime.UTC` instead of `timezone.utc` for UTC timezone

## DOCSTRINGS & COMMENTS

- ADD triple-quoted docstrings to all **public functions and classes**
- USE **Google-style** docstring formatting
- DESCRIBE parameters, return types, and side effects if any
- OMIT OBVIOUS COMMENTS: clean code is self-explanatory

## ERROR HANDLING

- KEEP try/except blocks minimal; wrap a line of code that may throw in function with a clear exception handling strategy
- AVOID bare `except:` blocks — ALWAYS CATCH specific exception types
- AVOID using general exceptions like `Exception` or `BaseException`
- FAIL FAST: Validate inputs and raise `ValueError` / `TypeError` when appropriate
- USE `logging.exception()` to log errors with exception stack traces

## GENERAL INSTRUCTIONS

- DO NOT USE `@staticmethod`, prefer `@classmethod` or functions instead
- USE `@classmethod` for alternative constructors or class-level utilities (no `@staticmethod`)
- ALWAYS USE package managers for dependencies and virtual environments management; If package manager not specified, DEFAULT TO `pip` and `venv`
- FOLLOW the **Zen of Python (PEP 20)** to guide decisions on clarity and simplicity

ENFORCE ALL OF THE ABOVE IN EVERY GENERATED SNIPPET, CLASS, FUNCTION, AND MODULE.
</python_rules>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Add wallet and transaction models.

Revision ID: 7b2a1c4e3f10
Revises: 1a31ce608336
Create Date: 2025-09-16 06:30:00.000000

"""

from __future__ import annotations

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

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


def upgrade() -> None:
"""Upgrade database schema by creating wallet and transaction tables."""
# Ensure uuid extension exists (PostgreSQL)
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')

op.create_table(
"wallet",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("balance", sa.Numeric(18, 2), nullable=False, server_default="0.00"),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
)
op.create_index("ix_wallet_user_id", "wallet", ["user_id"], unique=False)

op.create_table(
"transaction",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("wallet_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("amount", sa.Numeric(18, 2), nullable=False),
sa.Column("type", sa.String(length=10), nullable=False),
sa.Column(
"timestamp",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.text("NOW()"),
),
sa.Column("currency", sa.String(length=3), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallet.id"], ondelete="CASCADE"),
)
op.create_index(
"ix_transaction_wallet_id", "transaction", ["wallet_id"], unique=False
)


def downgrade() -> None:
"""Downgrade database schema by dropping new tables."""
op.drop_index("ix_transaction_wallet_id", table_name="transaction")
op.drop_table("transaction")
op.drop_index("ix_wallet_user_id", table_name="wallet")
op.drop_table("wallet")
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
189 changes: 189 additions & 0 deletions backend/app/api/routes/wallets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Wallet management API endpoints."""

from __future__ import annotations

import uuid
from decimal import ROUND_HALF_UP, Decimal

from app.api.deps import CurrentUser, SessionDep
from app.constants import (
BAD_REQUEST_CODE,
CONFLICT_CODE,
CROSS_CURRENCY_FEE_RATE,
DECIMAL_QUANT,
EXCHANGE_RATES,
MAX_WALLETS_PER_USER,
NOT_FOUND_CODE,
Currency,
TransactionType,
)
from app.models import (
Transaction,
TransactionCreate,
TransactionPublic,
Wallet,
WalletCreate,
WalletPublic,
)
from fastapi import APIRouter, HTTPException
from sqlmodel import func, select

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


def _quantize(amount: Decimal) -> Decimal:
"""Quantize a decimal amount to two decimal places with HALF_UP rounding."""

return amount.quantize(DECIMAL_QUANT, rounding=ROUND_HALF_UP)


def _convert_amount(
amount: Decimal,
from_currency: Currency,
to_currency: Currency,
) -> tuple[Decimal, bool]:
"""Convert amount between currencies using fixed rates.

Args:
amount: Original amount in from_currency.
from_currency: Source currency.
to_currency: Target currency (wallet currency).

Returns:
Tuple of (converted_amount_in_target_currency, is_cross_currency)
"""

if from_currency == to_currency:
return (_quantize(amount), False)
rate = EXCHANGE_RATES.get((from_currency, to_currency))
if rate is None:
raise HTTPException(
status_code=BAD_REQUEST_CODE,
detail="Unsupported currency conversion",
)
converted = _quantize(amount * rate)
return (converted, True)


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

Enforces a maximum of three wallets per user and initializes balance to 0.00.
"""

# Enforce wallet limit per user
count_statement = (
select(func.count())
.select_from(Wallet)
.where(Wallet.user_id == current_user.id)
)
user_wallet_count = session.exec(count_statement).one()
if user_wallet_count >= MAX_WALLETS_PER_USER:
raise HTTPException(
status_code=CONFLICT_CODE,
detail="User has reached the maximum number of wallets",
)

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


def _ensure_wallet_access(wallet: Wallet | None, current_user: CurrentUser) -> Wallet:
if not wallet:
raise HTTPException(status_code=NOT_FOUND_CODE, detail="Wallet not found")
if not current_user.is_superuser and wallet.user_id != current_user.id:
raise HTTPException(
status_code=BAD_REQUEST_CODE,
detail="Not enough permissions",
)
return wallet


@router.get("/{wallet_id}")
def read_wallet(
session: SessionDep,
current_user: CurrentUser,
wallet_id: uuid.UUID,
) -> WalletPublic:
"""Retrieve wallet details, including current balance."""

db_wallet = session.get(Wallet, wallet_id)
_ensure_wallet_access(db_wallet, current_user)
return WalletPublic.model_validate(db_wallet)


@router.post("/{wallet_id}/transactions")
def create_transaction(
*,
session: SessionDep,
current_user: CurrentUser,
wallet_id: uuid.UUID,
tx_in: TransactionCreate,
) -> TransactionPublic:
"""Create a credit or debit transaction for a wallet.

- Credits add to the wallet balance.
- Debits subtract from the wallet balance (cannot go negative).
- Cross-currency transactions are converted and charged a fee.
"""

if tx_in.amount <= Decimal("0"):
raise HTTPException(
status_code=BAD_REQUEST_CODE,
detail="Amount must be greater than zero",
)

db_wallet = session.get(Wallet, wallet_id)
wallet = _ensure_wallet_access(db_wallet, current_user)

# Convert amount to wallet currency if needed
converted_amount, is_cross = _convert_amount(
amount=tx_in.amount,
from_currency=tx_in.currency,
to_currency=wallet.currency,
)

# Apply cross-currency fee on the converted amount
if is_cross and CROSS_CURRENCY_FEE_RATE > 0:
fee_amount = _quantize(converted_amount * CROSS_CURRENCY_FEE_RATE)
net_amount = _quantize(converted_amount - fee_amount)
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

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

Cross-currency fees are only applied to credit transactions, but the business logic should apply fees to both credit and debit transactions when currencies differ. Currently, debit transactions in different currencies avoid fees entirely.

Suggested change
net_amount = _quantize(converted_amount - fee_amount)
if tx_in.type == TransactionType.CREDIT:
net_amount = _quantize(converted_amount - fee_amount)
else: # DEBIT
net_amount = _quantize(converted_amount + fee_amount)

Copilot uses AI. Check for mistakes.

else:
net_amount = converted_amount

# Adjust balance based on transaction type
if tx_in.type == TransactionType.CREDIT:
new_balance = _quantize(wallet.balance + net_amount)
else: # DEBIT
new_balance = _quantize(wallet.balance - net_amount)
if new_balance < Decimal("0"):
raise HTTPException(
status_code=BAD_REQUEST_CODE,
detail="Insufficient funds for debit transaction",
)

wallet.balance = new_balance
session.add(wallet)

db_tx = Transaction(
wallet_id=wallet.id,
amount=_quantize(tx_in.amount), # store original amount
type=tx_in.type,
currency=tx_in.currency,
Copy link

Copilot AI Sep 16, 2025

Choose a reason for hiding this comment

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

Storing only the original transaction amount loses important audit information. Consider storing both the original amount and the converted amount (with fees) for better transaction tracking and reconciliation.

Suggested change
currency=tx_in.currency,
currency=tx_in.currency,
converted_amount=net_amount, # store converted/net amount in wallet currency
converted_currency=wallet.currency, # store wallet currency
fee_amount=fee_amount if is_cross and CROSS_CURRENCY_FEE_RATE > 0 else Decimal("0.00"), # store fee if any

Copilot uses AI. Check for mistakes.

)
session.add(db_tx)
session.commit()
session.refresh(db_tx)
return TransactionPublic.model_validate(db_tx)
Loading