refactors
This commit is contained in:
parent
139a5fbef3
commit
f46d2ae8b3
12 changed files with 734 additions and 536 deletions
5
backend/services/__init__.py
Normal file
5
backend/services/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Service layer for business logic."""
|
||||
|
||||
from services.exchange import ExchangeService
|
||||
|
||||
__all__ = ["ExchangeService"]
|
||||
392
backend/services/exchange.py
Normal file
392
backend/services/exchange.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue