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.
This commit is contained in:
+177
-14
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import structlog
|
|
||||||
from dataclasses import dataclass, field
|
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.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()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -70,45 +73,45 @@ class PricingEngine:
|
|||||||
try:
|
try:
|
||||||
server = await get_server_squad_by_uuid(db, uuid)
|
server = await get_server_squad_by_uuid(db, uuid)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка загрузки сервера", squad_uuid=uuid, error=str(e))
|
logger.error('Ошибка загрузки сервера', squad_uuid=uuid, error=str(e))
|
||||||
details.append({"uuid": uuid, "price": 0, "status": "error"})
|
details.append({'uuid': uuid, 'price': 0, 'status': 'error'})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if server is None:
|
if server is None:
|
||||||
logger.error("Сервер не найден в БД", squad_uuid=uuid)
|
logger.error('Сервер не найден в БД', squad_uuid=uuid)
|
||||||
details.append({"uuid": uuid, "price": 0, "status": "not_found"})
|
details.append({'uuid': uuid, 'price': 0, 'status': 'not_found'})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
price = server.price_kopeks or 0
|
price = server.price_kopeks or 0
|
||||||
status = "available"
|
status = 'available'
|
||||||
|
|
||||||
if not server.is_available:
|
if not server.is_available:
|
||||||
status = "unavailable"
|
status = 'unavailable'
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Сервер недоступен, используем реальную цену",
|
'Сервер недоступен, используем реальную цену',
|
||||||
squad_uuid=uuid,
|
squad_uuid=uuid,
|
||||||
price_kopeks=price,
|
price_kopeks=price,
|
||||||
)
|
)
|
||||||
elif server.is_full:
|
elif server.is_full:
|
||||||
status = "full"
|
status = 'full'
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Сервер переполнен, используем реальную цену",
|
'Сервер переполнен, используем реальную цену',
|
||||||
squad_uuid=uuid,
|
squad_uuid=uuid,
|
||||||
price_kopeks=price,
|
price_kopeks=price,
|
||||||
)
|
)
|
||||||
elif promo_group_id is not None:
|
elif promo_group_id is not None:
|
||||||
allowed_ids = [pg.id for pg in (server.allowed_promo_groups or [])]
|
allowed_ids = [pg.id for pg in (server.allowed_promo_groups or [])]
|
||||||
if allowed_ids and promo_group_id not in allowed_ids:
|
if allowed_ids and promo_group_id not in allowed_ids:
|
||||||
status = "not_allowed"
|
status = 'not_allowed'
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Сервер недоступен для промогруппы, используем реальную цену",
|
'Сервер недоступен для промогруппы, используем реальную цену',
|
||||||
squad_uuid=uuid,
|
squad_uuid=uuid,
|
||||||
promo_group_id=promo_group_id,
|
promo_group_id=promo_group_id,
|
||||||
price_kopeks=price,
|
price_kopeks=price,
|
||||||
)
|
)
|
||||||
|
|
||||||
total_price += price
|
total_price += price
|
||||||
details.append({"uuid": uuid, "price": price, "status": status})
|
details.append({'uuid': uuid, 'price': price, 'status': status})
|
||||||
|
|
||||||
return total_price, details
|
return total_price, details
|
||||||
|
|
||||||
@@ -127,3 +130,163 @@ class PricingEngine:
|
|||||||
purchased_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
|
purchased_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
|
||||||
|
|
||||||
return base_price + purchased_price
|
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,
|
||||||
|
)
|
||||||
|
|||||||
+249
-18
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from app.services.pricing_engine import RenewalPricing, PricingEngine
|
|
||||||
|
from app.services.pricing_engine import PricingEngine, RenewalPricing
|
||||||
|
|
||||||
|
|
||||||
def test_renewal_pricing_is_frozen():
|
def test_renewal_pricing_is_frozen():
|
||||||
@@ -77,39 +78,39 @@ class TestCalculateServersPrice:
|
|||||||
engine = PricingEngine()
|
engine = PricingEngine()
|
||||||
db = AsyncMock()
|
db = AsyncMock()
|
||||||
server = _make_server(price_kopeks=5000)
|
server = _make_server(price_kopeks=5000)
|
||||||
with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=server):
|
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)
|
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||||
assert total == 5000
|
assert total == 5000
|
||||||
assert len(details) == 1
|
assert len(details) == 1
|
||||||
assert details[0]["price"] == 5000
|
assert details[0]['price'] == 5000
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unavailable_server_uses_real_price(self):
|
async def test_unavailable_server_uses_real_price(self):
|
||||||
engine = PricingEngine()
|
engine = PricingEngine()
|
||||||
db = AsyncMock()
|
db = AsyncMock()
|
||||||
server = _make_server(price_kopeks=7000, is_available=False)
|
server = _make_server(price_kopeks=7000, is_available=False)
|
||||||
with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=server):
|
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)
|
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||||
assert total == 7000 # NOT 0!
|
assert total == 7000 # NOT 0!
|
||||||
assert details[0]["status"] == "unavailable"
|
assert details[0]['status'] == 'unavailable'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_server_uses_real_price(self):
|
async def test_full_server_uses_real_price(self):
|
||||||
engine = PricingEngine()
|
engine = PricingEngine()
|
||||||
db = AsyncMock()
|
db = AsyncMock()
|
||||||
server = _make_server(price_kopeks=3000, is_full=True)
|
server = _make_server(price_kopeks=3000, is_full=True)
|
||||||
with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=server):
|
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)
|
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
|
||||||
assert total == 3000 # NOT 0!
|
assert total == 3000 # NOT 0!
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_server_not_found_zero_price(self):
|
async def test_server_not_found_zero_price(self):
|
||||||
engine = PricingEngine()
|
engine = PricingEngine()
|
||||||
db = AsyncMock()
|
db = AsyncMock()
|
||||||
with patch("app.services.pricing_engine.get_server_squad_by_uuid", return_value=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)
|
total, details = await engine._calculate_servers_price(['uuid-orphan'], db, promo_group_id=None)
|
||||||
assert total == 0
|
assert total == 0
|
||||||
assert details[0]["status"] == "not_found"
|
assert details[0]['status'] == 'not_found'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_servers(self):
|
async def test_multiple_servers(self):
|
||||||
@@ -117,36 +118,266 @@ class TestCalculateServersPrice:
|
|||||||
db = AsyncMock()
|
db = AsyncMock()
|
||||||
s1 = _make_server(price_kopeks=5000)
|
s1 = _make_server(price_kopeks=5000)
|
||||||
s2 = _make_server(price_kopeks=3000, is_available=False)
|
s2 = _make_server(price_kopeks=3000, is_available=False)
|
||||||
with patch("app.services.pricing_engine.get_server_squad_by_uuid", side_effect=[s1, s2]):
|
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)
|
total, details = await engine._calculate_servers_price(['uuid-1', 'uuid-2'], db, promo_group_id=None)
|
||||||
assert total == 8000
|
assert total == 8000
|
||||||
|
|
||||||
|
|
||||||
class TestCalculateTrafficPrice:
|
class TestCalculateTrafficPrice:
|
||||||
def test_base_only(self):
|
def test_base_only(self):
|
||||||
engine = PricingEngine()
|
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)
|
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)
|
price = engine._calculate_traffic_price(traffic_limit_gb=25, purchased_traffic_gb=0)
|
||||||
assert price == 3000
|
assert price == 3000
|
||||||
|
|
||||||
def test_purchased_separated(self):
|
def test_purchased_separated(self):
|
||||||
engine = PricingEngine()
|
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)
|
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)
|
price = engine._calculate_traffic_price(traffic_limit_gb=125, purchased_traffic_gb=100)
|
||||||
assert price == 11000 # NOT 12000
|
assert price == 11000 # NOT 12000
|
||||||
|
|
||||||
def test_zero_traffic(self):
|
def test_zero_traffic(self):
|
||||||
engine = PricingEngine()
|
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
|
ms.get_traffic_price.return_value = 0
|
||||||
price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0)
|
price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0)
|
||||||
assert price == 0
|
assert price == 0
|
||||||
|
|
||||||
def test_purchased_exceeds_total(self):
|
def test_purchased_exceeds_total(self):
|
||||||
engine = PricingEngine()
|
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)
|
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)
|
price = engine._calculate_traffic_price(traffic_limit_gb=80, purchased_traffic_gb=100)
|
||||||
assert price == 8000 # base_gb clamped to 0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user