diff --git a/backend/services/exchange.py b/backend/services/exchange.py index 05feaa9..cf8af31 100644 --- a/backend/services/exchange.py +++ b/backend/services/exchange.py @@ -25,13 +25,11 @@ from models import ( from repositories.availability import AvailabilityRepository from repositories.exchange import ExchangeRepository from repositories.price import PriceRepository +from repositories.pricing import PricingRepository from schemas import AvailableSlotsResponse, BookableSlot from shared_constants import ( EUR_TRADE_INCREMENT, - EUR_TRADE_MAX, - EUR_TRADE_MIN, LIGHTNING_MAX_EUR, - PREMIUM_PERCENTAGE, PRICE_STALENESS_SECONDS, SLOT_DURATION_MINUTES, ) @@ -48,24 +46,53 @@ class ExchangeService: self.price_repo = PriceRepository(db) self.exchange_repo = ExchangeRepository(db) self.availability_repo = AvailabilityRepository(db) + self.pricing_repo = PricingRepository(db) - def apply_premium_for_direction( + async def apply_premium_for_direction( self, market_price: float, - premium_percentage: int, + eur_amount: int, direction: TradeDirection, - ) -> float: + ) -> tuple[float, int]: """ - Apply premium to market price based on trade direction. + Apply premium to market price based on trade direction and amount. 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)) + + Premium calculation: + - Base premium for direction (from pricing config) + - Add extra premium if trade amount <= small trade threshold + + Returns: + Tuple of (agreed_price, total_premium_percentage) """ + # Load pricing config + pricing_config = await self.pricing_repo.get_current() + if pricing_config is None: + raise ServiceUnavailableError("Pricing configuration not available") + + # Get base premium for direction + base_premium = ( + pricing_config.premium_buy + if direction == TradeDirection.BUY + else pricing_config.premium_sell + ) + + # Calculate total premium (base + extra if small trade) + if eur_amount <= pricing_config.small_trade_threshold_eur: + total_premium = base_premium + pricing_config.small_trade_extra_premium + else: + total_premium = base_premium + + # Apply premium to market price if direction == TradeDirection.BUY: - return market_price * (1 + premium_percentage / 100) + agreed_price = market_price * (1 + total_premium / 100) else: # SELL - return market_price * (1 - premium_percentage / 100) + agreed_price = market_price * (1 - total_premium / 100) + + return agreed_price, total_premium def calculate_sats_amount( self, @@ -147,12 +174,39 @@ class ExchangeService: 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}") + async def validate_eur_amount( + self, eur_amount: int, direction: TradeDirection + ) -> None: + """Validate EUR amount is within configured limits for the given direction.""" + # Load pricing config + pricing_config = await self.pricing_repo.get_current() + if pricing_config is None: + raise ServiceUnavailableError("Pricing configuration not available") + + # Get direction-specific limits + eur_min = ( + pricing_config.eur_min_buy + if direction == TradeDirection.BUY + else pricing_config.eur_min_sell + ) + eur_max = ( + pricing_config.eur_max_buy + if direction == TradeDirection.BUY + else pricing_config.eur_max_sell + ) + + if eur_amount < eur_min: + direction_str = direction.value.upper() + raise BadRequestError( + f"EUR amount must be at least €{eur_min / 100:.0f} " + f"for {direction_str} trades" + ) + if eur_amount > eur_max: + direction_str = direction.value.upper() + raise BadRequestError( + f"EUR amount must be at most €{eur_max / 100:.0f} " + f"for {direction_str} trades" + ) if eur_amount % (EUR_TRADE_INCREMENT * 100) != 0: raise BadRequestError( f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}" @@ -218,8 +272,8 @@ class ExchangeService: f"Trade ID: {existing_trade.public_id}" ) - # Validate EUR amount - await self.validate_eur_amount(eur_amount) + # Validate EUR amount (direction-specific) + await self.validate_eur_amount(eur_amount, direction) # Validate Lightning threshold await self.validate_lightning_threshold(bitcoin_transfer_method, eur_amount) @@ -233,10 +287,11 @@ class ExchangeService: # Get and validate price cached_price = await self.validate_price_not_stale() - # Calculate agreed price based on direction + # Calculate agreed price based on direction and amount + # (includes premium calculation) market_price = cached_price.price - agreed_price = self.apply_premium_for_direction( - market_price, PREMIUM_PERCENTAGE, direction + agreed_price, total_premium_percentage = await self.apply_premium_for_direction( + market_price, eur_amount, direction ) # Calculate sats amount based on agreed price @@ -263,7 +318,7 @@ class ExchangeService: sats_amount=sats_amount, market_price_eur=market_price, agreed_price_eur=agreed_price, - premium_percentage=PREMIUM_PERCENTAGE, + premium_percentage=total_premium_percentage, status=ExchangeStatus.BOOKED, ) diff --git a/backend/tests/test_exchange.py b/backend/tests/test_exchange.py index e799b35..41d82a0 100644 --- a/backend/tests/test_exchange.py +++ b/backend/tests/test_exchange.py @@ -252,8 +252,10 @@ class TestCreateExchange: assert data["eur_amount"] == 10000 assert data["status"] == "booked" assert data["sats_amount"] > 0 - # For buy, agreed price is market * 1.05 - assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001) + # For buy with €100 (small trade): base 5% + extra 2% = 7% + # Agreed price is market * 1.07 + assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001) + assert data["premium_percentage"] == 7 @pytest.mark.asyncio async def test_create_exchange_sell_success( @@ -279,8 +281,10 @@ class TestCreateExchange: assert data["direction"] == "sell" assert data["bitcoin_transfer_method"] == "lightning" assert data["eur_amount"] == 20000 - # For sell, agreed price is market * 0.95 - assert data["agreed_price_eur"] == pytest.approx(19000.0, rel=0.001) + # For sell with €200 (at threshold): base 5% + extra 2% = 7% + # Agreed price is market * 0.93 + assert data["agreed_price_eur"] == pytest.approx(18600.0, rel=0.001) + assert data["premium_percentage"] == 7 @pytest.mark.asyncio async def test_cannot_book_same_slot_twice( @@ -536,6 +540,118 @@ class TestCreateExchange: assert response.status_code == 400 assert "at most" in response.json()["detail"] + @pytest.mark.asyncio + async def test_premium_calculation_small_trade_extra( + self, client_factory, regular_user, admin_user + ): + """Premium includes extra for small trades (amount <= threshold).""" + target_date = await setup_availability_and_price(client_factory, admin_user) + + with mock_price_fetcher(20000.0): + async with client_factory.create(cookies=regular_user["cookies"]) as client: + # Trade below threshold: should get base + extra premium + response = await client.post( + "/api/exchange", + json={ + "slot_start": f"{target_date}T09:00:00Z", + "direction": "buy", + "bitcoin_transfer_method": "onchain", + "eur_amount": 14000, # €140, below €200 threshold, multiple of €20 + }, + ) + + assert response.status_code == 200 + data = response.json() + # Base 5% + extra 2% = 7% total + assert data["premium_percentage"] == 7 + # Market 20000 * 1.07 = 21400 + assert data["agreed_price_eur"] == pytest.approx(21400.0, rel=0.001) + + @pytest.mark.asyncio + async def test_premium_calculation_large_trade_base_only( + self, client_factory, regular_user, admin_user + ): + """Premium is base only for large trades (amount > threshold).""" + target_date = await setup_availability_and_price(client_factory, admin_user) + + with mock_price_fetcher(20000.0): + async with client_factory.create(cookies=regular_user["cookies"]) as client: + # Trade above threshold: should get base premium only + response = await client.post( + "/api/exchange", + json={ + "slot_start": f"{target_date}T10:00:00Z", + "direction": "buy", + "bitcoin_transfer_method": "onchain", + "eur_amount": 24000, # €240, above €200 threshold, multiple of €20 + }, + ) + + assert response.status_code == 200 + data = response.json() + # Base 5% only (no extra) + assert data["premium_percentage"] == 5 + # Market 20000 * 1.05 = 21000 + assert data["agreed_price_eur"] == pytest.approx(21000.0, rel=0.001) + + @pytest.mark.asyncio + async def test_premium_calculation_direction_specific( + self, client_factory, regular_user, admin_user + ): + """Premium uses direction-specific base values.""" + target_date = await setup_availability_and_price(client_factory, admin_user) + target_date_2 = await setup_availability_and_price( + client_factory, admin_user, target_date=in_days(2) + ) + + # Update pricing config with different premiums for buy/sell + async with client_factory.get_db_session() as db: + from repositories.pricing import PricingRepository + + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=10, + premium_sell=8, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) + + with mock_price_fetcher(20000.0): + async with client_factory.create(cookies=regular_user["cookies"]) as client: + # Buy trade + buy_response = await client.post( + "/api/exchange", + json={ + "slot_start": f"{target_date}T09:00:00Z", + "direction": "buy", + "bitcoin_transfer_method": "onchain", + "eur_amount": 14000, # €140, multiple of €20 + }, + ) + assert buy_response.status_code == 200 + buy_data = buy_response.json() + # Buy: base 10% + extra 2% = 12% + assert buy_data["premium_percentage"] == 12 + + # Sell trade (different day to avoid one-trade-per-day limit) + sell_response = await client.post( + "/api/exchange", + json={ + "slot_start": f"{target_date_2}T10:00:00Z", + "direction": "sell", + "bitcoin_transfer_method": "onchain", + "eur_amount": 14000, # €140, multiple of €20 + }, + ) + assert sell_response.status_code == 200 + sell_data = sell_response.json() + # Sell: base 8% + extra 2% = 10% + assert sell_data["premium_percentage"] == 10 + @pytest.mark.asyncio async def test_amount_not_multiple_of_increment_rejected( self, client_factory, regular_user, admin_user @@ -595,9 +711,22 @@ class TestCreateExchange: }, ) - # Create stale price (create_exchange doesn't fetch, just reads from DB) + # Create stale price and pricing config async with client_factory.get_db_session() as db: await create_price_in_db(db, price=20000.0, minutes_ago=10) + from repositories.pricing import PricingRepository + + repo = PricingRepository(db) + await repo.create_or_update( + premium_buy=5, + premium_sell=5, + small_trade_threshold_eur=20000, + small_trade_extra_premium=2, + eur_min_buy=10000, + eur_max_buy=300000, + eur_min_sell=10000, + eur_max_sell=300000, + ) async with client_factory.create(cookies=regular_user["cookies"]) as client: response = await client.post(