"""Exchange routes for Bitcoin trading.""" import uuid from datetime import UTC, date, datetime, time, timedelta from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from auth import require_permission from database import get_db from date_validation import validate_date_in_range from exceptions import BadRequestError from mappers import ExchangeMapper from models import ( Availability, BitcoinTransferMethod, Exchange, ExchangeStatus, Permission, PriceHistory, TradeDirection, User, ) from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price from repositories.price import PriceRepository from schemas import ( AdminExchangeResponse, AvailableSlotsResponse, BookableSlot, ExchangeConfigResponse, ExchangePriceResponse, ExchangeRequest, ExchangeResponse, PriceResponse, UserSearchResult, ) from services.exchange import ExchangeService from shared_constants import ( EUR_TRADE_INCREMENT, EUR_TRADE_MAX, EUR_TRADE_MIN, PREMIUM_PERCENTAGE, SLOT_DURATION_MINUTES, ) router = APIRouter(prefix="/api/exchange", tags=["exchange"]) # ============================================================================= # Helper functions # ============================================================================= # ============================================================================= # Price Endpoint # ============================================================================= @router.get("/price", response_model=ExchangePriceResponse) async def get_exchange_price( db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)), ) -> ExchangePriceResponse: """ Get the current BTC/EUR price for trading. Returns the latest price from the database. If no price exists or the price is stale, attempts to fetch a fresh price from Bitfinex. The response includes: - market_price: The raw price from the exchange - premium_percentage: The premium to apply to trades - is_stale: Whether the price is older than 5 minutes - config: Trading configuration (min/max EUR, increment) """ config = ExchangeConfigResponse( eur_min=EUR_TRADE_MIN, eur_max=EUR_TRADE_MAX, eur_increment=EUR_TRADE_INCREMENT, premium_percentage=PREMIUM_PERCENTAGE, ) price_repo = PriceRepository(db) service = ExchangeService(db) # Try to get the latest cached price cached_price = await price_repo.get_latest() # If no cached price or it's stale, try to fetch a new one if cached_price is None or service.is_price_stale(cached_price.timestamp): try: price_value, timestamp = await fetch_btc_eur_price() # Store the new price new_price = PriceHistory( source=SOURCE_BITFINEX, pair=PAIR_BTC_EUR, price=price_value, timestamp=timestamp, ) db.add(new_price) await db.commit() await db.refresh(new_price) return ExchangePriceResponse( price=PriceResponse( market_price=price_value, premium_percentage=PREMIUM_PERCENTAGE, timestamp=timestamp, is_stale=False, ), config=config, ) except Exception as e: # If fetch fails and we have a cached price, return it with stale flag if cached_price is not None: return ExchangePriceResponse( price=PriceResponse( market_price=cached_price.price, premium_percentage=PREMIUM_PERCENTAGE, timestamp=cached_price.timestamp, is_stale=True, ), config=config, error=f"Failed to fetch fresh price: {e}", ) # No cached price and fetch failed return ExchangePriceResponse( price=None, config=config, error=f"Price unavailable: {e}", ) # Return the cached price (not stale) return ExchangePriceResponse( price=PriceResponse( market_price=cached_price.price, premium_percentage=PREMIUM_PERCENTAGE, timestamp=cached_price.timestamp, is_stale=service.is_price_stale(cached_price.timestamp), ), config=config, ) # ============================================================================= # Available Slots Endpoint # ============================================================================= def _expand_availability_to_slots( avail: Availability, slot_date: date, booked_starts: set[datetime] ) -> list[BookableSlot]: """ Expand an availability block into individual slots, filtering out booked ones. """ slots: list[BookableSlot] = [] # Start from the availability's start time current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC) avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC) while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end: slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES) # Only include if not already booked if current_start not in booked_starts: slots.append(BookableSlot(start_time=current_start, end_time=slot_end)) current_start = slot_end return slots @router.get("/slots", response_model=AvailableSlotsResponse) async def get_available_slots( date_param: date = Query(..., alias="date"), db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)), ) -> AvailableSlotsResponse: """ Get available booking slots for a specific date. Returns all slots that: - Fall within admin-defined availability windows - Are not already booked by another user """ validate_date_in_range(date_param, context="book") # Get availability for the date result = await db.execute( select(Availability).where(Availability.date == date_param) ) availabilities = result.scalars().all() if not availabilities: return AvailableSlotsResponse(date=date_param, slots=[]) # Get already booked slots for the date date_start = datetime.combine(date_param, time.min, tzinfo=UTC) date_end = datetime.combine(date_param, time.max, tzinfo=UTC) result = await db.execute( select(Exchange.slot_start).where( and_( Exchange.slot_start >= date_start, Exchange.slot_start <= date_end, Exchange.status == ExchangeStatus.BOOKED, ) ) ) booked_starts = {row[0] for row in result.all()} # Expand each availability into slots all_slots: list[BookableSlot] = [] for avail in availabilities: slots = _expand_availability_to_slots(avail, date_param, booked_starts) all_slots.extend(slots) # Sort by start time all_slots.sort(key=lambda s: s.start_time) return AvailableSlotsResponse(date=date_param, slots=all_slots) # ============================================================================= # Create Exchange Endpoint # ============================================================================= @router.post("", response_model=ExchangeResponse) async def create_exchange( request: ExchangeRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_permission(Permission.CREATE_EXCHANGE)), ) -> ExchangeResponse: """ Create a new exchange trade booking. Validates: - Slot is on a valid date and time boundary - Slot is within admin availability - Slot is not already booked - Price is not stale - EUR amount is within configured limits """ # Validate direction try: direction = TradeDirection(request.direction) except ValueError: raise BadRequestError( f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'." ) from None # Validate bitcoin transfer method try: bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method) except ValueError: raise BadRequestError( f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. " "Must be 'onchain' or 'lightning'." ) from None # Use service to create exchange (handles all validation) service = ExchangeService(db) exchange = await service.create_exchange( user=current_user, slot_start=request.slot_start, direction=direction, bitcoin_transfer_method=bitcoin_transfer_method, eur_amount=request.eur_amount, ) return ExchangeMapper.to_response(exchange, current_user.email) # ============================================================================= # User's Exchanges Endpoints # ============================================================================= trades_router = APIRouter(prefix="/api/trades", tags=["trades"]) @trades_router.get("", response_model=list[ExchangeResponse]) async def get_my_trades( db: AsyncSession = Depends(get_db), current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)), ) -> list[ExchangeResponse]: """Get the current user's exchanges, sorted by date (newest first).""" result = await db.execute( select(Exchange) .where(Exchange.user_id == current_user.id) .order_by(Exchange.slot_start.desc()) ) exchanges = result.scalars().all() return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges] @trades_router.get("/{public_id}", response_model=ExchangeResponse) async def get_my_trade( public_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)), ) -> ExchangeResponse: """Get a specific trade by public ID. User can only access their own trades.""" service = ExchangeService(db) exchange = await service.get_exchange_by_public_id(public_id, user=current_user) return ExchangeMapper.to_response(exchange, current_user.email) @trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse) async def cancel_my_trade( public_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)), ) -> ExchangeResponse: """Cancel one of the current user's exchanges.""" service = ExchangeService(db) # Get exchange without user filter first to check ownership separately exchange = await service.get_exchange_by_public_id(public_id) # Check ownership - return 403 if user doesn't own it if exchange.user_id != current_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot cancel another user's trade", ) exchange = await service.cancel_exchange(exchange, current_user, is_admin=False) return ExchangeMapper.to_response(exchange, current_user.email) # ============================================================================= # Admin Exchanges Endpoints # ============================================================================= admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"]) @admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse]) async def get_upcoming_trades( db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)), ) -> list[AdminExchangeResponse]: """Get all upcoming booked trades, sorted by slot time ascending.""" now = datetime.now(UTC) result = await db.execute( select(Exchange) .options(joinedload(Exchange.user)) .where( and_( Exchange.slot_start > now, Exchange.status == ExchangeStatus.BOOKED, ) ) .order_by(Exchange.slot_start.asc()) ) exchanges = result.scalars().all() return [ExchangeMapper.to_admin_response(ex) for ex in exchanges] @admin_trades_router.get("/past", response_model=list[AdminExchangeResponse]) async def get_past_trades( status: str | None = None, start_date: date | None = None, end_date: date | None = None, user_search: str | None = None, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)), ) -> list[AdminExchangeResponse]: """ Get past trades with optional filters. Filters: - status: Filter by exchange status - start_date, end_date: Filter by slot_start date range - user_search: Search by user email (partial match) """ now = datetime.now(UTC) # Start with base query for past trades (slot_start <= now OR not booked) query = ( select(Exchange) .options(joinedload(Exchange.user)) .where( (Exchange.slot_start <= now) | (Exchange.status != ExchangeStatus.BOOKED) ) ) # Apply status filter if status: try: status_enum = ExchangeStatus(status) query = query.where(Exchange.status == status_enum) except ValueError: raise HTTPException( status_code=400, detail=f"Invalid status: {status}", ) from None # Apply date range filter if start_date: start_dt = datetime.combine(start_date, time.min, tzinfo=UTC) query = query.where(Exchange.slot_start >= start_dt) if end_date: end_dt = datetime.combine(end_date, time.max, tzinfo=UTC) query = query.where(Exchange.slot_start <= end_dt) # Apply user search filter (join with User table) if user_search: query = query.join(Exchange.user).where(User.email.ilike(f"%{user_search}%")) # Order by most recent first query = query.order_by(Exchange.slot_start.desc()) result = await db.execute(query) exchanges = result.scalars().all() return [ExchangeMapper.to_admin_response(ex) for ex in exchanges] @admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse) async def complete_trade( public_id: uuid.UUID, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), ) -> AdminExchangeResponse: """Mark a trade as completed. Only possible after slot time has passed.""" service = ExchangeService(db) exchange = await service.get_exchange_by_public_id(public_id) exchange = await service.complete_exchange(exchange) return ExchangeMapper.to_admin_response(exchange) @admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse) async def mark_no_show( public_id: uuid.UUID, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), ) -> AdminExchangeResponse: """Mark a trade as no-show. Only possible after slot time has passed.""" service = ExchangeService(db) exchange = await service.get_exchange_by_public_id(public_id) exchange = await service.mark_no_show(exchange) return ExchangeMapper.to_admin_response(exchange) @admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse) async def admin_cancel_trade( public_id: uuid.UUID, db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)), ) -> AdminExchangeResponse: """Cancel any trade (admin only).""" service = ExchangeService(db) exchange = await service.get_exchange_by_public_id(public_id) exchange = await service.cancel_exchange(exchange, _current_user, is_admin=True) return ExchangeMapper.to_admin_response(exchange) # ============================================================================= # Admin User Search Endpoint # ============================================================================= admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"]) @admin_users_router.get("/search", response_model=list[UserSearchResult]) async def search_users( q: str = Query(..., min_length=1, description="Search query for user email"), db: AsyncSession = Depends(get_db), _current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)), ) -> list[UserSearchResult]: """ Search users by email for autocomplete. Returns users whose email contains the search query (case-insensitive). Limited to 10 results for autocomplete purposes. """ result = await db.execute( select(User).where(User.email.ilike(f"%{q}%")).order_by(User.email).limit(10) ) users = result.scalars().all() return [UserSearchResult(id=u.id, email=u.email) for u in users] # All routers from this module for easy registration routers = [router, trades_router, admin_trades_router, admin_users_router]