|
| 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