"""Exchange service for business logic related to Bitcoin trading.""" import uuid from datetime import UTC, date, datetime, time, timedelta from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from date_validation import validate_date_in_range from exceptions import ( BadRequestError, ConflictError, NotFoundError, ServiceUnavailableError, ) from models import ( Availability, BitcoinTransferMethod, Exchange, ExchangeStatus, PriceHistory, TradeDirection, User, ) from repositories.price import PriceRepository from shared_constants import ( EUR_TRADE_INCREMENT, EUR_TRADE_MAX, EUR_TRADE_MIN, LIGHTNING_MAX_EUR, PREMIUM_PERCENTAGE, PRICE_STALENESS_SECONDS, SLOT_DURATION_MINUTES, ) # Constants for satoshi calculations SATS_PER_BTC = 100_000_000 class ExchangeService: """Service for exchange-related business logic.""" def __init__(self, db: AsyncSession): self.db = db self.price_repo = PriceRepository(db) def apply_premium_for_direction( self, market_price: float, premium_percentage: int, direction: TradeDirection, ) -> float: """ Apply premium to market price based on trade direction. The premium is always favorable to the admin: - When user BUYS BTC: user pays MORE (market * (1 + premium/100)) - When user SELLS BTC: user receives LESS (market * (1 - premium/100)) """ if direction == TradeDirection.BUY: return market_price * (1 + premium_percentage / 100) else: # SELL return market_price * (1 - premium_percentage / 100) def calculate_sats_amount( self, 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) def is_price_stale(self, price_timestamp: datetime) -> bool: """Check if a price is older than the staleness threshold.""" age_seconds = (datetime.now(UTC) - price_timestamp).total_seconds() return age_seconds > PRICE_STALENESS_SECONDS async def get_latest_price(self) -> PriceHistory | None: """Get the most recent price from the database.""" return await self.price_repo.get_latest() async def validate_slot_timing(self, slot_start: datetime) -> None: """Validate slot timing - compute valid boundaries from slot duration.""" valid_minutes = tuple(range(0, 60, SLOT_DURATION_MINUTES)) if slot_start.minute not in valid_minutes: raise BadRequestError( f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary" ) if slot_start.second != 0 or slot_start.microsecond != 0: raise BadRequestError( "Slot start time must not have seconds or microseconds" ) async def validate_slot_availability( self, slot_start: datetime, slot_date: date ) -> None: """Verify slot falls within availability.""" slot_start_time = slot_start.time() slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES) slot_end_time = slot_end_dt.time() result = await self.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 = slot_start.strftime("%Y-%m-%d %H:%M") raise BadRequestError(f"Selected slot at {slot_str} UTC is not available") async def validate_price_not_stale(self) -> PriceHistory: """Validate price exists and is not stale.""" cached_price = await self.get_latest_price() if cached_price is None: raise ServiceUnavailableError( "Price data unavailable. Please try again later." ) if self.is_price_stale(cached_price.timestamp): raise ServiceUnavailableError( "Price is stale. Please refresh and try again." ) return cached_price async def validate_eur_amount(self, eur_amount: int) -> None: """Validate EUR amount is within configured limits.""" if eur_amount < EUR_TRADE_MIN * 100: raise BadRequestError(f"EUR amount must be at least €{EUR_TRADE_MIN}") if eur_amount > EUR_TRADE_MAX * 100: raise BadRequestError(f"EUR amount must be at most €{EUR_TRADE_MAX}") if eur_amount % (EUR_TRADE_INCREMENT * 100) != 0: raise BadRequestError( f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}" ) async def validate_lightning_threshold( self, bitcoin_transfer_method: BitcoinTransferMethod, eur_amount: int ) -> None: """Validate Lightning threshold.""" if ( bitcoin_transfer_method == BitcoinTransferMethod.LIGHTNING and eur_amount > LIGHTNING_MAX_EUR * 100 ): raise BadRequestError( f"Lightning payments are only allowed for amounts up to " f"€{LIGHTNING_MAX_EUR}. For amounts above €{LIGHTNING_MAX_EUR}, " "please use onchain transactions." ) async def check_existing_trade_on_date( self, user: User, slot_date: date ) -> Exchange | None: """Check if user already has a trade on this date.""" existing_trade_query = select(Exchange).where( and_( Exchange.user_id == user.id, Exchange.slot_start >= datetime.combine(slot_date, time.min, tzinfo=UTC), Exchange.slot_start < datetime.combine(slot_date, time.max, tzinfo=UTC) + timedelta(days=1), Exchange.status == ExchangeStatus.BOOKED, ) ) result = await self.db.execute(existing_trade_query) return result.scalar_one_or_none() async def check_slot_already_booked(self, slot_start: datetime) -> Exchange | None: """Check if slot is already booked (only consider BOOKED status).""" slot_booked_query = select(Exchange).where( and_( Exchange.slot_start == slot_start, Exchange.status == ExchangeStatus.BOOKED, ) ) result = await self.db.execute(slot_booked_query) return result.scalar_one_or_none() async def create_exchange( self, user: User, slot_start: datetime, direction: TradeDirection, bitcoin_transfer_method: BitcoinTransferMethod, eur_amount: int, ) -> Exchange: """ Create a new exchange trade booking with all business validation. Raises: BadRequestError: For validation failures ConflictError: If slot is already booked or user has trade on date ServiceUnavailableError: If price is unavailable or stale """ slot_date = slot_start.date() validate_date_in_range(slot_date, context="book") # Check if user already has a trade on this date existing_trade = await self.check_existing_trade_on_date(user, slot_date) if existing_trade: raise BadRequestError( f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. " f"Only one trade per day is allowed. " f"Trade ID: {existing_trade.public_id}" ) # Validate EUR amount await self.validate_eur_amount(eur_amount) # Validate Lightning threshold await self.validate_lightning_threshold(bitcoin_transfer_method, eur_amount) # Validate slot timing await self.validate_slot_timing(slot_start) # Verify slot falls within availability await self.validate_slot_availability(slot_start, slot_date) # Get and validate price cached_price = await self.validate_price_not_stale() # Calculate agreed price based on direction market_price = cached_price.price agreed_price = self.apply_premium_for_direction( market_price, PREMIUM_PERCENTAGE, direction ) # Calculate sats amount based on agreed price sats_amount = self.calculate_sats_amount(eur_amount, agreed_price) # Check if slot is already booked slot_booked = await self.check_slot_already_booked(slot_start) if slot_booked: slot_str = slot_start.strftime("%Y-%m-%d %H:%M") raise ConflictError( f"This slot at {slot_str} UTC has already been booked. " "Select another slot." ) # Create the exchange slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES) exchange = Exchange( user_id=user.id, slot_start=slot_start, slot_end=slot_end_dt, direction=direction, bitcoin_transfer_method=bitcoin_transfer_method, eur_amount=eur_amount, sats_amount=sats_amount, market_price_eur=market_price, agreed_price_eur=agreed_price, premium_percentage=PREMIUM_PERCENTAGE, status=ExchangeStatus.BOOKED, ) self.db.add(exchange) try: await self.db.commit() await self.db.refresh(exchange) except IntegrityError as e: await self.db.rollback() # This should rarely happen now since we check explicitly above, # but keep it for other potential integrity violations raise ConflictError( "Database constraint violation. Please try again." ) from e return exchange async def get_exchange_by_public_id( self, public_id: uuid.UUID, user: User | None = None ) -> Exchange: """ Get an exchange by public ID, optionally checking ownership. Raises: NotFoundError: If exchange not found or user doesn't own it (for security, returns 404) """ query = select(Exchange).where(Exchange.public_id == public_id) result = await self.db.execute(query) exchange = result.scalar_one_or_none() if not exchange: raise NotFoundError("Trade") # Check ownership if user is provided - return 404 for security # (prevents info leakage) if user and exchange.user_id != user.id: raise NotFoundError("Trade") return exchange async def cancel_exchange( self, exchange: Exchange, user: User, is_admin: bool = False ) -> Exchange: """ Cancel an exchange trade. Raises: BadRequestError: If cancellation is not allowed NotFoundError: If user doesn't own the exchange (when not admin, returns 404 for security) """ if not is_admin and exchange.user_id != user.id: raise NotFoundError("Trade") if exchange.status != ExchangeStatus.BOOKED: raise BadRequestError(f"Cannot cancel: status is '{exchange.status.value}'") if exchange.slot_start <= datetime.now(UTC): raise BadRequestError("Cannot cancel: trade slot time has already passed") exchange.status = ( ExchangeStatus.CANCELLED_BY_ADMIN if is_admin else ExchangeStatus.CANCELLED_BY_USER ) exchange.cancelled_at = datetime.now(UTC) await self.db.commit() await self.db.refresh(exchange) return exchange async def complete_exchange(self, exchange: Exchange) -> Exchange: """ Mark an exchange as completed. Raises: BadRequestError: If completion is not allowed """ if exchange.slot_start > datetime.now(UTC): raise BadRequestError("Cannot complete: trade slot has not yet started") if exchange.status != ExchangeStatus.BOOKED: raise BadRequestError( f"Cannot complete: status is '{exchange.status.value}'" ) exchange.status = ExchangeStatus.COMPLETED exchange.completed_at = datetime.now(UTC) await self.db.commit() await self.db.refresh(exchange) return exchange async def mark_no_show(self, exchange: Exchange) -> Exchange: """ Mark an exchange as no-show. Raises: BadRequestError: If marking as no-show is not allowed """ if exchange.slot_start > datetime.now(UTC): raise BadRequestError( "Cannot mark as no-show: trade slot has not yet started" ) if exchange.status != ExchangeStatus.BOOKED: raise BadRequestError( f"Cannot mark as no-show: status is '{exchange.status.value}'" ) exchange.status = ExchangeStatus.NO_SHOW exchange.completed_at = datetime.now(UTC) await self.db.commit() await self.db.refresh(exchange) return exchange