refactors

This commit is contained in:
counterweight 2025-12-25 00:59:57 +01:00
parent 139a5fbef3
commit f46d2ae8b3
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
12 changed files with 734 additions and 536 deletions

View file

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from database import get_db
from models import Permission, User from models import Permission, User
from repositories.user import UserRepository
from schemas import UserResponse from schemas import UserResponse
SECRET_KEY = os.environ["SECRET_KEY"] # Required - see .env.example SECRET_KEY = os.environ["SECRET_KEY"] # Required - see .env.example
@ -45,8 +46,9 @@ def create_access_token(
async def get_user_by_email(db: AsyncSession, email: str) -> User | None: async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email)) """Get user by email (backwards compatibility wrapper)."""
return result.scalar_one_or_none() repo = UserRepository(db)
return await repo.get_by_email(email)
async def authenticate_user(db: AsyncSession, email: str, password: str) -> User | None: async def authenticate_user(db: AsyncSession, email: str, password: str) -> User | None:

61
backend/exceptions.py Normal file
View file

@ -0,0 +1,61 @@
"""Standardized API exception classes for consistent error responses.
Note: These exceptions use string detail for backward compatibility with existing tests.
Future refactoring could standardize on structured error responses.
"""
from fastapi import HTTPException, status
class APIError(HTTPException):
"""Base API error with consistent structure.
Uses string detail for backward compatibility with existing tests.
"""
def __init__(
self,
status_code: int,
message: str,
):
super().__init__(status_code=status_code, detail=message)
class NotFoundError(APIError):
"""Resource not found error (404)."""
def __init__(self, resource: str):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
message=f"{resource} not found",
)
class ConflictError(APIError):
"""Conflict error (409)."""
def __init__(self, message: str):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
message=message,
)
class BadRequestError(APIError):
"""Bad request error (400)."""
def __init__(self, message: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
message=message,
)
class ServiceUnavailableError(APIError):
"""Service unavailable error (503)."""
def __init__(self, message: str):
super().__init__(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
message=message,
)

91
backend/mappers.py Normal file
View file

@ -0,0 +1,91 @@
"""Response mappers for converting models to API response schemas."""
from models import Exchange, Invite
from schemas import (
AdminExchangeResponse,
ExchangeResponse,
ExchangeUserContact,
InviteResponse,
)
class ExchangeMapper:
"""Mapper for Exchange model to response schemas."""
@staticmethod
def to_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,
public_id=str(exchange.public_id),
user_id=exchange.user_id,
user_email=email,
slot_start=exchange.slot_start,
slot_end=exchange.slot_end,
direction=exchange.direction.value,
bitcoin_transfer_method=exchange.bitcoin_transfer_method.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,
)
@staticmethod
def to_admin_response(exchange: Exchange) -> AdminExchangeResponse:
"""Convert an Exchange model to AdminExchangeResponse with user contact."""
user = exchange.user
return AdminExchangeResponse(
id=exchange.id,
public_id=str(exchange.public_id),
user_id=exchange.user_id,
user_email=user.email,
user_contact=ExchangeUserContact(
email=user.email,
contact_email=user.contact_email,
telegram=user.telegram,
signal=user.signal,
nostr_npub=user.nostr_npub,
),
slot_start=exchange.slot_start,
slot_end=exchange.slot_end,
direction=exchange.direction.value,
bitcoin_transfer_method=exchange.bitcoin_transfer_method.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,
)
class InviteMapper:
"""Mapper for Invite model to response schemas."""
@staticmethod
def to_response(invite: Invite) -> InviteResponse:
"""Build an InviteResponse from an Invite with loaded relationships."""
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=invite.godfather.email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=invite.used_by.email if invite.used_by else None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
)

View file

@ -0,0 +1,6 @@
"""Repository layer for database queries."""
from repositories.price import PriceRepository
from repositories.user import UserRepository
__all__ = ["PriceRepository", "UserRepository"]

View file

@ -0,0 +1,27 @@
"""Price repository for database queries."""
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from models import PriceHistory
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX
class PriceRepository:
"""Repository for price-related database queries."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_latest(
self, source: str = SOURCE_BITFINEX, pair: str = PAIR_BTC_EUR
) -> PriceHistory | None:
"""Get the most recent price from the database."""
query = (
select(PriceHistory)
.where(PriceHistory.source == source, PriceHistory.pair == pair)
.order_by(desc(PriceHistory.timestamp))
.limit(1)
)
result = await self.db.execute(query)
return result.scalar_one_or_none()

View file

@ -0,0 +1,23 @@
"""User repository for database queries."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import User
class UserRepository:
"""Repository for user-related database queries."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_email(self, email: str) -> User | None:
"""Get a user by email."""
result = await self.db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get_by_id(self, user_id: int) -> User | None:
"""Get a user by ID."""
result = await self.db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()

View file

@ -3,16 +3,16 @@
import uuid import uuid
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from sqlalchemy import and_, select
from sqlalchemy import and_, desc, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from date_validation import validate_date_in_range from date_validation import validate_date_in_range
from exceptions import BadRequestError
from mappers import ExchangeMapper
from models import ( from models import (
Availability, Availability,
BitcoinTransferMethod, BitcoinTransferMethod,
@ -24,169 +24,35 @@ from models import (
User, User,
) )
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
from repositories.price import PriceRepository
from schemas import ( from schemas import (
AdminExchangeResponse, AdminExchangeResponse,
AvailableSlotsResponse,
BookableSlot,
ExchangeConfigResponse,
ExchangePriceResponse,
ExchangeRequest, ExchangeRequest,
ExchangeResponse, ExchangeResponse,
ExchangeUserContact, PriceResponse,
UserSearchResult,
) )
from services.exchange import ExchangeService
from shared_constants import ( from shared_constants import (
EUR_TRADE_INCREMENT, EUR_TRADE_INCREMENT,
EUR_TRADE_MAX, EUR_TRADE_MAX,
EUR_TRADE_MIN, EUR_TRADE_MIN,
LIGHTNING_MAX_EUR,
PREMIUM_PERCENTAGE, PREMIUM_PERCENTAGE,
PRICE_STALENESS_SECONDS,
SLOT_DURATION_MINUTES, SLOT_DURATION_MINUTES,
) )
router = APIRouter(prefix="/api/exchange", tags=["exchange"]) 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."""
eur_min: int
eur_max: int
eur_increment: int
premium_percentage: int
class PriceResponse(BaseModel):
"""Current BTC/EUR price for trading.
Note: The actual agreed price depends on trade direction (buy/sell)
and is calculated by the frontend using market_price and premium_percentage.
"""
market_price: float # Raw price from exchange
premium_percentage: int
timestamp: datetime
is_stale: bool
class ExchangePriceResponse(BaseModel):
"""Combined price and configuration response."""
price: PriceResponse | None # None if price fetch failed
config: ExchangeConfigResponse
error: str | None = None
class BookableSlot(BaseModel):
"""A single bookable time slot."""
start_time: datetime
end_time: datetime
class AvailableSlotsResponse(BaseModel):
"""Response containing available slots for a date."""
date: date
slots: list[BookableSlot]
# ============================================================================= # =============================================================================
# Helper functions # Helper functions
# ============================================================================= # =============================================================================
def apply_premium_for_direction(
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(
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:
"""Get the most recent price from the database."""
query = (
select(PriceHistory)
.where(
PriceHistory.source == SOURCE_BITFINEX, PriceHistory.pair == PAIR_BTC_EUR
)
.order_by(desc(PriceHistory.timestamp))
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none()
def is_price_stale(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
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,
public_id=str(exchange.public_id),
user_id=exchange.user_id,
user_email=email,
slot_start=exchange.slot_start,
slot_end=exchange.slot_end,
direction=exchange.direction.value,
bitcoin_transfer_method=exchange.bitcoin_transfer_method.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 # Price Endpoint
# ============================================================================= # =============================================================================
@ -216,11 +82,14 @@ async def get_exchange_price(
premium_percentage=PREMIUM_PERCENTAGE, premium_percentage=PREMIUM_PERCENTAGE,
) )
price_repo = PriceRepository(db)
service = ExchangeService(db)
# Try to get the latest cached price # Try to get the latest cached price
cached_price = await get_latest_price(db) cached_price = await price_repo.get_latest()
# If no cached price or it's stale, try to fetch a new one # If no cached price or it's stale, try to fetch a new one
if cached_price is None or is_price_stale(cached_price.timestamp): if cached_price is None or service.is_price_stale(cached_price.timestamp):
try: try:
price_value, timestamp = await fetch_btc_eur_price() price_value, timestamp = await fetch_btc_eur_price()
@ -270,7 +139,7 @@ async def get_exchange_price(
market_price=cached_price.price, market_price=cached_price.price,
premium_percentage=PREMIUM_PERCENTAGE, premium_percentage=PREMIUM_PERCENTAGE,
timestamp=cached_price.timestamp, timestamp=cached_price.timestamp,
is_stale=is_price_stale(cached_price.timestamp), is_stale=service.is_price_stale(cached_price.timestamp),
), ),
config=config, config=config,
) )
@ -377,194 +246,34 @@ async def create_exchange(
- Price is not stale - Price is not stale
- EUR amount is within configured limits - EUR amount is within configured limits
""" """
slot_date = request.slot_start.date()
validate_date_in_range(slot_date, context="book")
# Check if user already has a trade on this date
existing_trade_query = select(Exchange).where(
and_(
Exchange.user_id == current_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,
)
)
existing_trade_result = await db.execute(existing_trade_query)
existing_trade = existing_trade_result.scalar_one_or_none()
if existing_trade:
raise HTTPException(
status_code=400,
detail=(
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 direction # Validate direction
try: try:
direction = TradeDirection(request.direction) direction = TradeDirection(request.direction)
except ValueError: except ValueError:
raise HTTPException( raise BadRequestError(
status_code=400, f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'."
detail=f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'.",
) from None ) from None
# Validate bitcoin transfer method # Validate bitcoin transfer method
try: try:
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method) bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
except ValueError: except ValueError:
raise HTTPException( raise BadRequestError(
status_code=400,
detail=(
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. " f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
"Must be 'onchain' or 'lightning'." "Must be 'onchain' or 'lightning'."
),
) from None ) from None
# Validate EUR amount # Use service to create exchange (handles all validation)
if request.eur_amount < EUR_TRADE_MIN * 100: service = ExchangeService(db)
raise HTTPException( exchange = await service.create_exchange(
status_code=400, user=current_user,
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 Lightning threshold
if (
bitcoin_transfer_method == BitcoinTransferMethod.LIGHTNING
and request.eur_amount > LIGHTNING_MAX_EUR * 100
):
raise HTTPException(
status_code=400,
detail=(
f"Lightning payments are only allowed for amounts up to "
f"{LIGHTNING_MAX_EUR}. For amounts above €{LIGHTNING_MAX_EUR}, "
"please use onchain transactions."
),
)
# Validate slot timing - compute valid boundaries from slot duration
valid_minutes = tuple(range(0, 60, SLOT_DURATION_MINUTES))
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)
# Check if slot is already booked (only consider BOOKED status, not cancelled)
slot_booked_query = select(Exchange).where(
and_(
Exchange.slot_start == request.slot_start,
Exchange.status == ExchangeStatus.BOOKED,
)
)
slot_booked_result = await db.execute(slot_booked_query)
slot_booked = slot_booked_result.scalar_one_or_none()
if slot_booked:
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=409,
detail=(
f"This slot at {slot_str} UTC has already been booked. "
"Select another slot."
),
)
# Create the exchange
exchange = Exchange(
user_id=current_user.id,
slot_start=request.slot_start, slot_start=request.slot_start,
slot_end=slot_end_dt,
direction=direction, direction=direction,
bitcoin_transfer_method=bitcoin_transfer_method, bitcoin_transfer_method=bitcoin_transfer_method,
eur_amount=request.eur_amount, 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) return ExchangeMapper.to_response(exchange, current_user.email)
try:
await db.commit()
await db.refresh(exchange)
except IntegrityError as e:
await db.rollback()
# This should rarely happen now since we check explicitly above,
# but keep it for other potential integrity violations
raise HTTPException(
status_code=409,
detail="Database constraint violation. Please try again.",
) from e
return _to_exchange_response(exchange, current_user.email)
# ============================================================================= # =============================================================================
@ -587,7 +296,7 @@ async def get_my_trades(
) )
exchanges = result.scalars().all() exchanges = result.scalars().all()
return [_to_exchange_response(ex, current_user.email) for ex in exchanges] return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges]
@trades_router.get("/{public_id}", response_model=ExchangeResponse) @trades_router.get("/{public_id}", response_model=ExchangeResponse)
@ -597,20 +306,10 @@ async def get_my_trade(
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)), current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
) -> ExchangeResponse: ) -> ExchangeResponse:
"""Get a specific trade by public ID. User can only access their own trades.""" """Get a specific trade by public ID. User can only access their own trades."""
result = await db.execute( service = ExchangeService(db)
select(Exchange).where( exchange = await service.get_exchange_by_public_id(public_id, user=current_user)
and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id)
)
)
exchange = result.scalar_one_or_none()
if not exchange: return ExchangeMapper.to_response(exchange, current_user.email)
raise HTTPException(
status_code=404,
detail="Trade not found or you don't have permission to view it.",
)
return _to_exchange_response(exchange, current_user.email)
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse) @trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
@ -620,48 +319,20 @@ async def cancel_my_trade(
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)), current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
) -> ExchangeResponse: ) -> ExchangeResponse:
"""Cancel one of the current user's exchanges.""" """Cancel one of the current user's exchanges."""
# Get the exchange with eager loading of user relationship service = ExchangeService(db)
result = await db.execute( # Get exchange without user filter first to check ownership separately
select(Exchange) exchange = await service.get_exchange_by_public_id(public_id)
.options(joinedload(Exchange.user))
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange: # Check ownership - return 403 if user doesn't own it
raise HTTPException(
status_code=404,
detail="Trade not found",
)
# Verify ownership
if exchange.user_id != current_user.id: if exchange.user_id != current_user.id:
raise HTTPException( raise HTTPException(
status_code=403, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot cancel another user's trade", detail="Cannot cancel another user's trade",
) )
# Check if already in a final state exchange = await service.cancel_exchange(exchange, current_user, is_admin=False)
if exchange.status != ExchangeStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel: status is '{exchange.status.value}'",
)
# Check if slot time has already passed return ExchangeMapper.to_response(exchange, current_user.email)
if exchange.slot_start <= datetime.now(UTC):
raise HTTPException(
status_code=400,
detail="Cannot cancel: trade slot time has already passed",
)
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)
# ============================================================================= # =============================================================================
@ -671,37 +342,6 @@ async def cancel_my_trade(
admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"]) admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"])
def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
"""Convert an Exchange model to AdminExchangeResponse with user contact."""
user = exchange.user
return AdminExchangeResponse(
id=exchange.id,
public_id=str(exchange.public_id),
user_id=exchange.user_id,
user_email=user.email,
user_contact=ExchangeUserContact(
email=user.email,
contact_email=user.contact_email,
telegram=user.telegram,
signal=user.signal,
nostr_npub=user.nostr_npub,
),
slot_start=exchange.slot_start,
slot_end=exchange.slot_end,
direction=exchange.direction.value,
bitcoin_transfer_method=exchange.bitcoin_transfer_method.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,
)
@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse]) @admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse])
async def get_upcoming_trades( async def get_upcoming_trades(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -722,7 +362,7 @@ async def get_upcoming_trades(
) )
exchanges = result.scalars().all() exchanges = result.scalars().all()
return [_to_admin_exchange_response(ex) for ex in exchanges] return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse]) @admin_trades_router.get("/past", response_model=list[AdminExchangeResponse])
@ -783,7 +423,7 @@ async def get_past_trades(
result = await db.execute(query) result = await db.execute(query)
exchanges = result.scalars().all() exchanges = result.scalars().all()
return [_to_admin_exchange_response(ex) for ex in exchanges] return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse) @admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
@ -793,41 +433,11 @@ async def complete_trade(
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
"""Mark a trade as completed. Only possible after slot time has passed.""" """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)
result = await db.execute( return ExchangeMapper.to_admin_response(exchange)
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail="Trade not found",
)
# Check slot has passed
if exchange.slot_start > datetime.now(UTC):
raise HTTPException(
status_code=400,
detail="Cannot complete: trade slot has not yet started",
)
# Check status is BOOKED
if exchange.status != ExchangeStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot complete: status is '{exchange.status.value}'",
)
exchange.status = ExchangeStatus.COMPLETED
exchange.completed_at = datetime.now(UTC)
await db.commit()
await db.refresh(exchange)
return _to_admin_exchange_response(exchange)
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse) @admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
@ -837,41 +447,11 @@ async def mark_no_show(
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)), _current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
"""Mark a trade as no-show. Only possible after slot time has passed.""" """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)
result = await db.execute( return ExchangeMapper.to_admin_response(exchange)
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail="Trade not found",
)
# Check slot has passed
if exchange.slot_start > datetime.now(UTC):
raise HTTPException(
status_code=400,
detail="Cannot mark as no-show: trade slot has not yet started",
)
# Check status is BOOKED
if exchange.status != ExchangeStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot mark as no-show: status is '{exchange.status.value}'",
)
exchange.status = ExchangeStatus.NO_SHOW
exchange.completed_at = datetime.now(UTC)
await db.commit()
await db.refresh(exchange)
return _to_admin_exchange_response(exchange)
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse) @admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
@ -881,34 +461,11 @@ async def admin_cancel_trade(
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)), _current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
) -> AdminExchangeResponse: ) -> AdminExchangeResponse:
"""Cancel any trade (admin only).""" """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)
result = await db.execute( return ExchangeMapper.to_admin_response(exchange)
select(Exchange)
.options(joinedload(Exchange.user))
.where(Exchange.public_id == public_id)
)
exchange = result.scalar_one_or_none()
if not exchange:
raise HTTPException(
status_code=404,
detail="Trade not found",
)
# Check status is BOOKED
if exchange.status != ExchangeStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel: status is '{exchange.status.value}'",
)
exchange.status = ExchangeStatus.CANCELLED_BY_ADMIN
exchange.cancelled_at = datetime.now(UTC)
await db.commit()
await db.refresh(exchange)
return _to_admin_exchange_response(exchange)
# ============================================================================= # =============================================================================
@ -918,13 +475,6 @@ async def admin_cancel_trade(
admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"]) admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
class UserSearchResult(BaseModel):
"""Result item for user search."""
id: int
email: str
@admin_users_router.get("/search", response_model=list[UserSearchResult]) @admin_users_router.get("/search", response_model=list[UserSearchResult])
async def search_users( async def search_users(
q: str = Query(..., min_length=1, description="Search query for user email"), q: str = Query(..., min_length=1, description="Search query for user email"),

View file

@ -9,11 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from exceptions import BadRequestError, NotFoundError
from invite_utils import ( from invite_utils import (
generate_invite_identifier, generate_invite_identifier,
is_valid_identifier_format, is_valid_identifier_format,
normalize_identifier, normalize_identifier,
) )
from mappers import InviteMapper
from models import Invite, InviteStatus, Permission, User from models import Invite, InviteStatus, Permission, User
from pagination import calculate_offset, create_paginated_response from pagination import calculate_offset, create_paginated_response
from schemas import ( from schemas import (
@ -31,22 +33,6 @@ admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
MAX_INVITE_COLLISION_RETRIES = 3 MAX_INVITE_COLLISION_RETRIES = 3
def _to_invite_response(invite: Invite) -> InviteResponse:
"""Build an InviteResponse from an Invite with loaded relationships."""
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=invite.godfather.email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=invite.used_by.email if invite.used_by else None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
)
@router.get("/{identifier}/check", response_model=InviteCheckResponse) @router.get("/{identifier}/check", response_model=InviteCheckResponse)
async def check_invite( async def check_invite(
identifier: str, identifier: str,
@ -118,10 +104,7 @@ async def create_invite(
result = await db.execute(select(User.id).where(User.id == data.godfather_id)) result = await db.execute(select(User.id).where(User.id == data.godfather_id))
godfather_id = result.scalar_one_or_none() godfather_id = result.scalar_one_or_none()
if not godfather_id: if not godfather_id:
raise HTTPException( raise BadRequestError("Godfather user not found")
status_code=status.HTTP_400_BAD_REQUEST,
detail="Godfather user not found",
)
# Try to create invite with retry on collision # Try to create invite with retry on collision
invite: Invite | None = None invite: Invite | None = None
@ -150,7 +133,7 @@ async def create_invite(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create invite", detail="Failed to create invite",
) )
return _to_invite_response(invite) return InviteMapper.to_response(invite)
@admin_router.get("/invites", response_model=PaginatedInviteRecords) @admin_router.get("/invites", response_model=PaginatedInviteRecords)
@ -197,7 +180,7 @@ async def list_all_invites(
invites = result.scalars().all() invites = result.scalars().all()
# Build responses using preloaded relationships # Build responses using preloaded relationships
records = [_to_invite_response(invite) for invite in invites] records = [InviteMapper.to_response(invite) for invite in invites]
return create_paginated_response(records, total, page, per_page) return create_paginated_response(records, total, page, per_page)
@ -213,16 +196,12 @@ async def revoke_invite(
invite = result.scalar_one_or_none() invite = result.scalar_one_or_none()
if not invite: if not invite:
raise HTTPException( raise NotFoundError("Invite")
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite not found",
)
if invite.status != InviteStatus.READY: if invite.status != InviteStatus.READY:
raise HTTPException( raise BadRequestError(
status_code=status.HTTP_400_BAD_REQUEST, f"Cannot revoke invite with status '{invite.status.value}'. "
detail=f"Cannot revoke invite with status '{invite.status.value}'. " "Only READY invites can be revoked."
"Only READY invites can be revoked.",
) )
invite.status = InviteStatus.REVOKED invite.status = InviteStatus.REVOKED
@ -230,7 +209,7 @@ async def revoke_invite(
await db.commit() await db.commit()
await db.refresh(invite) await db.refresh(invite)
return _to_invite_response(invite) return InviteMapper.to_response(invite)
# All routers from this module for easy registration # All routers from this module for easy registration

View file

@ -54,6 +54,7 @@ async def update_profile(
) )
if errors: if errors:
# Keep field_errors format for backward compatibility with frontend
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={"field_errors": errors}, detail={"field_errors": errors},

View file

@ -277,3 +277,64 @@ class ConstantsResponse(BaseModel):
roles: list[str] roles: list[str]
invite_statuses: list[InviteStatus] invite_statuses: list[InviteStatus]
bitcoin_transfer_methods: list[BitcoinTransferMethod] bitcoin_transfer_methods: list[BitcoinTransferMethod]
# =============================================================================
# Exchange Price/Config Schemas
# =============================================================================
class ExchangeConfigResponse(BaseModel):
"""Exchange configuration for the frontend."""
eur_min: int
eur_max: int
eur_increment: int
premium_percentage: int
class PriceResponse(BaseModel):
"""Current BTC/EUR price for trading.
Note: The actual agreed price depends on trade direction (buy/sell)
and is calculated by the frontend using market_price and premium_percentage.
"""
market_price: float # Raw price from exchange
premium_percentage: int
timestamp: datetime
is_stale: bool
class ExchangePriceResponse(BaseModel):
"""Combined price and configuration response."""
price: PriceResponse | None # None if price fetch failed
config: ExchangeConfigResponse
error: str | None = None
class BookableSlot(BaseModel):
"""A single bookable time slot."""
start_time: datetime
end_time: datetime
class AvailableSlotsResponse(BaseModel):
"""Response containing available slots for a date."""
date: date
slots: list[BookableSlot]
# =============================================================================
# Admin User Search Schemas
# =============================================================================
class UserSearchResult(BaseModel):
"""Result item for user search."""
id: int
email: str

View file

@ -0,0 +1,5 @@
"""Service layer for business logic."""
from services.exchange import ExchangeService
__all__ = ["ExchangeService"]

View 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