From 02e5401327786c9dfe5ae7d4c89624c9455aa53e Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 12 Mar 2026 22:29:44 +0300 Subject: [PATCH] feat: implement calculate_renewal_price with tariff and classic modes Add the main public method calculate_renewal_price to PricingEngine, routing to _calculate_tariff_mode or _calculate_classic_mode based on whether the subscription has a linked tariff. Both modes apply stacked discounts (promo-group then promo-offer). Classic mode tries CLASSIC_PERIOD_PRICES first, falling back to PERIOD_PRICES. Adds 8 new tests covering both modes, discounts, extra devices, and fallback. --- app/services/pricing_engine.py | 191 +++++++++++++++++++++-- tests/test_pricing_engine.py | 267 ++++++++++++++++++++++++++++++--- 2 files changed, 426 insertions(+), 32 deletions(-) diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py index e00343e8..9e771dbc 100644 --- a/app/services/pricing_engine.py +++ b/app/services/pricing_engine.py @@ -1,10 +1,13 @@ from __future__ import annotations -import structlog from dataclasses import dataclass, field -from app.config import settings +import structlog + +from app.config import CLASSIC_PERIOD_PRICES, PERIOD_PRICES, settings from app.database.crud.server_squad import get_server_squad_by_uuid +from app.utils.promo_offer import get_user_active_promo_discount_percent + logger = structlog.get_logger() @@ -70,45 +73,45 @@ class PricingEngine: try: server = await get_server_squad_by_uuid(db, uuid) except Exception as e: - logger.error("Ошибка загрузки сервера", squad_uuid=uuid, error=str(e)) - details.append({"uuid": uuid, "price": 0, "status": "error"}) + logger.error('Ошибка загрузки сервера', squad_uuid=uuid, error=str(e)) + details.append({'uuid': uuid, 'price': 0, 'status': 'error'}) continue if server is None: - logger.error("Сервер не найден в БД", squad_uuid=uuid) - details.append({"uuid": uuid, "price": 0, "status": "not_found"}) + logger.error('Сервер не найден в БД', squad_uuid=uuid) + details.append({'uuid': uuid, 'price': 0, 'status': 'not_found'}) continue price = server.price_kopeks or 0 - status = "available" + status = 'available' if not server.is_available: - status = "unavailable" + status = 'unavailable' logger.warning( - "Сервер недоступен, используем реальную цену", + 'Сервер недоступен, используем реальную цену', squad_uuid=uuid, price_kopeks=price, ) elif server.is_full: - status = "full" + status = 'full' logger.warning( - "Сервер переполнен, используем реальную цену", + 'Сервер переполнен, используем реальную цену', squad_uuid=uuid, price_kopeks=price, ) elif promo_group_id is not None: allowed_ids = [pg.id for pg in (server.allowed_promo_groups or [])] if allowed_ids and promo_group_id not in allowed_ids: - status = "not_allowed" + status = 'not_allowed' logger.warning( - "Сервер недоступен для промогруппы, используем реальную цену", + 'Сервер недоступен для промогруппы, используем реальную цену', squad_uuid=uuid, promo_group_id=promo_group_id, price_kopeks=price, ) total_price += price - details.append({"uuid": uuid, "price": price, "status": status}) + details.append({'uuid': uuid, 'price': price, 'status': status}) return total_price, details @@ -127,3 +130,163 @@ class PricingEngine: purchased_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0 return base_price + purchased_price + + # ------------------------------------------------------------------ + # Main public method + # ------------------------------------------------------------------ + + async def calculate_renewal_price( + self, + db, # AsyncSession + subscription, + period_days: int, + *, + user=None, + ) -> RenewalPricing: + """Calculate renewal price for a subscription. + + Routes to tariff mode (subscription has a tariff) or classic mode + (legacy env-based pricing). Stacked discounts (promo-group then + promo-offer) are applied in both modes. + """ + if subscription.tariff_id is not None and subscription.tariff is not None: + return await self._calculate_tariff_mode(db, subscription, period_days, user=user) + return await self._calculate_classic_mode(db, subscription, period_days, user=user) + + # ------------------------------------------------------------------ + # Tariff mode + # ------------------------------------------------------------------ + + async def _calculate_tariff_mode( + self, + db, + subscription, + period_days: int, + *, + user=None, + ) -> RenewalPricing: + """Price calculation when subscription is linked to a Tariff.""" + tariff = subscription.tariff + period_prices: dict = tariff.period_prices or {} + base_price = period_prices.get(str(period_days), 0) + + # Extra devices above the tariff's included limit + device_price_per_unit = settings.PRICE_PER_DEVICE + extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0)) + devices_price = extra_devices * device_price_per_unit + + subtotal = base_price + devices_price + + # Resolve discounts + group_pct = 0 + if user and getattr(user, 'promo_group', None) is not None: + group_pct = user.promo_group.get_discount_percent('period', period_days) + + offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + + final_total, group_discount, offer_discount = self.apply_stacked_discounts( + subtotal, + group_pct, + offer_pct, + ) + + breakdown = { + 'tariff_id': tariff.id, + 'extra_devices': extra_devices, + 'group_discount_pct': group_pct, + 'offer_discount_pct': offer_pct, + } + + return RenewalPricing( + base_price=base_price, + servers_price=0, + traffic_price=0, + devices_price=devices_price, + promo_group_discount=group_discount, + promo_offer_discount=offer_discount, + final_total=final_total, + period_days=period_days, + is_tariff_mode=True, + breakdown=breakdown, + ) + + # ------------------------------------------------------------------ + # Classic mode + # ------------------------------------------------------------------ + + async def _calculate_classic_mode( + self, + db, + subscription, + period_days: int, + *, + user=None, + ) -> RenewalPricing: + """Price calculation for legacy (non-tariff) subscriptions. + + Uses CLASSIC_PERIOD_PRICES from settings, falling back to the + global PERIOD_PRICES dict during migration. + """ + # Try CLASSIC_PERIOD_PRICES first, fall back to PERIOD_PRICES + base_price = CLASSIC_PERIOD_PRICES.get(period_days) + if base_price is None: + base_price = PERIOD_PRICES.get(period_days, 0) + + # Servers + connected_squads: list[str] = subscription.connected_squads or [] + promo_group_id = getattr(user, 'promo_group_id', None) if user else None + servers_price, server_details = await self._calculate_servers_price( + connected_squads, + db, + promo_group_id=promo_group_id, + ) + + # Traffic + traffic_limit_gb = subscription.traffic_limit_gb or 0 + purchased_traffic_gb = subscription.purchased_traffic_gb or 0 + traffic_price = self._calculate_traffic_price(traffic_limit_gb, purchased_traffic_gb) + + # Devices + default_device_limit = settings.DEFAULT_DEVICE_LIMIT + device_price_per_unit = settings.PRICE_PER_DEVICE + extra_devices = max(0, (subscription.device_limit or 0) - default_device_limit) + devices_price = extra_devices * device_price_per_unit + + subtotal = base_price + servers_price + traffic_price + devices_price + + # Resolve discounts + group_pct = 0 + if user and getattr(user, 'promo_group', None) is not None: + group_pct = user.promo_group.get_discount_percent('period', period_days) + + offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + + final_total, group_discount, offer_discount = self.apply_stacked_discounts( + subtotal, + group_pct, + offer_pct, + ) + + breakdown = { + 'servers': server_details, + 'servers_individual_prices': [d['price'] for d in server_details], + 'server_ids': connected_squads, + 'base_traffic_gb': max(0, traffic_limit_gb - purchased_traffic_gb), + 'purchased_traffic_gb': purchased_traffic_gb, + 'extra_devices': extra_devices, + 'group_discount_pct': group_pct, + 'offer_discount_pct': offer_pct, + } + + return RenewalPricing( + base_price=base_price, + servers_price=servers_price, + traffic_price=traffic_price, + devices_price=devices_price, + promo_group_discount=group_discount, + promo_offer_discount=offer_discount, + final_total=final_total, + period_days=period_days, + is_tariff_mode=False, + breakdown=breakdown, + ) diff --git a/tests/test_pricing_engine.py b/tests/test_pricing_engine.py index fda62c3e..c0505e06 100644 --- a/tests/test_pricing_engine.py +++ b/tests/test_pricing_engine.py @@ -1,5 +1,6 @@ import pytest -from app.services.pricing_engine import RenewalPricing, PricingEngine + +from app.services.pricing_engine import PricingEngine, RenewalPricing def test_renewal_pricing_is_frozen(): @@ -77,39 +78,39 @@ class TestCalculateServersPrice: engine = PricingEngine() db = AsyncMock() server = _make_server(price_kopeks=5000) - with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=server): - total, details = await engine._calculate_servers_price(["uuid-1"], db, promo_group_id=None) + with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server): + total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None) assert total == 5000 assert len(details) == 1 - assert details[0]["price"] == 5000 + assert details[0]['price'] == 5000 @pytest.mark.asyncio async def test_unavailable_server_uses_real_price(self): engine = PricingEngine() db = AsyncMock() server = _make_server(price_kopeks=7000, is_available=False) - with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=server): - total, details = await engine._calculate_servers_price(["uuid-1"], db, promo_group_id=None) + with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server): + total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None) assert total == 7000 # NOT 0! - assert details[0]["status"] == "unavailable" + assert details[0]['status'] == 'unavailable' @pytest.mark.asyncio async def test_full_server_uses_real_price(self): engine = PricingEngine() db = AsyncMock() server = _make_server(price_kopeks=3000, is_full=True) - with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=server): - total, details = await engine._calculate_servers_price(["uuid-1"], db, promo_group_id=None) + with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server): + total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None) assert total == 3000 # NOT 0! @pytest.mark.asyncio async def test_server_not_found_zero_price(self): engine = PricingEngine() db = AsyncMock() - with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=None): - total, details = await engine._calculate_servers_price(["uuid-orphan"], db, promo_group_id=None) + with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=None): + total, details = await engine._calculate_servers_price(['uuid-orphan'], db, promo_group_id=None) assert total == 0 - assert details[0]["status"] == "not_found" + assert details[0]['status'] == 'not_found' @pytest.mark.asyncio async def test_multiple_servers(self): @@ -117,36 +118,266 @@ class TestCalculateServersPrice: db = AsyncMock() s1 = _make_server(price_kopeks=5000) s2 = _make_server(price_kopeks=3000, is_available=False) - with patch("app.services.pricing_engine.get_server_squad_by_uuid", side_effect=[s1, s2]): - total, details = await engine._calculate_servers_price(["uuid-1", "uuid-2"], db, promo_group_id=None) + with patch('app.services.pricing_engine.get_server_squad_by_uuid', side_effect=[s1, s2]): + total, details = await engine._calculate_servers_price(['uuid-1', 'uuid-2'], db, promo_group_id=None) assert total == 8000 class TestCalculateTrafficPrice: def test_base_only(self): engine = PricingEngine() - with patch("app.services.pricing_engine.settings") as ms: + with patch('app.services.pricing_engine.settings') as ms: ms.get_traffic_price.side_effect = lambda gb: {25: 3000, 50: 5000}.get(gb, 0) price = engine._calculate_traffic_price(traffic_limit_gb=25, purchased_traffic_gb=0) assert price == 3000 def test_purchased_separated(self): engine = PricingEngine() - with patch("app.services.pricing_engine.settings") as ms: + with patch('app.services.pricing_engine.settings') as ms: ms.get_traffic_price.side_effect = lambda gb: {25: 3000, 100: 8000, 125: 12000}.get(gb, 0) price = engine._calculate_traffic_price(traffic_limit_gb=125, purchased_traffic_gb=100) assert price == 11000 # NOT 12000 def test_zero_traffic(self): engine = PricingEngine() - with patch("app.services.pricing_engine.settings") as ms: + with patch('app.services.pricing_engine.settings') as ms: ms.get_traffic_price.return_value = 0 price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0) assert price == 0 def test_purchased_exceeds_total(self): engine = PricingEngine() - with patch("app.services.pricing_engine.settings") as ms: + with patch('app.services.pricing_engine.settings') as ms: ms.get_traffic_price.side_effect = lambda gb: {0: 0, 100: 8000}.get(gb, 0) price = engine._calculate_traffic_price(traffic_limit_gb=80, purchased_traffic_gb=100) assert price == 8000 # base_gb clamped to 0 + + +class TestCalculateRenewalPriceTariffMode: + @pytest.mark.asyncio + async def test_tariff_basic(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = 2 + subscription.tariff = MagicMock() + subscription.tariff.period_prices = {'30': 19000} + subscription.tariff.device_limit = 2 + subscription.tariff.id = 2 + subscription.device_limit = 2 + subscription.connected_squads = [] + subscription.traffic_limit_gb = 50 + subscription.purchased_traffic_gb = 0 + user = MagicMock() + user.promo_group = None + user.promo_offer_discount_percent = 0 + user.promo_offer_expires_at = None + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), + patch('app.services.pricing_engine.settings') as ms, + ): + ms.PRICE_PER_DEVICE = 5000 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + assert result.is_tariff_mode is True + assert result.final_total == 19000 + + @pytest.mark.asyncio + async def test_tariff_extra_devices(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = 2 + subscription.tariff = MagicMock() + subscription.tariff.period_prices = {'30': 19000} + subscription.tariff.device_limit = 2 + subscription.tariff.id = 2 + subscription.device_limit = 4 + subscription.connected_squads = [] + subscription.traffic_limit_gb = 50 + subscription.purchased_traffic_gb = 0 + user = MagicMock() + user.promo_group = None + user.promo_offer_discount_percent = 0 + user.promo_offer_expires_at = None + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), + patch('app.services.pricing_engine.settings') as ms, + ): + ms.PRICE_PER_DEVICE = 5000 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + assert result.devices_price == 10000 + assert result.final_total == 29000 + + @pytest.mark.asyncio + async def test_tariff_with_discounts(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = 1 + subscription.tariff = MagicMock() + subscription.tariff.period_prices = {'30': 20000} + subscription.tariff.device_limit = 1 + subscription.tariff.id = 1 + subscription.device_limit = 1 + promo_group = MagicMock() + promo_group.get_discount_percent.return_value = 10 + user = MagicMock() + user.promo_group = promo_group + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5), + patch('app.services.pricing_engine.settings') as ms, + ): + ms.PRICE_PER_DEVICE = 5000 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + assert result.base_price == 20000 + assert result.promo_group_discount == 2000 + # After group: 18000, then 5% off 18000 = 900 + assert result.promo_offer_discount == 900 + assert result.final_total == 17100 + + @pytest.mark.asyncio + async def test_tariff_missing_period_returns_zero_base(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = 1 + subscription.tariff = MagicMock() + subscription.tariff.period_prices = {'30': 19000} + subscription.tariff.device_limit = 1 + subscription.tariff.id = 1 + subscription.device_limit = 1 + user = MagicMock() + user.promo_group = None + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), + patch('app.services.pricing_engine.settings') as ms, + ): + ms.PRICE_PER_DEVICE = 5000 + result = await engine.calculate_renewal_price(db, subscription, 60, user=user) + assert result.base_price == 0 + assert result.final_total == 0 + + +class TestCalculateRenewalPriceClassicMode: + @pytest.mark.asyncio + async def test_classic_all_components(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = None + subscription.tariff = None + subscription.connected_squads = ['uuid-1'] + subscription.traffic_limit_gb = 50 + subscription.purchased_traffic_gb = 0 + subscription.device_limit = 2 + user = MagicMock() + user.promo_group = None + user.promo_group_id = None + user.promo_offer_discount_percent = 0 + user.promo_offer_expires_at = None + server = _make_server(price_kopeks=5000) + with ( + patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server), + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), + patch('app.services.pricing_engine.settings') as ms, + patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 29000}), + patch('app.services.pricing_engine.PERIOD_PRICES', {30: 29000}), + ): + ms.get_traffic_price.return_value = 3000 + ms.PRICE_PER_DEVICE = 0 + ms.DEFAULT_DEVICE_LIMIT = 2 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + assert result.is_tariff_mode is False + assert result.base_price == 29000 + assert result.servers_price == 5000 + assert result.traffic_price == 3000 + assert result.final_total == 37000 + + @pytest.mark.asyncio + async def test_classic_with_discounts(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = None + subscription.tariff = None + subscription.connected_squads = [] + subscription.traffic_limit_gb = 0 + subscription.purchased_traffic_gb = 0 + subscription.device_limit = 2 + promo_group = MagicMock() + promo_group.id = 1 + promo_group.get_discount_percent.return_value = 20 + user = MagicMock() + user.promo_group = promo_group + user.promo_group_id = 1 + user.promo_offer_discount_percent = 10 + user.promo_offer_expires_at = None + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=10), + patch('app.services.pricing_engine.settings') as ms, + patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}), + patch('app.services.pricing_engine.PERIOD_PRICES', {30: 10000}), + ): + ms.get_traffic_price.return_value = 0 + ms.PRICE_PER_DEVICE = 0 + ms.DEFAULT_DEVICE_LIMIT = 2 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + assert result.final_total == 7200 + assert result.promo_group_discount == 2000 + assert result.promo_offer_discount == 800 + + @pytest.mark.asyncio + async def test_classic_fallback_to_period_prices(self): + """When CLASSIC_PERIOD_PRICES has no entry, falls back to PERIOD_PRICES.""" + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = None + subscription.tariff = None + subscription.connected_squads = [] + subscription.traffic_limit_gb = 0 + subscription.purchased_traffic_gb = 0 + subscription.device_limit = 1 + user = MagicMock() + user.promo_group = None + user.promo_group_id = None + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), + patch('app.services.pricing_engine.settings') as ms, + patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {}), + patch('app.services.pricing_engine.PERIOD_PRICES', {30: 99000}), + ): + ms.get_traffic_price.return_value = 0 + ms.PRICE_PER_DEVICE = 0 + ms.DEFAULT_DEVICE_LIMIT = 1 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + assert result.base_price == 99000 + assert result.final_total == 99000 + + @pytest.mark.asyncio + async def test_classic_extra_devices(self): + engine = PricingEngine() + db = AsyncMock() + subscription = MagicMock() + subscription.tariff_id = None + subscription.tariff = None + subscription.connected_squads = [] + subscription.traffic_limit_gb = 0 + subscription.purchased_traffic_gb = 0 + subscription.device_limit = 5 + user = MagicMock() + user.promo_group = None + user.promo_group_id = None + with ( + patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), + patch('app.services.pricing_engine.settings') as ms, + patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}), + patch('app.services.pricing_engine.PERIOD_PRICES', {}), + ): + ms.get_traffic_price.return_value = 0 + ms.PRICE_PER_DEVICE = 3000 + ms.DEFAULT_DEVICE_LIMIT = 2 + result = await engine.calculate_renewal_price(db, subscription, 30, user=user) + # 5 - 2 = 3 extra devices * 3000 = 9000 + assert result.devices_price == 9000 + assert result.final_total == 19000