From ce9159c5b04e1893af2746a3f03dccdebd63bf4f Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 22 Dec 2025 18:28:56 +0100 Subject: [PATCH] Phase 2.1-2.3: Add exchange endpoints Add exchange trading endpoints: - POST /api/exchange: Create exchange trade - Validates slot, price staleness, EUR amount limits - Calculates sats from EUR and agreed price - Direction-specific premium (buy=+5%, sell=-5%) - GET /api/trades: List user's exchanges - POST /api/trades/{id}/cancel: Cancel user's exchange Add schemas: - ExchangeRequest, ExchangeResponse - ExchangeUserContact, AdminExchangeResponse (for Phase 2.4) - PaginatedExchanges, PaginatedAdminExchanges --- backend/main.py | 3 +- backend/routes/exchange.py | 326 +++++++++++++++++++++++++++++++++++-- backend/schemas.py | 68 ++++++++ 3 files changed, 382 insertions(+), 15 deletions(-) diff --git a/backend/main.py b/backend/main.py index ad871c5..c941188 100644 --- a/backend/main.py +++ b/backend/main.py @@ -41,7 +41,6 @@ app.add_middleware( # Include routers - modules with single router app.include_router(auth_routes.router) app.include_router(audit_routes.router) -app.include_router(exchange_routes.router) app.include_router(profile_routes.router) app.include_router(availability_routes.router) app.include_router(meta_routes.router) @@ -51,3 +50,5 @@ for r in invites_routes.routers: app.include_router(r) for r in booking_routes.routers: app.include_router(r) +for r in exchange_routes.routers: + app.include_router(r) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 8e4775f..9e8f533 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -1,26 +1,50 @@ """Exchange routes for Bitcoin trading.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from sqlalchemy import desc, select +from sqlalchemy import and_, desc, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from auth import require_permission from database import get_db -from models import Permission, PriceHistory, User +from date_validation import validate_date_in_range +from models import ( + Availability, + Exchange, + ExchangeStatus, + Permission, + PriceHistory, + TradeDirection, + User, +) from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price +from schemas import ExchangeRequest, ExchangeResponse from shared_constants import ( EUR_TRADE_INCREMENT, EUR_TRADE_MAX, EUR_TRADE_MIN, PREMIUM_PERCENTAGE, PRICE_STALENESS_SECONDS, + SLOT_DURATION_MINUTES, ) router = APIRouter(prefix="/api/exchange", tags=["exchange"]) +# ============================================================================= +# Constants for satoshi calculations +# ============================================================================= + +SATS_PER_BTC = 100_000_000 + + +# ============================================================================= +# Pydantic models for price endpoint +# ============================================================================= + class ExchangeConfigResponse(BaseModel): """Exchange configuration for the frontend.""" @@ -49,20 +73,53 @@ class ExchangePriceResponse(BaseModel): error: str | None = None -def apply_premium(market_price: float, premium_percentage: int) -> float: +# ============================================================================= +# Helper functions +# ============================================================================= + + +def apply_premium_for_direction( + market_price: float, + premium_percentage: int, + direction: TradeDirection, +) -> float: """ - Apply premium to market price. + Apply premium to market price based on trade direction. The premium is always favorable to the admin: - - When user BUYS BTC (admin sells): user pays MORE (add premium) - - When user SELLS BTC (admin buys): user receives LESS (subtract premium) - - For simplicity, we return the "buy" price here (with premium added). - The sell price would be market_price * (1 - premium/100). - - The frontend/booking logic handles direction-specific pricing. + - When user BUYS BTC: user pays MORE (market * (1 + premium/100)) + - When user SELLS BTC: user receives LESS (market * (1 - premium/100)) """ - return market_price * (1 + premium_percentage / 100) + if direction == TradeDirection.BUY: + return market_price * (1 + premium_percentage / 100) + else: # SELL + return market_price * (1 - premium_percentage / 100) + + +def apply_premium(market_price: float, premium_percentage: int) -> float: + """Apply buy-side premium (for price display).""" + return apply_premium_for_direction( + market_price, premium_percentage, TradeDirection.BUY + ) + + +def calculate_sats_amount( + eur_cents: int, + price_eur_per_btc: float, +) -> int: + """ + Calculate satoshi amount from EUR cents and price. + + Args: + eur_cents: Amount in EUR cents (e.g., 10000 = €100) + price_eur_per_btc: Price in EUR per BTC + + Returns: + Amount in satoshis + """ + eur_amount = eur_cents / 100 + btc_amount = eur_amount / price_eur_per_btc + return int(btc_amount * SATS_PER_BTC) async def get_latest_price(db: AsyncSession) -> PriceHistory | None: @@ -85,6 +142,36 @@ def is_price_stale(price_timestamp: datetime) -> bool: return age_seconds > PRICE_STALENESS_SECONDS +def _to_exchange_response( + exchange: Exchange, + user_email: str | None = None, +) -> ExchangeResponse: + """Convert an Exchange model to ExchangeResponse schema.""" + email = user_email if user_email is not None else exchange.user.email + return ExchangeResponse( + id=exchange.id, + user_id=exchange.user_id, + user_email=email, + slot_start=exchange.slot_start, + slot_end=exchange.slot_end, + direction=exchange.direction.value, + eur_amount=exchange.eur_amount, + sats_amount=exchange.sats_amount, + market_price_eur=exchange.market_price_eur, + agreed_price_eur=exchange.agreed_price_eur, + premium_percentage=exchange.premium_percentage, + status=exchange.status.value, + created_at=exchange.created_at, + cancelled_at=exchange.cancelled_at, + completed_at=exchange.completed_at, + ) + + +# ============================================================================= +# Price Endpoint +# ============================================================================= + + @router.get("/price", response_model=ExchangePriceResponse) async def get_exchange_price( db: AsyncSession = Depends(get_db), @@ -172,3 +259,214 @@ async def get_exchange_price( ), config=config, ) + + +# ============================================================================= +# 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.BOOK_APPOINTMENT)), +) -> 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 + """ + slot_date = request.slot_start.date() + validate_date_in_range(slot_date, context="book") + + # Validate direction + try: + direction = TradeDirection(request.direction) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'.", + ) from None + + # Validate EUR amount + if request.eur_amount < EUR_TRADE_MIN * 100: + raise HTTPException( + status_code=400, + detail=f"EUR amount must be at least €{EUR_TRADE_MIN}", + ) + if request.eur_amount > EUR_TRADE_MAX * 100: + raise HTTPException( + status_code=400, + detail=f"EUR amount must be at most €{EUR_TRADE_MAX}", + ) + if request.eur_amount % (EUR_TRADE_INCREMENT * 100) != 0: + raise HTTPException( + status_code=400, + detail=f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}", + ) + + # Validate slot timing + valid_minutes = (0, 15, 30, 45) + if request.slot_start.minute not in valid_minutes: + raise HTTPException( + status_code=400, + detail=f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary", + ) + if request.slot_start.second != 0 or request.slot_start.microsecond != 0: + raise HTTPException( + status_code=400, + detail="Slot start time must not have seconds or microseconds", + ) + + # Verify slot falls within availability + slot_start_time = request.slot_start.time() + slot_end_dt = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES) + slot_end_time = slot_end_dt.time() + + result = await db.execute( + select(Availability).where( + and_( + Availability.date == slot_date, + Availability.start_time <= slot_start_time, + Availability.end_time >= slot_end_time, + ) + ) + ) + matching_availability = result.scalar_one_or_none() + + if not matching_availability: + slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M") + raise HTTPException( + status_code=400, + detail=f"Selected slot at {slot_str} UTC is not available", + ) + + # Get and validate price + cached_price = await get_latest_price(db) + + if cached_price is None: + raise HTTPException( + status_code=503, + detail="Price data unavailable. Please try again later.", + ) + + if is_price_stale(cached_price.timestamp): + raise HTTPException( + status_code=503, + detail="Price is stale. Please refresh and try again.", + ) + + # Calculate agreed price based on direction + market_price = cached_price.price + agreed_price = apply_premium_for_direction( + market_price, PREMIUM_PERCENTAGE, direction + ) + + # Calculate sats amount based on agreed price + sats_amount = calculate_sats_amount(request.eur_amount, agreed_price) + + # Create the exchange + exchange = Exchange( + user_id=current_user.id, + slot_start=request.slot_start, + slot_end=slot_end_dt, + direction=direction, + eur_amount=request.eur_amount, + sats_amount=sats_amount, + market_price_eur=market_price, + agreed_price_eur=agreed_price, + premium_percentage=PREMIUM_PERCENTAGE, + status=ExchangeStatus.BOOKED, + ) + + db.add(exchange) + + try: + await db.commit() + await db.refresh(exchange) + except IntegrityError: + await db.rollback() + raise HTTPException( + status_code=409, + detail="This slot has already been booked. Select another slot.", + ) from None + + return _to_exchange_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_APPOINTMENTS)), +) -> 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 [_to_exchange_response(ex, current_user.email) for ex in exchanges] + + +@trades_router.post("/{exchange_id}/cancel", response_model=ExchangeResponse) +async def cancel_my_trade( + exchange_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)), +) -> ExchangeResponse: + """Cancel one of the current user's exchanges.""" + # Get the exchange with eager loading of user relationship + result = await db.execute( + select(Exchange) + .options(joinedload(Exchange.user)) + .where(Exchange.id == exchange_id) + ) + exchange = result.scalar_one_or_none() + + if not exchange: + raise HTTPException( + status_code=404, + detail=f"Trade {exchange_id} not found", + ) + + # Verify ownership + if exchange.user_id != current_user.id: + raise HTTPException( + status_code=403, + detail="Cannot cancel another user's trade", + ) + + # Check if already in a final state + if exchange.status != ExchangeStatus.BOOKED: + raise HTTPException( + status_code=400, + detail=f"Cannot cancel: status is '{exchange.status.value}'", + ) + + # Cancel the exchange (no time restriction per spec) + exchange.status = ExchangeStatus.CANCELLED_BY_USER + exchange.cancelled_at = datetime.now(UTC) + + await db.commit() + await db.refresh(exchange) + + return _to_exchange_response(exchange, current_user.email) + + +# All routers from this module for easy registration +routers = [router, trades_router] diff --git a/backend/schemas.py b/backend/schemas.py index aaf3533..aee21b2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -234,6 +234,74 @@ class PriceHistoryResponse(BaseModel): created_at: datetime +# ============================================================================= +# Exchange Schemas +# ============================================================================= + + +class ExchangeRequest(BaseModel): + """Request to create an exchange trade.""" + + slot_start: datetime + direction: str # "buy" or "sell" + eur_amount: int # EUR cents (e.g., 10000 = €100) + + +class ExchangeResponse(BaseModel): + """Response model for an exchange trade.""" + + id: int + user_id: int + user_email: str + slot_start: datetime + slot_end: datetime + direction: str + eur_amount: int # EUR cents + sats_amount: int # Satoshis + market_price_eur: float + agreed_price_eur: float + premium_percentage: int + status: str + created_at: datetime + cancelled_at: datetime | None + completed_at: datetime | None + + +class ExchangeUserContact(BaseModel): + """User contact info for admin view.""" + + email: str + contact_email: str | None + telegram: str | None + signal: str | None + nostr_npub: str | None + + +class AdminExchangeResponse(BaseModel): + """Response model for admin exchange view (includes user contact).""" + + id: int + user_id: int + user_email: str + user_contact: ExchangeUserContact + slot_start: datetime + slot_end: datetime + direction: str + eur_amount: int + sats_amount: int + market_price_eur: float + agreed_price_eur: float + premium_percentage: int + status: str + created_at: datetime + cancelled_at: datetime | None + completed_at: datetime | None + + +PaginatedExchanges = PaginatedResponse[ExchangeResponse] +PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse] + + # ============================================================================= # Meta/Constants Schemas # =============================================================================