diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 06eb1dfa..9bc3bcc5 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -22,7 +22,6 @@ from app.database.models import ( Tariff, TransactionType, User, - UserPromoGroup, ) from app.services.guest_purchase_service import ( GuestPurchaseError, @@ -112,15 +111,17 @@ async def get_gift_config( price = base_price # Apply promo group discount + from app.services.pricing_engine import PricingEngine + promo_group_discount = 0 if promo_group: promo_group_discount = promo_group.get_discount_percent('period', days) if promo_group_discount > 0: - price = int(price * (100 - promo_group_discount) / 100) + price = PricingEngine.apply_discount(price, promo_group_discount) # Apply active promo offer discount (stacks on top) if promo_offer_discount_percent > 0: - price = price - price * promo_offer_discount_percent // 100 + price = PricingEngine.apply_discount(price, promo_offer_discount_percent) # Ensure minimum price of 1 kopek after all discounts price = max(1, price) @@ -249,43 +250,28 @@ async def create_gift_purchase( detail='Tariff not found or inactive', ) - price_kopeks = tariff.get_price_for_period(body.period_days) - if price_kopeks is None: + # Validate that period has a configured price before locking + if tariff.get_price_for_period(body.period_days) is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Price is not configured for this period', ) - # Lock user row to prevent concurrent promo offer double-spend - locked_result = await db.execute( - select(User) - .options( - selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), - selectinload(User.promo_group), - ) - .where(User.id == user.id) - .with_for_update() - .execution_options(populate_existing=True) + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + + from app.services.pricing_engine import pricing_engine + + pricing_result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + body.period_days, + device_limit=tariff.device_limit, + user=user, ) - user = locked_result.scalar_one() - - # Apply promo group discount - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None - if promo_group is None: - promo_group = getattr(user, 'promo_group', None) - - if promo_group: - discount_percent = promo_group.get_discount_percent('period', body.period_days) - if discount_percent > 0: - price_kopeks = int(price_kopeks * (100 - discount_percent) / 100) - - # Apply active promo offer discount (stacks) - promo_offer_discount_percent = get_user_active_promo_discount_percent(user) - if promo_offer_discount_percent > 0: - price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100 - - # Ensure minimum price of 1 kopek after all discounts - price_kopeks = max(1, price_kopeks) + price_kopeks = max(1, pricing_result.final_total) + consume_promo = pricing_result.promo_offer_discount > 0 # Determine buyer contact info if user.email: @@ -420,7 +406,7 @@ async def create_gift_purchase( ) # Consume promo offer discount before committing gateway purchase - if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0): + if consume_promo and getattr(user, 'promo_offer_discount_percent', 0): user.promo_offer_discount_percent = 0 user.promo_offer_discount_source = None user.promo_offer_discount_expires_at = None @@ -485,7 +471,7 @@ async def create_gift_purchase( price_kopeks, description=f'Gift: {tariff.name} ({body.period_days}d)', create_transaction=False, - consume_promo_offer=promo_offer_discount_percent > 0, + consume_promo_offer=consume_promo, ) if not balance_ok: await db.rollback() diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py index 9004b2d7..8a93ec8b 100644 --- a/app/cabinet/routes/landing.py +++ b/app/cabinet/routes/landing.py @@ -342,7 +342,9 @@ async def _load_landing_tariffs( effective_discount = tariff_override if tariff_override is not None else discount.percent original_price_kopeks = price original_price_label = settings.format_price(price) - price = max(1, price - (price * effective_discount // 100)) + from app.services.pricing_engine import PricingEngine + + price = max(1, PricingEngine.apply_discount(price, effective_discount)) periods.append( LandingTariffPeriod( diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index e25a2982..c04238ea 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -42,7 +42,6 @@ from app.services.system_settings_service import bot_configuration_service from app.services.user_cart_service import user_cart_service from app.utils.cache import RateLimitCache, cache, cache_key from app.utils.pricing_utils import format_period_description -from app.utils.promo_offer import get_user_active_promo_discount_percent from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.subscription import ( @@ -68,29 +67,14 @@ router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription']) def _get_addon_discount_percent( - user: User, + user: User | None, category: str, - period_days: int | None = None, + period_days_hint: int | None = None, ) -> int: - """Get addon discount percent for user from promo group. + """Get addon discount percent for user — delegates to PricingEngine.""" + from app.services.pricing_engine import PricingEngine - Mirrors logic from app/handlers/subscription/common.py:_get_addon_discount_percent_for_user - """ - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - if promo_group is None: - return 0 - - if not getattr(promo_group, 'apply_discounts_to_addons', True): - return 0 - - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - return 0 + return PricingEngine.get_addon_discount_percent(user, category, period_days_hint) def _apply_addon_discount( @@ -117,27 +101,12 @@ def _apply_addon_discount( } -def _get_period_discount_percent(user: User, period_days: int | None = None) -> int: - """Get period discount percent for tariff switch calculations.""" - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - if promo_group is None: - return 0 - - try: - return user.get_promo_discount('period', period_days) - except AttributeError: - return 0 - - def _subscription_to_response( subscription: Subscription, servers: list[ServerInfo] | None = None, tariff_name: str | None = None, traffic_purchases: list[dict[str, Any]] | None = None, + user: User | None = None, ) -> SubscriptionData: """Convert Subscription model to response.""" now = datetime.now(UTC) @@ -200,6 +169,18 @@ def _subscription_to_response( traffic_reset_mode = None if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff: daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None) + # Применяем скидку промогруппы + promo-offer для отображения + if daily_price_kopeks and daily_price_kopeks > 0 and user: + from app.services.pricing_engine import PricingEngine + from app.utils.promo_offer import get_user_active_promo_discount_percent + + _promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None + _group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0 + _offer_pct = get_user_active_promo_discount_percent(user) + if _group_pct > 0 or _offer_pct > 0: + daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts( + daily_price_kopeks, _group_pct, _offer_pct + ) if not tariff_name: # Only set if not passed as parameter tariff_name = getattr(subscription.tariff, 'name', None) traffic_reset_mode = ( @@ -321,7 +302,9 @@ async def get_subscription( } ) - subscription_data = _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data) + subscription_data = _subscription_to_response( + fresh_user.subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user + ) return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data) @@ -401,6 +384,11 @@ async def renew_subscription( detail='Selected renewal period is not available', ) + # Lock user row to prevent TOCTOU on promo-offer state + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Unified pricing via PricingEngine pricing = await pricing_engine.calculate_renewal_price( db, @@ -724,6 +712,11 @@ async def purchase_traffic( subscription.end_date, ) + # Lock user row to prevent TOCTOU on promo-offer state + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Apply discount from promo group using proper method period_hint_days = days_charged if days_charged > 0 else 30 discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days) @@ -923,6 +916,11 @@ async def purchase_devices_legacy( base_total_price = device_price * request.devices + # Lock user row to prevent TOCTOU on promo-offer state + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Apply discount from promo group discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30) total_price = discount_result['discounted'] @@ -1363,7 +1361,7 @@ async def activate_trial( except Exception as e: logger.error('Failed to send trial activation notification', error=e) - return _subscription_to_response(subscription) + return _subscription_to_response(subscription, user=user) # ============ Full Purchase Flow (like MiniApp) ============ @@ -1427,17 +1425,29 @@ async def _build_tariff_response( # Стоимость доп. устройств за этот период extra_devices_cost = extra_devices_count * extra_device_price_per_month * months - # Apply promo group discount for this period (на базовую цену тарифа) + # Apply per-category promo group discounts original_price = base_tariff_price + extra_devices_cost - discount_percent = 0 discount_amount = 0 - final_price = original_price if promo_group: - discount_percent = promo_group.get_discount_percent('period', period_days) - if discount_percent > 0: - discount_amount = original_price * discount_percent // 100 - final_price = original_price - discount_amount + period_pct = promo_group.get_discount_percent('period', period_days) + devices_pct = promo_group.get_discount_percent('devices', period_days) + discounted_base = ( + pricing_engine.apply_discount(base_tariff_price, period_pct) + if period_pct > 0 + else base_tariff_price + ) + discounted_devices = ( + pricing_engine.apply_discount(extra_devices_cost, devices_pct) + if devices_pct > 0 + else extra_devices_cost + ) + final_price = discounted_base + discounted_devices + discount_amount = original_price - final_price + discount_percent = max(period_pct, devices_pct) + else: + discount_percent = 0 + final_price = original_price per_month = final_price // months if months > 0 else final_price original_per_month = original_price // months if months > 0 else original_price @@ -1474,16 +1484,21 @@ async def _build_tariff_response( traffic_label = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' - # Apply discount to daily price if applicable + # Apply discount to daily price if applicable (group + promo-offer) daily_price = getattr(tariff, 'daily_price_kopeks', 0) original_daily_price = daily_price daily_discount_percent = 0 - if promo_group and daily_price > 0: - # For daily tariffs, use period discount with period_days=1 - daily_discount_percent = promo_group.get_discount_percent('period', 1) - if daily_discount_percent > 0: - discount_amount = daily_price * daily_discount_percent // 100 - daily_price = daily_price - discount_amount + if daily_price > 0: + from app.services.pricing_engine import PricingEngine + from app.utils.promo_offer import get_user_active_promo_discount_percent + + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + if daily_group_pct > 0 or daily_offer_pct > 0: + daily_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, daily_group_pct, daily_offer_pct) + # Комбинированный процент для отображения + remaining = (100 - daily_group_pct) * (100 - daily_offer_pct) + daily_discount_percent = 100 - remaining // 100 # Apply discount to custom price_per_day if applicable price_per_day = tariff.price_per_day_kopeks @@ -1492,18 +1507,16 @@ async def _build_tariff_response( if promo_group and price_per_day > 0: custom_days_discount_percent = promo_group.get_discount_percent('period', 30) # Use 30-day rate as base if custom_days_discount_percent > 0: - discount_amount = price_per_day * custom_days_discount_percent // 100 - price_per_day = price_per_day - discount_amount + price_per_day = pricing_engine.apply_discount(price_per_day, custom_days_discount_percent) # Apply discount to device price if applicable device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0 original_device_price = device_price device_discount_percent = 0 if promo_group and device_price > 0: - device_discount_percent = promo_group.get_discount_percent('devices') + device_discount_percent = promo_group.get_discount_percent('devices', 30) if device_discount_percent > 0: - discount_amount = device_price * device_discount_percent // 100 - device_price = device_price - discount_amount + device_price = pricing_engine.apply_discount(device_price, device_discount_percent) # Показываем реальное количество устройств (с докупленными) для текущего тарифа actual_device_limit = tariff.device_limit @@ -1703,6 +1716,9 @@ async def submit_purchase( ) try: + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) context = await purchase_service.build_options(db, user) # Convert request to dict for parsing @@ -1774,7 +1790,7 @@ async def submit_purchase( return { 'success': True, 'message': result['message'], - 'subscription': _subscription_to_response(subscription), + 'subscription': _subscription_to_response(subscription, user=user), 'was_trial_conversion': result.get('was_trial_conversion', False), } @@ -1854,6 +1870,11 @@ async def purchase_tariff( detail='Tariff not found or inactive', ) + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Check tariff availability for user's promo group and get promo group for discounts promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None promo_group_id = promo_group.id if promo_group else None @@ -1865,105 +1886,43 @@ async def purchase_tariff( # Handle daily tariffs specially is_daily_tariff = getattr(tariff, 'is_daily', False) - discount_percent = 0 - original_price = 0 - if is_daily_tariff: - daily_price = getattr(tariff, 'daily_price_kopeks', 0) - if daily_price <= 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Daily tariff has invalid price', - ) - original_price = daily_price - # Apply promo group discount for daily tariff - if promo_group: - discount_percent = promo_group.get_discount_percent('period', 1) - if discount_percent > 0: - discount_amount = daily_price * discount_percent // 100 - daily_price = daily_price - discount_amount - # For daily tariffs, charge first day and set period to 1 day - price_kopeks = daily_price period_days = 1 else: period_days = request.period_days - # Get price for period (support custom days) - price_kopeks = tariff.get_price_for_period(period_days) - if price_kopeks is None: - # Check for custom days - if tariff.can_purchase_custom_days(): - price_kopeks = tariff.get_price_for_custom_days(period_days) - if price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Period must be between {tariff.min_days} and {tariff.max_days} days', - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Invalid period for this tariff', - ) - original_price = price_kopeks - # Apply promo group discount for period - if promo_group and price_kopeks > 0: - discount_percent = promo_group.get_discount_percent('period', period_days) - if discount_percent > 0: - discount_amount = price_kopeks * discount_percent // 100 - price_kopeks = price_kopeks - discount_amount - - # Calculate traffic limit and price + # Determine traffic limit (custom traffic support) traffic_limit_gb = tariff.traffic_limit_gb - traffic_price_kopeks = 0 + custom_traffic_gb = None if request.traffic_gb is not None and tariff.can_purchase_custom_traffic(): - # Custom traffic requested - traffic_price_kopeks = tariff.get_price_for_custom_traffic(request.traffic_gb) - if traffic_price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f'Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB', - ) - # Apply traffic discount if promo group has it - if promo_group and traffic_price_kopeks > 0: - traffic_discount_percent = promo_group.get_discount_percent('traffic', period_days) - if traffic_discount_percent > 0: - traffic_discount = traffic_price_kopeks * traffic_discount_percent // 100 - traffic_price_kopeks = traffic_price_kopeks - traffic_discount + custom_traffic_gb = request.traffic_gb traffic_limit_gb = request.traffic_gb - price_kopeks += traffic_price_kopeks - # Проверяем, есть ли докупленные устройства при продлении того же тарифа + # Determine device_limit for renewal pricing existing_subscription = await get_subscription_by_user_id(db, user.id) - extra_devices = 0 + device_limit = None effective_device_limit = tariff.device_limit if existing_subscription and existing_subscription.tariff_id == tariff.id: - extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0)) - if extra_devices > 0: + device_limit = existing_subscription.device_limit + if (existing_subscription.device_limit or 0) > (tariff.device_limit or 0): effective_device_limit = existing_subscription.device_limit - if not is_daily_tariff: - from app.utils.pricing_utils import calculate_months_from_days - device_price_per_month = ( - tariff.device_price_kopeks - if tariff.device_price_kopeks is not None - else settings.PRICE_PER_DEVICE - ) - months = calculate_months_from_days(period_days) - extra_devices_cost = extra_devices * device_price_per_month * months - # Применяем скидку промогруппы на устройства - if promo_group and extra_devices_cost > 0: - devices_discount_pct = promo_group.get_discount_percent('devices', period_days) - if devices_discount_pct > 0: - extra_devices_cost = extra_devices_cost - (extra_devices_cost * devices_discount_pct // 100) - price_kopeks += extra_devices_cost - - # Apply promo offer discount (temporary discount from promo offers) - price_before_promo_offer = price_kopeks - promo_offer_discount_percent = get_user_active_promo_discount_percent(user) - promo_offer_discount_value = 0 - if promo_offer_discount_percent > 0: - promo_offer_discount_value = price_kopeks * promo_offer_discount_percent // 100 - price_kopeks = price_kopeks - promo_offer_discount_value + # Calculate price via PricingEngine (single source of truth) + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period_days, + device_limit=device_limit, + custom_traffic_gb=custom_traffic_gb, + user=user, + ) + price_kopeks = result.final_total + original_price = result.original_total + bd = result.breakdown + group_pcts = bd.get('group_discount_pct', {}) + discount_percent = group_pcts.get('period', 0) + promo_offer_discount_percent = bd.get('offer_discount_pct', 0) + promo_offer_discount_value = result.promo_offer_discount + price_before_promo_offer = price_kopeks + promo_offer_discount_value # Check balance if user.balance_kopeks < price_kopeks: @@ -2141,7 +2100,7 @@ async def purchase_tariff( response = { 'success': True, 'message': f"Тариф '{tariff.name}' успешно активирован", - 'subscription': _subscription_to_response(subscription), + 'subscription': _subscription_to_response(subscription, user=user), 'tariff_id': tariff.id, 'tariff_name': tariff.name, 'charged_amount': price_kopeks, @@ -2319,6 +2278,11 @@ async def purchase_devices( base_price_prorated = int(base_price_per_month * days_left / total_days) base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble + # Lock user BEFORE discount computation to prevent TOCTOU on promo group + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Apply discount from promo group period_hint_days = days_left discount_result = _apply_addon_discount(user, 'devices', base_price_prorated, period_hint_days) @@ -2977,8 +2941,7 @@ async def get_available_countries( await db.refresh(user, ['subscription']) promo_group_id = user.promo_group_id - # Exclude trial-only servers from available servers for purchase - available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True) + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) connected_squads = [] days_left = 0 @@ -2989,11 +2952,10 @@ async def get_available_countries( delta = user.subscription.end_date - datetime.now(UTC) days_left = max(0, delta.days) - # Get discount from promo group - servers_discount_percent = 0 - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None - if promo_group: - servers_discount_percent = promo_group.get_discount_percent('servers', None) + # Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag) + from app.services.pricing_engine import PricingEngine + + servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None) countries = [] for server in available_servers: @@ -3076,8 +3038,7 @@ async def update_countries( current_countries = user.subscription.connected_squads or [] promo_group_id = user.promo_group_id - # Exclude trial-only servers from available servers for purchase - available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True) + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) allowed_country_ids = {server.squad_uuid for server in available_servers} # Validate selected countries @@ -3097,15 +3058,19 @@ async def update_countries( 'connected_squads': current_countries, } + # Lock user row to prevent TOCTOU on promo-offer state + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Calculate cost for added servers total_cost = 0 added_names = [] removed_names = [] - servers_discount_percent = 0 - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None - if promo_group: - servers_discount_percent = promo_group.get_discount_percent('servers', None) + from app.services.pricing_engine import PricingEngine + + servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None) added_server_prices = [] @@ -3903,82 +3868,18 @@ async def preview_tariff_switch( delta = user.subscription.end_date - datetime.now(UTC) remaining_days = max(0, delta.days) - # Calculate switch cost - current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False - new_is_daily = getattr(new_tariff, 'is_daily', False) - switching_to_daily = not current_is_daily and new_is_daily - switching_from_daily = current_is_daily and not new_is_daily - - def get_monthly_price(tariff) -> int: - """Get 30-day price from tariff, or calculate from closest period.""" - if not tariff or not tariff.period_prices: - return 0 - # Try to get 30-day price directly - if '30' in tariff.period_prices: - return tariff.period_prices['30'] - # Find closest period and calculate monthly equivalent - min_period = None - min_price = 0 - for period_str, price in tariff.period_prices.items(): - period_days = int(period_str) - if min_period is None or period_days < min_period: - min_period = period_days - min_price = price - if min_period and min_period > 0: - return int(min_price * 30 / min_period) - return 0 - - # Get period discount percent for cost calculation - period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30) - base_upgrade_cost = 0 - discount_value = 0 - - if switching_to_daily: - # Switching TO daily - pay first day price - daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) - base_upgrade_cost = daily_price - # Apply discount to daily price - if period_discount_percent > 0 and base_upgrade_cost > 0: - discount_value = int(base_upgrade_cost * period_discount_percent / 100) - upgrade_cost = base_upgrade_cost - discount_value - else: - upgrade_cost = base_upgrade_cost - is_upgrade = upgrade_cost > 0 - elif switching_from_daily: - # Switching FROM daily TO periodic - full payment for new tariff - min_period_price = 0 - if new_tariff.period_prices: - min_period_price = min(new_tariff.period_prices.values()) - base_upgrade_cost = min_period_price - # Apply discount - if period_discount_percent > 0 and base_upgrade_cost > 0: - discount_value = int(base_upgrade_cost * period_discount_percent / 100) - upgrade_cost = base_upgrade_cost - discount_value - else: - upgrade_cost = base_upgrade_cost - is_upgrade = upgrade_cost > 0 - else: - # Calculate proportional cost difference using monthly prices - current_monthly = get_monthly_price(current_tariff) - new_monthly = get_monthly_price(new_tariff) - - price_diff = new_monthly - current_monthly - - if price_diff > 0: - # Upgrade - pay proportional difference - base_upgrade_cost = int(price_diff * remaining_days / 30) - # Apply discount to upgrade cost - if period_discount_percent > 0 and base_upgrade_cost > 0: - discount_value = int(base_upgrade_cost * period_discount_percent / 100) - upgrade_cost = base_upgrade_cost - discount_value - else: - upgrade_cost = base_upgrade_cost - is_upgrade = True - else: - # Downgrade or same - free - upgrade_cost = 0 - base_upgrade_cost = 0 - is_upgrade = False + # Calculate switch cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily) + switch_result = pricing_engine.calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days, + user=user, + ) + upgrade_cost = switch_result.upgrade_cost + is_upgrade = switch_result.is_upgrade + base_upgrade_cost = switch_result.raw_cost + discount_value = switch_result.discount_value + period_discount_percent = switch_result.effective_discount_pct balance = user.balance_kopeks or 0 has_enough = balance >= upgrade_cost @@ -4090,88 +3991,41 @@ async def switch_tariff( detail='Tariff not available', ) + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Calculate remaining days remaining_days = 0 - if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC): - delta = user.subscription.end_date - datetime.now(UTC) + if subscription.end_date and subscription.end_date > datetime.now(UTC): + delta = subscription.end_date - datetime.now(UTC) remaining_days = max(0, delta.days) - # Calculate cost - current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + # Calculate cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily) + switch_result = pricing_engine.calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days, + user=user, + ) + upgrade_cost = switch_result.upgrade_cost + base_upgrade_cost = switch_result.raw_cost + discount_value = switch_result.discount_value + period_discount_percent = switch_result.effective_discount_pct + new_period_days = switch_result.new_period_days + + # Validate daily price for switching TO daily new_is_daily = getattr(new_tariff, 'is_daily', False) - switching_from_daily = current_is_daily and not new_is_daily + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False switching_to_daily = not current_is_daily and new_is_daily + switching_from_daily = current_is_daily and not new_is_daily - # Get period discount percent for cost calculation - period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30) - base_upgrade_cost = 0 - discount_value = 0 - - if switching_to_daily: - # Switching TO daily tariff - charge first day price - daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) - if daily_price <= 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Daily tariff has invalid price', - ) - base_upgrade_cost = daily_price - # Apply discount - if period_discount_percent > 0 and base_upgrade_cost > 0: - discount_value = int(base_upgrade_cost * period_discount_percent / 100) - upgrade_cost = base_upgrade_cost - discount_value - else: - upgrade_cost = base_upgrade_cost - new_period_days = 1 # Daily tariff starts with 1 day - elif switching_from_daily: - # Switch FROM daily to regular tariff - pay for minimum period - min_period_days = 30 - min_period_price = 0 - if new_tariff.period_prices: - min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) - min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) - base_upgrade_cost = min_period_price - # Apply discount - if period_discount_percent > 0 and base_upgrade_cost > 0: - discount_value = int(base_upgrade_cost * period_discount_percent / 100) - upgrade_cost = base_upgrade_cost - discount_value - else: - upgrade_cost = base_upgrade_cost - new_period_days = min_period_days - else: - # Regular tariff switch - calculate proportional cost difference using monthly prices - def get_monthly_price(tariff) -> int: - if not tariff or not tariff.period_prices: - return 0 - if '30' in tariff.period_prices: - return tariff.period_prices['30'] - min_period = None - min_price = 0 - for period_str, price in tariff.period_prices.items(): - period_days = int(period_str) - if min_period is None or period_days < min_period: - min_period = period_days - min_price = price - if min_period and min_period > 0: - return int(min_price * 30 / min_period) - return 0 - - current_monthly = get_monthly_price(current_tariff) - new_monthly = get_monthly_price(new_tariff) - price_diff = new_monthly - current_monthly - - if price_diff > 0: - base_upgrade_cost = int(price_diff * remaining_days / 30) - # Apply discount - if period_discount_percent > 0 and base_upgrade_cost > 0: - discount_value = int(base_upgrade_cost * period_discount_percent / 100) - upgrade_cost = base_upgrade_cost - discount_value - else: - upgrade_cost = base_upgrade_cost - else: - upgrade_cost = 0 - base_upgrade_cost = 0 - new_period_days = 0 + if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Daily tariff has invalid price', + ) # Charge if upgrade if upgrade_cost > 0: @@ -4202,6 +4056,7 @@ async def switch_tariff( user, upgrade_cost, description, + consume_promo_offer=switch_result.offer_discount_pct > 0, mark_as_paid_subscription=True, commit=False, ) @@ -4359,7 +4214,7 @@ async def switch_tariff( 'success': True, 'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'" + (' (devices reset)' if devices_reset else ''), - 'subscription': _subscription_to_response(subscription), + 'subscription': _subscription_to_response(subscription, user=user), 'old_tariff_name': old_tariff_name, 'new_tariff_id': new_tariff.id, 'new_tariff_name': new_tariff.name, @@ -4426,7 +4281,21 @@ async def toggle_subscription_pause( new_paused_state = not is_currently_paused user.subscription.is_daily_paused = new_paused_state - daily_price = getattr(tariff, 'daily_price_kopeks', 0) + raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) + + # Lock user BEFORE discount computation to prevent TOCTOU on promo group + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + + # Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_price = ( + PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price + ) # If resuming, check balance and charge if not new_paused_state: @@ -4568,22 +4437,16 @@ async def switch_traffic_package( # Upgrade - charge difference price_diff = new_price - current_price - # Apply promo discount - traffic_discount_percent = 0 - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - if promo_group: - apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) - if apply_to_addons: - traffic_discount_percent = max( - 0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)) - ) + # Lock user row to prevent TOCTOU on promo-offer state + from app.database.crud.user import lock_user_for_pricing - if traffic_discount_percent > 0: - price_diff = int(price_diff * (100 - traffic_discount_percent) / 100) + user = await lock_user_for_pricing(db, user.id) + + # Apply promo discount via PricingEngine + price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount( + price_diff, + user, + ) # Prorated calculation final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date) diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index cc7ec4a1..1a3d0551 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -141,8 +141,12 @@ async def get_available_server_squads( .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) - if exclude_trial_only: - query = query.where(ServerSquad.is_trial_eligible.is_(False)) + # НЕ фильтруем по is_trial_eligible — это поле означает "доступен для триала", + # а НЕ "только для триала". Сквад может быть одновременно триальным и платным. + # Фильтр exclude_trial_only убирал единственный доступный сквад, из-за чего + # пользователи без триала получали пустой connected_squads при покупке. + # Параметр exclude_trial_only сохранён для обратной совместимости, но не используется. + # TODO: если нужна логика "только для триала", добавить отдельное поле is_trial_only if promo_group_id is not None: query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 4d946fae..251f1578 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,6 +1,5 @@ from collections.abc import Iterable from datetime import UTC, datetime, timedelta -from typing import Optional import structlog from sqlalchemy import and_, delete, func, select @@ -11,7 +10,6 @@ from sqlalchemy.orm.exc import StaleDataError from app.config import settings from app.database.crud.notification import clear_notifications from app.database.models import ( - PromoGroup, Subscription, SubscriptionServer, SubscriptionStatus, @@ -20,7 +18,6 @@ from app.database.models import ( User, UserStatus, ) -from app.utils.pricing_utils import calculate_months_from_days from app.utils.timezone import format_local_datetime @@ -221,6 +218,23 @@ async def create_paid_subscription( if device_limit is None: device_limit = settings.DEFAULT_DEVICE_LIMIT + # Fallback: если connected_squads пустой — берём первый доступный сквад + final_squads = list(connected_squads or []) + if not final_squads: + try: + from app.database.crud.server_squad import get_available_server_squads + + available = await get_available_server_squads(db) + if available: + final_squads = [available[0].squad_uuid] + logger.warning( + '⚠️ connected_squads пустой при создании подписки, используем fallback сквад', + user_id=user_id, + fallback_squad=final_squads[0], + ) + except Exception as error: + logger.error('❌ Не удалось получить fallback сквад', user_id=user_id, error=error) + subscription = Subscription( user_id=user_id, status=SubscriptionStatus.ACTIVE.value, @@ -229,7 +243,7 @@ async def create_paid_subscription( end_date=end_date, traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, - connected_squads=connected_squads or [], + connected_squads=final_squads, autopay_enabled=settings.is_autopay_enabled_by_default(), autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, tariff_id=tariff_id, @@ -249,7 +263,7 @@ async def create_paid_subscription( status=subscription.status, ) - squad_uuids = list(connected_squads or []) + squad_uuids = list(final_squads) if update_server_counters and squad_uuids: try: from app.database.crud.server_squad import ( @@ -299,7 +313,25 @@ async def replace_subscription( current_time = datetime.now(UTC) old_squads = set(subscription.connected_squads or []) - new_squads = set(connected_squads or []) + + # Fallback: если connected_squads пустой — берём первый доступный сквад + final_connected = list(connected_squads or []) + if not final_connected: + try: + from app.database.crud.server_squad import get_available_server_squads + + available = await get_available_server_squads(db) + if available: + final_connected = [available[0].squad_uuid] + logger.warning( + '⚠️ connected_squads пустой при замене подписки, используем fallback сквад', + subscription_id=subscription.id, + fallback_squad=final_connected[0], + ) + except Exception as error: + logger.error('❌ Не удалось получить fallback сквад', subscription_id=subscription.id, error=error) + + new_squads = set(final_connected) new_autopay_enabled = subscription.autopay_enabled if autopay_enabled is None else autopay_enabled new_autopay_days_before = subscription.autopay_days_before if autopay_days_before is None else autopay_days_before @@ -549,9 +581,17 @@ async def extend_subscription( logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit) if connected_squads is not None: - old_squads = subscription.connected_squads - subscription.connected_squads = connected_squads - logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads) + # Не перезаписываем существующие сквады пустым списком + if connected_squads or not subscription.connected_squads: + old_squads = subscription.connected_squads + subscription.connected_squads = connected_squads + logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads) + else: + logger.warning( + '⚠️ Попытка перезаписать сквады пустым списком, сохраняем текущие', + subscription_id=subscription.id, + current_squads=subscription.connected_squads, + ) # Обработка daily полей при смене тарифа if is_tariff_change and tariff_id is not None: @@ -1198,212 +1238,6 @@ async def add_subscription_servers( return subscription -async def get_server_monthly_price(db: AsyncSession, server_squad_id: int) -> int: - from app.database.models import ServerSquad - - result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_squad_id)) - return result.scalar() or 0 - - -async def get_servers_monthly_prices( - db: AsyncSession, - server_squad_ids: list[int], - *, - user: Optional['User'] = None, -) -> list[int]: - """Получает месячные цены серверов с проверкой доступности для промогруппы пользователя.""" - from sqlalchemy.orm import selectinload - - from app.database.models import ServerSquad - - prices = [] - - # Загружаем промогруппы пользователя если нужно - user_promo_group = None - user_promo_group_id = None - if user: - try: - # Пробуем загрузить промогруппы если ещё не загружены - await db.refresh(user, ['user_promo_groups', 'promo_group']) - except Exception: - pass - try: - user_promo_group = user.get_primary_promo_group() - user_promo_group_id = user_promo_group.id if user_promo_group else None - except Exception as e: - logger.warning('Не удалось получить промогруппу пользователя', error=e) - - for server_id in server_squad_ids: - # Загружаем сервер с промогруппами - result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.allowed_promo_groups)) - .where(ServerSquad.id == server_id) - ) - server = result.scalar_one_or_none() - - if not server: - prices.append(0) - continue - - # Проверяем доступность сервера для промогруппы пользователя - is_allowed = True - if user_promo_group_id is not None and server.allowed_promo_groups: - allowed_ids = {pg.id for pg in server.allowed_promo_groups} - is_allowed = user_promo_group_id in allowed_ids - - if server.is_available and is_allowed: - prices.append(server.price_kopeks) - else: - # Сервер недоступен для промогруппы пользователя - logger.warning( - '⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups', - display_name=server.display_name, - server_id=server_id, - user_promo_group_id=user_promo_group_id, - value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [], - ) - prices.append(server.price_kopeks) # Всё равно берём реальную цену - - return prices - - -def _get_discount_percent( - user: User | None, - promo_group: PromoGroup | None, - category: str, - *, - period_days: int | None = None, -) -> int: - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - -async def calculate_subscription_total_cost( - db: AsyncSession, - period_days: int, - traffic_gb: int, - server_squad_ids: list[int], - devices: int, - *, - user: User | None = None, - promo_group: PromoGroup | None = None, -) -> tuple[int, dict]: - from app.config import PERIOD_PRICES - - months_in_period = calculate_months_from_days(period_days) - - base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = _get_discount_percent( - user, - promo_group, - 'period', - period_days=period_days, - ) - base_discount_total = base_price_original * period_discount_percent // 100 - base_price = base_price_original - base_discount_total - - promo_group = promo_group or (user.promo_group if user else None) - - traffic_price_per_month = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = _get_discount_percent( - user, - promo_group, - 'traffic', - period_days=period_days, - ) - traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 - discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month - total_traffic_price = discounted_traffic_per_month * months_in_period - total_traffic_discount = traffic_discount_per_month * months_in_period - - servers_prices = await get_servers_monthly_prices(db, server_squad_ids, user=user) - servers_price_per_month = sum(servers_prices) - servers_discount_percent = _get_discount_percent( - user, - promo_group, - 'servers', - period_days=period_days, - ) - servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 - discounted_servers_per_month = servers_price_per_month - servers_discount_per_month - total_servers_price = discounted_servers_per_month * months_in_period - total_servers_discount = servers_discount_per_month * months_in_period - - additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_discount_percent( - user, - promo_group, - 'devices', - period_days=period_days, - ) - devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 - discounted_devices_per_month = devices_price_per_month - devices_discount_per_month - total_devices_price = discounted_devices_per_month * months_in_period - total_devices_discount = devices_discount_per_month * months_in_period - - total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price - - details = { - 'base_price': base_price, - 'base_price_original': base_price_original, - 'base_discount_percent': period_discount_percent, - 'base_discount_total': base_discount_total, - 'traffic_price_per_month': traffic_price_per_month, - 'traffic_discount_percent': traffic_discount_percent, - 'traffic_discount_total': total_traffic_discount, - 'total_traffic_price': total_traffic_price, - 'servers_price_per_month': servers_price_per_month, - 'servers_discount_percent': servers_discount_percent, - 'servers_discount_total': total_servers_discount, - 'total_servers_price': total_servers_price, - 'devices_price_per_month': devices_price_per_month, - 'devices_discount_percent': devices_discount_percent, - 'devices_discount_total': total_devices_discount, - 'total_devices_price': total_devices_price, - 'months_in_period': months_in_period, - 'servers_individual_prices': [ - (price - (price * servers_discount_percent // 100)) * months_in_period for price in servers_prices - ], - } - - logger.debug( - '📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period - ) - logger.debug('Базовый период: ₽', base_price=base_price / 100) - if total_traffic_price > 0: - message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽' - if total_traffic_discount > 0: - message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)' - logger.debug(message) - if total_servers_price > 0: - message = ( - f' Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽' - ) - if total_servers_discount > 0: - message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' - logger.debug(message) - if total_devices_price > 0: - message = ( - f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽' - ) - if total_devices_discount > 0: - message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)' - logger.debug(message) - logger.debug('ИТОГО: ₽', total_cost=total_cost / 100) - - return total_cost, details - - async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) -> list[int]: result = await db.execute( select(SubscriptionServer.server_squad_id).where(SubscriptionServer.subscription_id == subscription_id) @@ -1901,8 +1735,9 @@ async def get_disabled_daily_subscriptions_for_resume( # Не возобновляем подписки, приостановленные пользователем вручную # is_(False) не ловит NULL, поэтому добавляем OR is_(None) (Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)), - # Баланс пользователя >= суточной цены тарифа - User.balance_kopeks >= Tariff.daily_price_kopeks, + # Баланс пользователя > 0 (permissive pre-filter; + # actual discounted price check happens in _process_single_charge) + User.balance_kopeks > 0, ) ) ) @@ -1947,8 +1782,9 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list Subscription.is_trial.is_(False), # Только недавно экспайренные Subscription.updated_at >= recovery_threshold, - # Баланс достаточен для списания - User.balance_kopeks >= Tariff.daily_price_kopeks, + # Баланс > 0 (permissive pre-filter; + # actual discounted price check happens in _process_single_charge) + User.balance_kopeks > 0, ) ) ) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index d5cffc87..22cbd6bb 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -528,6 +528,26 @@ async def add_user_balance_by_id( return False +async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User: + """Lock user row with FOR UPDATE and return refreshed instance. + + Call BEFORE computing prices that depend on promo offer state + to prevent TOCTOU race conditions where two concurrent requests + both read the same promo offer discount and charge a discounted price. + """ + result = await db.execute( + select(User) + .where(User.id == user_id) + .options( + selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), + selectinload(User.promo_group), + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one() + + async def subtract_user_balance( db: AsyncSession, user: User, diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index bea36071..6c53e652 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -1103,13 +1103,27 @@ async def confirm_button_selection(callback: types.CallbackQuery, db_user: User, await callback.message.delete() except Exception: pass - await callback.bot.send_photo( - chat_id=callback.message.chat.id, - photo=media_file_id, - caption=preview_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode='HTML', - ) + # Telegram ограничивает caption до 1024 символов + if len(preview_text) <= 1024: + await callback.bot.send_photo( + chat_id=callback.message.chat.id, + photo=media_file_id, + caption=preview_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode='HTML', + ) + else: + # Фото без caption + текст отдельным сообщением + await callback.bot.send_photo( + chat_id=callback.message.chat.id, + photo=media_file_id, + ) + await callback.bot.send_message( + chat_id=callback.message.chat.id, + text=preview_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode='HTML', + ) else: # Если нет file_id, используем safe редактирование await safe_edit_or_send_text( @@ -1244,13 +1258,27 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: 'video': 'video', 'document': 'document', }[media_type] - await send_method( - chat_id=telegram_id, - **{media_kwarg: media_file_id}, - caption=message_text, - parse_mode='HTML', - reply_markup=broadcast_keyboard, - ) + # Telegram ограничивает caption до 1024 символов + if len(message_text) <= 1024: + await send_method( + chat_id=telegram_id, + **{media_kwarg: media_file_id}, + caption=message_text, + parse_mode='HTML', + reply_markup=broadcast_keyboard, + ) + else: + # Медиа без caption + текст отдельным сообщением + await send_method( + chat_id=telegram_id, + **{media_kwarg: media_file_id}, + ) + await callback.bot.send_message( + chat_id=telegram_id, + text=message_text, + parse_mode='HTML', + reply_markup=broadcast_keyboard, + ) else: # Неизвестный media_type — отправляем как текст await callback.bot.send_message( diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 073c48a1..d8ea5e22 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -4457,6 +4457,11 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: subscription_service = SubscriptionService() + # TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications + from app.database.crud.user import lock_user_for_pricing + + target_user = await lock_user_for_pricing(db, target_user.id) + try: price_kopeks = await _calculate_subscription_period_price( db, @@ -4914,7 +4919,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, user_id = int(parts[4]) tariff_id = int(parts[5]) period = int(parts[6]) - price_kopeks = int(parts[7]) + price_kopeks_from_callback = int(parts[7]) user_service = UserService() profile = await user_service.get_user_profile(db, user_id) @@ -4933,7 +4938,48 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, await callback.answer('❌ Тариф недоступен', show_alert=True) return - # Проверяем баланс ещё раз + # TOCTOU protection: lock user row before pricing to prevent concurrent balance modifications + from app.database.crud.user import lock_user_for_pricing + + target_user = await lock_user_for_pricing(db, target_user.id) + + from app.database.crud.subscription import get_subscription_by_user_id + + existing_subscription = await get_subscription_by_user_id(db, target_user.id) + + # Recalculate price from locked state (callback data may be stale) + from app.services.pricing_engine import PricingEngine + + pricing_engine = PricingEngine() + device_limit = None + if existing_subscription and existing_subscription.tariff_id == tariff_id: + device_limit = existing_subscription.device_limit + + try: + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=device_limit, + user=target_user, + ) + price_kopeks = result.final_total + except Exception as e: + logger.error( + 'Ошибка расчёта стоимости тарифа при списании средств админом для пользователя', + telegram_id=target_user.telegram_id, + e=e, + ) + await callback.answer('❌ Не удалось рассчитать стоимость тарифа', show_alert=True) + return + + if price_kopeks_from_callback != price_kopeks: + logger.info( + 'Стоимость тарифа для пользователя изменилась перед списанием', + telegram_id=target_user.telegram_id, + price_kopeks_from_callback=price_kopeks_from_callback, + price_kopeks=price_kopeks, + ) + if target_user.balance_kopeks < price_kopeks: await callback.answer('❌ Недостаточно средств на балансе', show_alert=True) return @@ -4942,7 +4988,6 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, from app.database.crud.subscription import ( create_paid_subscription, extend_subscription, - get_subscription_by_user_id, ) from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index de032223..3fdd1f15 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -18,7 +18,6 @@ from app.keyboards.inline import ( from app.localization.texts import get_texts from app.states import BalanceStates from app.utils.decorators import error_handler -from app.utils.price_display import calculate_user_price logger = structlog.get_logger(__name__) @@ -153,7 +152,8 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: """ Generate quick amount buttons with user-specific pricing and discounts. - Includes full subscription cost: base period price + devices + servers + traffic. + Uses PricingEngine as the single source of truth for all price calculations, + including base period price, devices, servers, traffic, and per-category discounts. Args: language: User's language for formatting @@ -165,135 +165,77 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: if not settings.is_quick_amount_buttons_enabled(): return [] - from app.config import PERIOD_PRICES from app.database.crud.subscription import get_subscription_by_user_id from app.database.database import AsyncSessionLocal - from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days + from app.services.pricing_engine import pricing_engine texts = get_texts(language) - tariff = None - tariff_prices = None - tariff_periods = None - devices_price_per_month = 0 - servers_per_month_prices: list[int] = [] - traffic_price_per_month = 0 + buttons = [] async with AsyncSessionLocal() as db: subscription = await get_subscription_by_user_id(db, user.id) - # В режиме тарифов получаем цены из тарифа пользователя + tariff = None + tariff_periods = None if settings.is_tariffs_mode() and subscription and subscription.tariff_id: - from app.database.crud.tariff import get_tariff_by_id - - tariff = await get_tariff_by_id(db, subscription.tariff_id) + tariff = subscription.tariff if tariff and tariff.period_prices: - tariff_prices = {int(k): v for k, v in tariff.period_prices.items()} - tariff_periods = sorted(tariff_prices.keys()) + tariff_periods = sorted(int(k) for k in tariff.period_prices.keys()) - # Получаем стоимость устройств, серверов и трафика из подписки - if subscription and not subscription.is_trial: - # Устройства: в режиме тарифов используем цену и базовый лимит из тарифа - if settings.is_tariffs_mode() and tariff and tariff_prices: - tariff_device_price = getattr(tariff, 'device_price_kopeks', None) - if tariff_device_price and tariff_device_price > 0: - device_unit_price = tariff_device_price - base_device_limit = tariff.device_limit or 0 - else: - device_unit_price = settings.PRICE_PER_DEVICE - base_device_limit = settings.DEFAULT_DEVICE_LIMIT - else: - device_unit_price = settings.PRICE_PER_DEVICE - base_device_limit = settings.DEFAULT_DEVICE_LIMIT - - device_limit = subscription.device_limit or base_device_limit - additional_devices = max(0, device_limit - base_device_limit) - if additional_devices > 0: - devices_price_per_month = additional_devices * device_unit_price - - # Серверы - connected_squads = subscription.connected_squads or [] - if connected_squads: - from app.services.subscription_service import SubscriptionService - - subscription_service = SubscriptionService() - _, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids( - connected_squads, db, promo_group_id=user.promo_group_id - ) - - # Трафик - traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - - buttons = [] - - # Используем периоды тарифа в режиме тарифов, иначе стандартные - if tariff_periods: - periods = tariff_periods[:6] - else: - periods = settings.get_available_subscription_periods()[:6] - - for period in periods: - # Получаем цену из тарифа или из PERIOD_PRICES - if tariff_prices and period in tariff_prices: - base_price_kopeks = tariff_prices[period] + if tariff_periods: + periods = tariff_periods[:6] else: - base_price_kopeks = PERIOD_PRICES.get(period, 0) + periods = settings.get_available_subscription_periods()[:6] - if base_price_kopeks > 0: - # Базовая цена периода с промо-скидками - price_info = calculate_user_price(user, base_price_kopeks, period, 'period') - - months = calculate_months_from_days(period) - - # Стоимость устройств со скидкой - devices_addon = 0 - if devices_price_per_month > 0: - devices_discount = user.get_promo_discount('devices', period) - devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount) - devices_addon = devices_discounted * months - - # Стоимость серверов со скидкой - servers_addon = 0 - if servers_per_month_prices: - servers_discount = user.get_promo_discount('servers', period) - for server_price in servers_per_month_prices: - discounted, _ = apply_percentage_discount(server_price, servers_discount) - servers_addon += discounted - servers_addon *= months - - # Стоимость трафика со скидкой - traffic_addon = 0 - if traffic_price_per_month > 0: - traffic_discount = user.get_promo_discount('traffic', period) - traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount) - traffic_addon = traffic_discounted * months - - total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon - callback_data = f'quick_amount_{total_price}' - - period_label = f'{period} дней' - - # Скидка считается от полной базовой стоимости (период + аддоны без скидок) - total_base = ( - base_price_kopeks - + (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months - ) - has_discount = total_base > total_price and total_base > 0 - - if has_discount: - discount_pct = round((total_base - total_price) * 100 / total_base) - if discount_pct > 0: - button_text = ( - f'{texts.format_price(total_base)} ➜ ' - f'{texts.format_price(total_price)} ' - f'(-{discount_pct}%) • {period_label}' + for period in periods: + try: + if tariff and tariff_periods and period in tariff_periods: + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=subscription.device_limit if subscription else None, + user=user, ) + elif subscription: + result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user) + else: + result = await pricing_engine.calculate_classic_new_subscription_price( + db, + period, + [], + 0, + settings.DEFAULT_DEVICE_LIMIT, + user=user, + ) + + total_price = result.final_total + original_total = result.original_total + + if total_price <= 0: + continue + + callback_data = f'quick_amount_{total_price}' + period_label = f'{period} дней' + + has_discount = original_total > total_price and original_total > 0 + if has_discount: + discount_pct = round((original_total - total_price) * 100 / original_total) + if discount_pct > 0: + button_text = ( + f'{texts.format_price(original_total)} ➜ ' + f'{texts.format_price(total_price)} ' + f'(-{discount_pct}%) • {period_label}' + ) + else: + button_text = f'{texts.format_price(total_price)} • {period_label}' else: button_text = f'{texts.format_price(total_price)} • {period_label}' - else: - button_text = f'{texts.format_price(total_price)} • {period_label}' - buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data)) + buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data)) + except Exception: + logger.warning('Failed to calculate price for period', period=period) + continue keyboard_rows = [] for i in range(0, len(buttons), 2): diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 10b92f6a..26b5894e 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1249,7 +1249,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d """ texts = get_texts(db_user.language) - from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids + from app.database.crud.server_squad import get_available_server_squads from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance @@ -1287,7 +1287,9 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d if not connected_squads and available_servers: connected_squads = [available_servers[0].squad_uuid] - server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else [] + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) balance = db_user.balance_kopeks available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) @@ -1299,7 +1301,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d best_price = 0 best_pricing = None # Cache pricing result for reuse in finalize() - # Для продления используем PricingEngine (единый расчёт для всех поверхностей). + # PricingEngine — единый расчёт для всех поверхностей (и продление, и новая подписка). from app.services.pricing_engine import pricing_engine renewal_service = SubscriptionRenewalService() if subscription else None @@ -1310,9 +1312,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user) price = pricing_result.final_total else: - price, _ = await subscription_service.calculate_subscription_price_with_months( - period, traffic_limit_gb, server_ids, device_limit, db, user=db_user + new_pricing = await pricing_engine.calculate_classic_new_subscription_price( + db, + period, + connected_squads, + traffic_limit_gb, + device_limit, + user=db_user, ) + price = new_pricing.final_total if price <= balance: best_period = period best_price = price @@ -1326,9 +1334,15 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d min_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user) min_price = min_pricing.final_total else: - min_price, _ = await subscription_service.calculate_subscription_price_with_months( - min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user + min_new_pricing = await pricing_engine.calculate_classic_new_subscription_price( + db, + min_period, + connected_squads, + traffic_limit_gb, + device_limit, + user=db_user, ) + min_price = min_new_pricing.final_total missing = min_price - balance await callback.answer( texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100} ₽'), @@ -1365,12 +1379,14 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d ) else: # Списать баланс ДО создания подписки (чтобы не было orphaned subscription при неудаче) + consume_promo = get_user_active_promo_discount_percent(db_user) > 0 success = await subtract_user_balance( db, db_user, best_price, f'Активация подписки на {best_period} дней', mark_as_paid_subscription=True, + consume_promo_offer=consume_promo, ) if not success: await callback.answer('❌ Недостаточно средств', show_alert=True) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 37e4a4af..36d2c7d2 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -401,13 +401,25 @@ async def handle_simple_subscription_pay_with_balance( state_data=data, ) - # Рассчитываем цену подписки + # Lock user BEFORE pricing to prevent TOCTOU + from app.database.crud.user import lock_user_for_pricing, subtract_user_balance + + db_user = await lock_user_for_pricing(db, db_user.id) + + # Рассчитываем цену подписки (group discounts per-category) price_kopeks, price_breakdown = await _calculate_simple_subscription_price( db, subscription_params, user=db_user, resolved_squad_uuid=resolved_squad_uuid, ) + + # PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price. + # Only determine whether to consume the offer (zero it out after use). + from app.utils.promo_offer import get_user_active_promo_discount_percent + + consume_promo = get_user_active_promo_discount_percent(db_user) > 0 + total_required = price_kopeks logger.warning( 'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance', @@ -431,15 +443,13 @@ async def handle_simple_subscription_pay_with_balance( try: # Списываем средства с баланса пользователя - from app.database.crud.user import subtract_user_balance - purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней' success = await subtract_user_balance( db, db_user, price_kopeks, purchase_description, - consume_promo_offer=False, + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) @@ -840,7 +850,7 @@ async def handle_simple_subscription_payment_method( state_data=data, ) - # Рассчитываем цену подписки + # Рассчитываем цену подписки (group discounts per-category) price_kopeks, _ = await _calculate_simple_subscription_price( db, subscription_params, @@ -848,6 +858,14 @@ async def handle_simple_subscription_payment_method( resolved_squad_uuid=resolved_squad_uuid, ) + # Apply promo-offer discount on top of group discounts (consistent with balance-pay path) + from app.services.pricing_engine import PricingEngine + from app.utils.promo_offer import get_user_active_promo_discount_percent + + offer_pct = get_user_active_promo_discount_percent(db_user) + if offer_pct > 0: + price_kopeks = PricingEngine.apply_discount(price_kopeks, offer_pct) + if payment_method == 'stars': # Оплата через Telegram Stars order = await purchase_service.create_subscription_order( @@ -2121,13 +2139,25 @@ async def confirm_simple_subscription_purchase( state_data=data, ) - # Рассчитываем цену подписки + # Lock user BEFORE pricing to prevent TOCTOU + from app.database.crud.user import lock_user_for_pricing, subtract_user_balance + + db_user = await lock_user_for_pricing(db, db_user.id) + + # Рассчитываем цену подписки (group discounts per-category) price_kopeks, price_breakdown = await _calculate_simple_subscription_price( db, subscription_params, user=db_user, resolved_squad_uuid=resolved_squad_uuid, ) + + # PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price. + # Only determine whether to consume the offer (zero it out after use). + from app.utils.promo_offer import get_user_active_promo_discount_percent + + consume_promo = get_user_active_promo_discount_percent(db_user) > 0 + total_required = price_kopeks logger.warning( 'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance', @@ -2151,15 +2181,13 @@ async def confirm_simple_subscription_purchase( try: # Списываем средства с баланса пользователя - from app.database.crud.user import subtract_user_balance - purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней' success = await subtract_user_balance( db, db_user, price_kopeks, purchase_description, - consume_promo_offer=False, + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index 304263f6..9abe6806 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -56,43 +56,6 @@ def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str return template -def _get_addon_discount_percent_for_user( - user: User | None, - category: str, - period_days_hint: int | None = None, -) -> int: - if user is None: - return 0 - - promo_group = user.get_primary_promo_group() - if promo_group is None: - return 0 - - if not getattr(promo_group, 'apply_discounts_to_addons', True): - return 0 - - try: - return user.get_promo_discount(category, period_days_hint) - except AttributeError: - return 0 - - -def _apply_addon_discount( - user: User | None, - category: str, - amount: int, - period_days_hint: int | None = None, -) -> dict[str, int]: - percent = _get_addon_discount_percent_for_user(user, category, period_days_hint) - discounted_amount, discount_value = apply_percentage_discount(amount, percent) - - return { - 'discounted': discounted_amount, - 'discount': discount_value, - 'percent': percent, - } - - def _get_promo_offer_discount_percent(user: User | None) -> int: return get_user_active_promo_discount_percent(user) diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 088eed83..2919e3bc 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -5,9 +5,9 @@ from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from app.config import PERIOD_PRICES, settings +from app.config import settings from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import lock_user_for_pricing, subtract_user_balance from app.database.models import TransactionType, User from app.keyboards.inline import ( get_back_keyboard, @@ -17,6 +17,7 @@ from app.keyboards.inline import ( get_manage_countries_keyboard, ) from app.localization.texts import get_texts +from app.services.pricing_engine import PricingEngine, pricing_engine from app.services.subscription_checkout_service import ( save_subscription_checkout_draft, should_offer_checkout_resume, @@ -28,7 +29,7 @@ from app.utils.pricing_utils import ( calculate_prorated_price, ) -from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger +from .common import _get_period_hint_from_subscription, logger from .summary import present_subscription_summary @@ -58,7 +59,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db: current_countries = subscription.connected_squads period_hint_days = _get_period_hint_from_subscription(subscription) - servers_discount_percent = _get_addon_discount_percent_for_user( + servers_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'servers', period_hint_days, @@ -194,7 +195,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db await state.update_data(countries=current_selected) period_hint_days = _get_period_hint_from_subscription(subscription) - servers_discount_percent = _get_addon_discount_percent_for_user( + servers_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'servers', period_hint_days, @@ -257,7 +258,12 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, days_to_pay = max(1, (subscription.end_date - now).days) period_hint_days = days_to_pay if days_to_pay > 0 else None - servers_discount_percent = _get_addon_discount_percent_for_user( + + # TOCTOU protection: lock user row before reading discount and charging balance + db_user = await lock_user_for_pricing(db, db_user.id) + subscription = db_user.subscription + + servers_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'servers', period_hint_days, @@ -496,31 +502,18 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True) return - period_base_price = PERIOD_PRICES.get(data['period_days'], 0) - discounted_base_price, _ = apply_percentage_discount( - period_base_price, - db_user.get_promo_discount('period', data['period_days']), - ) - - base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb']) - - try: - subscription_service = SubscriptionService() - countries_price, _ = await subscription_service.get_countries_price_by_uuids( - selected_countries, - db, - promo_group_id=db_user.promo_group_id, - ) - except AttributeError: - logger.warning('Используем fallback функцию для расчета цен стран') - countries_price, _ = await get_countries_price_by_uuids_fallback( - selected_countries, - db, - promo_group_id=db_user.promo_group_id, - ) - data['countries'] = selected_countries - data['total_price'] = base_price + countries_price + + # Вычисляем цену через PricingEngine с актуальными FSM-данными + pricing_result = await pricing_engine.calculate_classic_new_subscription_price( + db, + data['period_days'], + list(selected_countries), + data.get('traffic_gb', 0) or 0, + data.get('devices', settings.DEFAULT_DEVICE_LIMIT), + user=db_user, + ) + data['total_price'] = pricing_result.final_total await state.set_data(data) await callback.message.edit_reply_markup( @@ -700,7 +693,7 @@ async def handle_add_country_to_subscription( total_price = 0 subscription = db_user.subscription period_hint_days = _get_period_hint_from_subscription(subscription) - servers_discount_percent = _get_addon_discount_percent_for_user( + servers_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'servers', period_hint_days, @@ -808,12 +801,16 @@ async def confirm_add_countries_to_subscription( await callback.answer('⚠️ Изменения не обнаружены', show_alert=True) return + # TOCTOU protection: lock user row before reading discount and charging balance + db_user = await lock_user_for_pricing(db, db_user.id) + subscription = db_user.subscription + total_price = 0 new_countries_names = [] removed_countries_names = [] period_hint_days = _get_period_hint_from_subscription(subscription) - servers_discount_percent = _get_addon_discount_percent_for_user( + servers_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'servers', period_hint_days, diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index d5597c7b..8e6a2cdb 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import lock_user_for_pricing, subtract_user_balance from app.database.models import Subscription, TransactionType, User from app.keyboards.inline import ( get_app_selection_keyboard, @@ -21,6 +21,7 @@ from app.keyboards.inline import ( get_specific_app_keyboard, ) from app.localization.texts import get_texts +from app.services.pricing_engine import PricingEngine from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service @@ -33,7 +34,6 @@ from app.utils.subscription_utils import ( ) from .common import ( - _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_apps_for_platform_async, get_device_name, @@ -174,7 +174,7 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db current_devices = subscription.device_limit period_hint_days = _get_period_hint_from_subscription(subscription) - devices_discount_percent = _get_addon_discount_percent_for_user( + devices_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'devices', period_hint_days, @@ -325,7 +325,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d days_left = max(1, (subscription.end_date - now).days) period_hint_days = days_left - devices_discount_percent = _get_addon_discount_percent_for_user( + devices_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'devices', period_hint_days, @@ -345,7 +345,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d days_left = max(1, (subscription.end_date - now).days) period_hint_days = days_left - devices_discount_percent = _get_addon_discount_percent_for_user( + devices_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'devices', period_hint_days, @@ -492,7 +492,8 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d async def execute_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) - price = int(callback_parts[4]) + + db_user = await lock_user_for_pricing(db, db_user.id) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -514,12 +515,15 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d show_alert=True, ) return + price_per_device = tariff_device_price elif not settings.is_devices_selection_enabled(): await callback.answer( texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return + else: + price_per_device = settings.PRICE_PER_DEVICE # Проверяем минимальное количество устройств на тарифе tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1 @@ -533,6 +537,33 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d ) return + # Recompute price under lock (callback-baked value may be stale) + devices_difference = new_devices_count - current_devices + if devices_difference > 0: + if tariff: + chargeable_devices = devices_difference + elif current_devices < settings.DEFAULT_DEVICE_LIMIT: + free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices + chargeable_devices = max(0, devices_difference - free_devices) + else: + chargeable_devices = devices_difference + + devices_price_per_month = chargeable_devices * price_per_device + days_left = max(1, (subscription.end_date - datetime.now(UTC)).days) + devices_discount_percent = PricingEngine.get_addon_discount_percent( + db_user, + 'devices', + days_left, + ) + discounted_per_month, _ = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price = int(discounted_per_month * days_left / 30) + price = max(100, price) + else: + price = 0 + try: if price > 0: success = await subtract_user_balance( @@ -1148,6 +1179,9 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: devices_price_per_month = devices_count * price_per_device + # TOCTOU: lock user row before reading promo/discount state + db_user = await lock_user_for_pricing(db, db_user.id) + # Проверяем является ли тариф суточным is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) @@ -1157,7 +1191,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: days_left = max(1, (subscription.end_date - now).days) period_hint_days = days_left - devices_discount_percent = _get_addon_discount_percent_for_user( + devices_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'devices', period_hint_days, @@ -1177,7 +1211,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: days_left = max(1, (subscription.end_date - now).days) period_hint_days = days_left - devices_discount_percent = _get_addon_discount_percent_for_user( + devices_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'devices', period_hint_days, diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index 295ff1e2..f986a0d0 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -4,18 +4,15 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession -from app.config import PERIOD_PRICES, settings +from app.config import settings from app.database.models import User from app.utils.pricing_utils import ( - apply_percentage_discount, - calculate_months_from_days, format_period_description, - validate_pricing_calculation, ) from app.utils.timezone import format_local_datetime -from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger -from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback +from .common import logger +from .countries import _get_available_countries, _get_countries_info from .devices import get_current_devices_count from .promo import _build_promo_group_discount_text, _get_promo_offer_hint @@ -25,82 +22,18 @@ async def _prepare_subscription_summary( data: dict[str, Any], texts, ) -> tuple[str, dict[str, Any]]: + from app.database.database import AsyncSessionLocal + from app.services.pricing_engine import PricingEngine, pricing_engine + summary_data = dict(data) if 'period_days' not in summary_data: raise KeyError('period_days missing from subscription data — FSM state likely expired') - countries = await _get_available_countries(db_user.promo_group_id) - - months_in_period = calculate_months_from_days(summary_data['period_days']) - period_display = format_period_description(summary_data['period_days'], db_user.language) - - base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0) - period_discount_percent = db_user.get_promo_discount( - 'period', - summary_data['period_days'], - ) - base_price, base_discount_total = apply_percentage_discount( - base_price_original, - period_discount_percent, - ) - - if settings.is_traffic_fixed(): - traffic_limit = settings.get_fixed_traffic_limit() - traffic_price_per_month = settings.get_traffic_price(traffic_limit) - final_traffic_gb = traffic_limit - else: - traffic_gb = summary_data.get('traffic_gb', 0) - traffic_price_per_month = settings.get_traffic_price(traffic_gb) - final_traffic_gb = traffic_gb - - traffic_discount_percent = db_user.get_promo_discount( - 'traffic', - summary_data['period_days'], - ) - traffic_component = _apply_discount_to_monthly_component( - traffic_price_per_month, - traffic_discount_percent, - months_in_period, - ) - total_traffic_price = traffic_component['total'] - - countries_price_per_month = 0 - selected_countries_names: list[str] = [] - selected_server_prices: list[int] = [] - server_monthly_prices: list[int] = [] - - selected_country_ids = set(summary_data.get('countries', [])) - for country in countries: - if country['uuid'] in selected_country_ids: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - selected_countries_names.append(html.escape(country['name'])) - server_monthly_prices.append(server_price_per_month) - - servers_discount_percent = db_user.get_promo_discount( - 'servers', - summary_data['period_days'], - ) - total_countries_price = 0 - total_servers_discount = 0 - discounted_servers_price_per_month = 0 - - for server_price_per_month in server_monthly_prices: - discounted_per_month, discount_per_month = apply_percentage_discount( - server_price_per_month, - servers_discount_percent, - ) - total_price_for_server = discounted_per_month * months_in_period - total_discount_for_server = discount_per_month * months_in_period - - discounted_servers_price_per_month += discounted_per_month - total_countries_price += total_price_for_server - total_servers_discount += total_discount_for_server - selected_server_prices.append(total_price_for_server) + period_days = summary_data['period_days'] + # --- Resolve device limit (same logic as before) --- devices_selection_enabled = settings.is_devices_selection_enabled() - forced_disabled_limit: int | None = None if devices_selection_enabled: devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) else: @@ -109,54 +42,75 @@ async def _prepare_subscription_summary( devices_selected = settings.DEFAULT_DEVICE_LIMIT else: devices_selected = forced_disabled_limit - summary_data['devices'] = devices_selected - additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = db_user.get_promo_discount( - 'devices', - summary_data['period_days'], - ) - devices_component = _apply_discount_to_monthly_component( - devices_price_per_month, - devices_discount_percent, - months_in_period, - ) - total_devices_price = devices_component['total'] - total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + # --- Resolve traffic --- + if settings.is_traffic_fixed(): + final_traffic_gb = settings.get_fixed_traffic_limit() + else: + final_traffic_gb = summary_data.get('traffic_gb', 0) + # --- Resolve connected squads --- + connected_squads = list(summary_data.get('countries', [])) + + # --- Delegate pricing to PricingEngine --- + async with AsyncSessionLocal() as db: + pricing = await pricing_engine.calculate_classic_new_subscription_price( + db, + period_days, + connected_squads, + final_traffic_gb, + devices_selected, + user=db_user, + ) + + # --- Build legacy dict from PricingEngine result --- + details = PricingEngine.classic_pricing_to_purchase_details(pricing) + bd = pricing.breakdown + + months_in_period = details['months_in_period'] + base_price = details['base_price'] + base_price_original = details['base_price_original'] + base_discount_total = details['base_discount_total'] + period_discount_percent = details['base_discount_percent'] + traffic_price_per_month = details['traffic_price_per_month'] + traffic_discount_percent = details['traffic_discount_percent'] + traffic_discount_total = details['traffic_discount_total'] + total_traffic_price = details['total_traffic_price'] + servers_price_per_month = details['servers_price_per_month'] + servers_discount_percent = details['servers_discount_percent'] + servers_discount_total = details['servers_discount_total'] + total_servers_price = details['total_servers_price'] + devices_price_per_month = details['devices_price_per_month'] + devices_discount_percent = details['devices_discount_percent'] + devices_discount_total = details['devices_discount_total'] + total_devices_price = details['total_devices_price'] + + # Compute discounted per-month values (not in classic_pricing_to_purchase_details) + traffic_discounted_per_month = PricingEngine.apply_discount(traffic_price_per_month, traffic_discount_percent) + servers_discounted_per_month = PricingEngine.apply_discount(servers_price_per_month, servers_discount_percent) + devices_discounted_per_month = PricingEngine.apply_discount(devices_price_per_month, devices_discount_percent) discounted_monthly_additions = ( - traffic_component['discounted_per_month'] - + discounted_servers_price_per_month - + devices_component['discounted_per_month'] + traffic_discounted_per_month + servers_discounted_per_month + devices_discounted_per_month ) - is_valid = validate_pricing_calculation( - base_price, - discounted_monthly_additions, - months_in_period, - total_price, - ) - - if not is_valid: - raise ValueError('Subscription price calculation validation failed') - - original_total_price = total_price - promo_offer_component = _apply_promo_offer_discount(db_user, total_price) - if promo_offer_component['discount'] > 0: - total_price = promo_offer_component['discounted'] + # --- Promo offer discount (already computed by PricingEngine) --- + promo_offer_discount = pricing.promo_offer_discount + offer_pct = bd.get('offer_discount_pct', 0) + # subtotal before promo offer = final_total + promo_offer_discount + subtotal_before_offer = pricing.final_total + promo_offer_discount + total_price = pricing.final_total summary_data['total_price'] = total_price - if promo_offer_component['discount'] > 0: - summary_data['promo_offer_discount_percent'] = promo_offer_component['percent'] - summary_data['promo_offer_discount_value'] = promo_offer_component['discount'] - summary_data['total_price_before_promo_offer'] = original_total_price + if promo_offer_discount > 0: + summary_data['promo_offer_discount_percent'] = offer_pct + summary_data['promo_offer_discount_value'] = promo_offer_discount + summary_data['total_price_before_promo_offer'] = subtotal_before_offer else: summary_data.pop('promo_offer_discount_percent', None) summary_data.pop('promo_offer_discount_value', None) summary_data.pop('total_price_before_promo_offer', None) - summary_data['server_prices_for_period'] = selected_server_prices + summary_data['server_prices_for_period'] = details['servers_individual_prices'] summary_data['months_in_period'] = months_in_period summary_data['base_price'] = base_price summary_data['base_price_original'] = base_price_original @@ -164,24 +118,27 @@ async def _prepare_subscription_summary( summary_data['base_discount_total'] = base_discount_total summary_data['final_traffic_gb'] = final_traffic_gb summary_data['traffic_price_per_month'] = traffic_price_per_month - summary_data['traffic_discount_percent'] = traffic_component['discount_percent'] - summary_data['traffic_discount_total'] = traffic_component['discount_total'] - summary_data['traffic_discounted_price_per_month'] = traffic_component['discounted_per_month'] + summary_data['traffic_discount_percent'] = traffic_discount_percent + summary_data['traffic_discount_total'] = traffic_discount_total + summary_data['traffic_discounted_price_per_month'] = traffic_discounted_per_month summary_data['total_traffic_price'] = total_traffic_price - summary_data['servers_price_per_month'] = countries_price_per_month - summary_data['countries_price_per_month'] = countries_price_per_month + summary_data['servers_price_per_month'] = servers_price_per_month + summary_data['countries_price_per_month'] = servers_price_per_month summary_data['servers_discount_percent'] = servers_discount_percent - summary_data['servers_discount_total'] = total_servers_discount - summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month - summary_data['total_servers_price'] = total_countries_price - summary_data['total_countries_price'] = total_countries_price + summary_data['servers_discount_total'] = servers_discount_total + summary_data['servers_discounted_price_per_month'] = servers_discounted_per_month + summary_data['total_servers_price'] = total_servers_price + summary_data['total_countries_price'] = total_servers_price summary_data['devices_price_per_month'] = devices_price_per_month - summary_data['devices_discount_percent'] = devices_component['discount_percent'] - summary_data['devices_discount_total'] = devices_component['discount_total'] - summary_data['devices_discounted_price_per_month'] = devices_component['discounted_per_month'] + summary_data['devices_discount_percent'] = devices_discount_percent + summary_data['devices_discount_total'] = devices_discount_total + summary_data['devices_discounted_price_per_month'] = devices_discounted_per_month summary_data['total_devices_price'] = total_devices_price summary_data['discounted_monthly_additions'] = discounted_monthly_additions + # --- Build display text --- + period_display = format_period_description(period_days, db_user.language) + if settings.is_traffic_fixed(): if final_traffic_gb == 0: traffic_display = 'Безлимитный' @@ -192,6 +149,13 @@ async def _prepare_subscription_summary( else: traffic_display = f'{summary_data.get("traffic_gb", 0)} ГБ' + # Resolve country display names (still needed for the summary text) + countries = await _get_available_countries(db_user.promo_group_id) + selected_country_ids = set(connected_squads) + selected_countries_names: list[str] = [ + html.escape(country['name']) for country in countries if country['uuid'] in selected_country_ids + ] + details_lines = [] # Добавляем строку базового периода только если цена не равна 0 @@ -212,40 +176,34 @@ async def _prepare_subscription_summary( f'- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}' f' = {texts.format_price(total_traffic_price)}' ) - if traffic_component['discount_total'] > 0: - traffic_line += ( - f' (скидка {traffic_component["discount_percent"]}%:' - f' -{texts.format_price(traffic_component["discount_total"])})' - ) + if traffic_discount_total > 0: + traffic_line += f' (скидка {traffic_discount_percent}%: -{texts.format_price(traffic_discount_total)})' details_lines.append(traffic_line) - if total_countries_price > 0: + if total_servers_price > 0: servers_line = ( - f'- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}' - f' = {texts.format_price(total_countries_price)}' + f'- Серверы: {texts.format_price(servers_price_per_month)}/мес × {months_in_period}' + f' = {texts.format_price(total_servers_price)}' ) - if total_servers_discount > 0: - servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(total_servers_discount)})' + if servers_discount_total > 0: + servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(servers_discount_total)})' details_lines.append(servers_line) if devices_selection_enabled and total_devices_price > 0: devices_line = ( f'- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}' f' = {texts.format_price(total_devices_price)}' ) - if devices_component['discount_total'] > 0: - devices_line += ( - f' (скидка {devices_component["discount_percent"]}%:' - f' -{texts.format_price(devices_component["discount_total"])})' - ) + if devices_discount_total > 0: + devices_line += f' (скидка {devices_discount_percent}%: -{texts.format_price(devices_discount_total)})' details_lines.append(devices_line) - if promo_offer_component['discount'] > 0: + if promo_offer_discount > 0: details_lines.append( texts.t( 'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT', '- Промо-предложение: -{amount} ({percent}% дополнительно)', ).format( - amount=texts.format_price(promo_offer_component['discount']), - percent=promo_offer_component['percent'], + amount=texts.format_price(promo_offer_discount), + percent=offer_pct, ) ) @@ -309,114 +267,21 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: if subscription.is_trial: return 0 - from app.config import settings - from app.database.crud.tariff import get_tariff_by_id - from app.services.subscription_service import SubscriptionService - - subscription_service = SubscriptionService() + from app.services.pricing_engine import pricing_engine try: owner = subscription.user except AttributeError: owner = None - promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None - - # В тарифном режиме цена тарифа уже включает серверы и трафик - tariff = None - tariff_price_found = False - if settings.is_tariffs_mode() and subscription.tariff_id: - tariff = await get_tariff_by_id(db, subscription.tariff_id) - if tariff and tariff.period_prices: - base_cost_original = tariff.period_prices.get('30', 0) or tariff.period_prices.get(30, 0) - if base_cost_original > 0: - tariff_price_found = True - - if not tariff_price_found: - base_cost_original = PERIOD_PRICES.get(30, 0) - - if tariff_price_found: - # Тарифный режим: серверы и трафик включены в цену. - # Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price). - from app.utils.promo_offer import get_user_active_promo_discount_percent - - original_price = base_cost_original - - tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0 - device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit - extra_devices = max(0, device_limit - tariff_device_limit) - device_price_per_unit = ( - tariff.device_price_kopeks - if tariff and tariff.device_price_kopeks is not None - else settings.PRICE_PER_DEVICE - ) - devices_price = extra_devices * device_price_per_unit - original_price += devices_price - - # Скидка промогруппы на полную сумму (база + устройства) - period_discount_percent = 0 - if owner: - try: - period_discount_percent = owner.get_promo_discount('period', 30) - except AttributeError: - pass - discount_total = original_price * period_discount_percent // 100 - total_cost = original_price - discount_total - - # Promo-offer скидка (временная) - promo_offer_percent = get_user_active_promo_discount_percent(owner) - if promo_offer_percent > 0: - promo_offer_discount = total_cost * promo_offer_percent // 100 - total_cost = total_cost - promo_offer_discount - else: - # Классический режим: серверы + трафик + устройства считаются отдельно - period_discount_percent = 0 - if owner: - try: - period_discount_percent = owner.get_promo_discount('period', 30) - except AttributeError: - period_discount_percent = 0 - - base_cost, _ = apply_percentage_discount( - base_cost_original, - period_discount_percent, - ) - - try: - servers_cost, _ = await subscription_service.get_countries_price_by_uuids( - subscription.connected_squads, - db, - promo_group_id=promo_group_id, - ) - except AttributeError: - servers_cost, _ = await get_countries_price_by_uuids_fallback( - subscription.connected_squads, - db, - promo_group_id=promo_group_id, - ) - - traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb) - device_limit = subscription.device_limit - if device_limit is None: - if settings.is_devices_selection_enabled(): - device_limit = settings.DEFAULT_DEVICE_LIMIT - else: - forced_limit = settings.get_disabled_mode_device_limit() - if forced_limit is None: - device_limit = settings.DEFAULT_DEVICE_LIMIT - else: - device_limit = forced_limit - - devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - - total_cost = base_cost + servers_cost + traffic_cost + devices_cost - - logger.info('Месячная стоимость подписки', subscription_id=subscription.id, total_cost_kopeks=total_cost) + result = await pricing_engine.calculate_renewal_price(db, subscription, 30, user=owner) + total_cost = result.final_total + logger.info('Monthly subscription cost', subscription_id=subscription.id, total_cost_kopeks=total_cost) return total_cost except Exception as e: - logger.error('Ошибка расчета стоимости подписки', error=e) + logger.error('Error calculating subscription cost', error=e) return 0 diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 41ddcc05..1a985f24 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -9,7 +9,7 @@ from aiogram.fsm.context import FSMContext from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import PERIOD_PRICES, settings +from app.config import settings from app.database.crud.subscription import ( create_paid_subscription, create_pending_trial_subscription, @@ -37,6 +37,7 @@ from app.keyboards.inline import ( ) from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService +from app.services.pricing_engine import pricing_engine from app.services.remnawave_service import RemnaWaveConfigurationError from app.services.subscription_checkout_service import ( clear_subscription_checkout_draft, @@ -99,7 +100,6 @@ from app.handlers.simple_subscription import ( from app.states import SubscriptionStates from app.utils.price_display import PriceInfo, format_price_text from app.utils.pricing_utils import ( - apply_percentage_discount, calculate_months_from_days, format_period_description, ) @@ -343,8 +343,23 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d ] if is_daily: - # Для суточного тарифа показываем цену и прогресс-бар - daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100 + # Для суточного тарифа показываем цену с учётом скидки промогруппы + promo-offer + raw_daily_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + promo_group = ( + db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None + ) + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + from app.services.pricing_engine import PricingEngine + from app.utils.promo_offer import get_user_active_promo_discount_percent + + daily_offer_pct = get_user_active_promo_discount_percent(db_user) + if daily_group_pct > 0 or daily_offer_pct > 0: + daily_kopeks, _, _ = PricingEngine.apply_stacked_discounts( + raw_daily_kopeks, daily_group_pct, daily_offer_pct + ) + else: + daily_kopeks = raw_daily_kopeks + daily_price = daily_kopeks / 100 tariff_info_lines.append(f'Цена: {daily_price:.2f} ₽/день') # Прогресс-бар до следующего списания @@ -1735,9 +1750,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us await callback.answer('⚠ У вас нет активной подписки', show_alert=True) return + from app.database.crud.user import lock_user_for_pricing from app.services.pricing_engine import pricing_engine from app.services.subscription_renewal_service import SubscriptionRenewalChargeError, SubscriptionRenewalService + db_user = await lock_user_for_pricing(db, db_user.id) months_in_period = calculate_months_from_days(days) try: @@ -1884,7 +1901,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us await callback.answer() -async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User): +async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): period_days = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) @@ -1894,18 +1911,23 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use await callback.answer(texts.t('PERIOD_NOT_AVAILABLE', '❌ Этот период больше недоступен'), show_alert=True) return - # Получаем цену с защитой от KeyError - period_price = PERIOD_PRICES.get(period_days, 0) - data = await state.get_data() data['period_days'] = period_days - data['total_price'] = period_price if settings.is_traffic_fixed(): - fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - data['total_price'] += fixed_traffic_price data['traffic_gb'] = settings.get_fixed_traffic_limit() + # Вычисляем промежуточную цену через PricingEngine (countries/devices ещё не выбраны) + pricing_result = await pricing_engine.calculate_classic_new_subscription_price( + db, + period_days, + list(data.get('countries', [])), + data.get('traffic_gb', 0) or 0, + data.get('devices', settings.DEFAULT_DEVICE_LIMIT), + user=db_user, + ) + data['total_price'] = pricing_result.final_total + await state.set_data(data) if settings.is_traffic_selectable(): @@ -1958,7 +1980,7 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use await callback.answer() -async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User): +async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) if not settings.is_devices_selection_enabled(): @@ -1980,27 +2002,27 @@ async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_us data = await state.get_data() - # Получаем цену периода с защитой от KeyError period_days = data.get('period_days') - if not period_days or period_days not in PERIOD_PRICES: + if not period_days: await callback.answer( texts.t('PERIOD_NOT_AVAILABLE', '❌ Период больше недоступен, начните заново'), show_alert=True ) return - base_price = PERIOD_PRICES.get(period_days, 0) + settings.get_traffic_price(data.get('traffic_gb', 0)) - - countries = await _get_available_countries(db_user.promo_group_id) - # Проверяем, что ключ 'countries' существует в данных перед доступом к нему - selected_countries = data.get('countries', []) - countries_price = sum(c['price_kopeks'] for c in countries if c['uuid'] in selected_countries) - - devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) data['devices'] = devices - data['total_price'] = base_price + countries_price + devices_price + + # Вычисляем цену через PricingEngine с актуальными FSM-данными + pricing_result = await pricing_engine.calculate_classic_new_subscription_price( + db, + period_days, + list(data.get('countries', [])), + data.get('traffic_gb', 0) or 0, + devices, + user=db_user, + ) + data['total_price'] = pricing_result.final_total await state.set_data(data) if devices != previous_devices: @@ -2049,8 +2071,6 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ await save_subscription_checkout_draft(db_user.id, dict(data)) resume_callback = 'subscription_resume_checkout' if should_offer_checkout_resume(db_user, True) else None - countries = await _get_available_countries(db_user.promo_group_id) - period_days = data.get('period_days') if period_days is None: await callback.message.edit_text( @@ -2059,62 +2079,8 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ ) await callback.answer() return - months_in_period = data.get('months_in_period', calculate_months_from_days(period_days)) - - # Всегда пересчитываем base_price из PERIOD_PRICES для безопасности - # (не доверяем кэшированным значениям из FSM данных) - base_price_original = PERIOD_PRICES.get(period_days, 0) - base_discount_percent = db_user.get_promo_discount( - 'period', - period_days, - ) - base_price, base_discount_total = apply_percentage_discount( - base_price_original, - base_discount_percent, - ) - server_prices = data.get('server_prices_for_period', []) - - if not server_prices: - countries_price_per_month = 0 - per_month_prices: list[int] = [] - for country in countries: - # Проверяем, что ключ 'countries' существует в данных перед доступом к нему - selected_countries = data.get('countries', []) - if country['uuid'] in selected_countries: - server_price_per_month = country['price_kopeks'] - countries_price_per_month += server_price_per_month - per_month_prices.append(server_price_per_month) - - servers_discount_percent = db_user.get_promo_discount( - 'servers', - period_days, - ) - total_servers_price = 0 - total_servers_discount = 0 - discounted_servers_price_per_month = 0 - server_prices = [] - - for server_price_per_month in per_month_prices: - discounted_per_month, discount_per_month = apply_percentage_discount( - server_price_per_month, - servers_discount_percent, - ) - total_price_for_server = discounted_per_month * months_in_period - total_discount_for_server = discount_per_month * months_in_period - - discounted_servers_price_per_month += discounted_per_month - total_servers_price += total_price_for_server - total_servers_discount += total_discount_for_server - server_prices.append(total_price_for_server) - - total_countries_price = total_servers_price - else: - total_countries_price = data.get('total_servers_price', sum(server_prices)) - countries_price_per_month = data.get('servers_price_per_month', 0) - discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month) - total_servers_discount = data.get('servers_discount_total', 0) - servers_discount_percent = data.get('servers_discount_percent', 0) + # --- Resolve device limit (needed for PricingEngine and subscription creation) --- devices_selection_enabled = settings.is_devices_selection_enabled() forced_disabled_limit: int | None = None if devices_selection_enabled: @@ -2126,95 +2092,42 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ else: devices_selected = forced_disabled_limit - additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = data.get('devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE) - - devices_discount_percent = 0 - discounted_devices_price_per_month = 0 - devices_discount_total = 0 - total_devices_price = 0 - - if devices_selection_enabled and additional_devices > 0: - if 'devices_discount_percent' in data: - devices_discount_percent = data.get('devices_discount_percent', 0) - discounted_devices_price_per_month = data.get('devices_discounted_price_per_month', devices_price_per_month) - devices_discount_total = data.get('devices_discount_total', 0) - total_devices_price = data.get('total_devices_price', discounted_devices_price_per_month * months_in_period) - else: - devices_discount_percent = db_user.get_promo_discount( - 'devices', - period_days, - ) - discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - devices_discount_total = discount_per_month * months_in_period - total_devices_price = discounted_devices_price_per_month * months_in_period - + # --- Resolve traffic --- if settings.is_traffic_fixed(): final_traffic_gb = settings.get_fixed_traffic_limit() - traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(final_traffic_gb)) else: - final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb')) - traffic_gb = data.get('traffic_gb') - if traffic_gb is not None: - traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(traffic_gb)) - else: - traffic_price_per_month = data.get('traffic_price_per_month', 0) + final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb', 0)) - if 'traffic_discount_percent' in data: - traffic_discount_percent = data.get('traffic_discount_percent', 0) - discounted_traffic_price_per_month = data.get('traffic_discounted_price_per_month', traffic_price_per_month) - traffic_discount_total = data.get('traffic_discount_total', 0) - total_traffic_price = data.get('total_traffic_price', discounted_traffic_price_per_month * months_in_period) - else: - traffic_discount_percent = db_user.get_promo_discount( - 'traffic', - period_days, - ) - discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount( - traffic_price_per_month, - traffic_discount_percent, - ) - traffic_discount_total = discount_per_month * months_in_period - total_traffic_price = discounted_traffic_price_per_month * months_in_period - - total_servers_price = data.get('total_servers_price', total_countries_price) + # --- Resolve connected squads --- + connected_squads = list(data.get('countries', [])) cached_total_price = data.get('total_price', 0) - cached_promo_discount_value = data.get('promo_offer_discount_value', 0) - # Всегда пересчитываем monthly_additions из компонентов для безопасности - discounted_monthly_additions = ( - discounted_traffic_price_per_month + discounted_servers_price_per_month + discounted_devices_price_per_month + # Lock user BEFORE promo-offer read to prevent TOCTOU + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + + # --- Delegate pricing to PricingEngine --- + from app.services.pricing_engine import PricingEngine, pricing_engine + + pricing_result = await pricing_engine.calculate_classic_new_subscription_price( + db, + period_days, + connected_squads, + final_traffic_gb, + devices_selected, + user=db_user, ) + details = PricingEngine.classic_pricing_to_purchase_details(pricing_result) - # Вычисляем ожидаемую цену до промо-скидки из компонентов - calculated_total_before_promo = base_price + (discounted_monthly_additions * months_in_period) + final_price = pricing_result.final_total + server_prices = details['servers_individual_prices'] + months_in_period = details['months_in_period'] + promo_offer_discount_value = pricing_result.promo_offer_discount + promo_offer_discount_percent = pricing_result.breakdown.get('offer_discount_pct', 0) - # Получаем сохраненную цену до промо-скидки или используем вычисленную - validation_total_price = data.get('total_price_before_promo_offer') - if validation_total_price is None and cached_promo_discount_value > 0: - validation_total_price = cached_total_price + cached_promo_discount_value - if validation_total_price is None: - validation_total_price = cached_total_price - - current_promo_offer_percent = _get_promo_offer_discount_percent(db_user) - if current_promo_offer_percent > 0: - final_price, promo_offer_discount_value = apply_percentage_discount( - calculated_total_before_promo, - current_promo_offer_percent, - ) - promo_offer_discount_percent = current_promo_offer_percent - else: - final_price = calculated_total_before_promo - promo_offer_discount_value = 0 - promo_offer_discount_percent = 0 - - # Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене - # Блокируем только если цена ВЫРОСЛА (пользователь переплатит). - # Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене. + # --- Price validation: block if price increased significantly vs cached FSM price --- price_difference = final_price - cached_total_price if price_difference > 0: max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽ @@ -2244,36 +2157,50 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_ final_price=final_price / 100, ) - # Используем пересчитанную цену - validation_total_price = calculated_total_before_promo + # --- Logging --- + base_price_original = details['base_price_original'] + base_price = details['base_price'] + base_discount_total = details['base_discount_total'] + base_discount_percent = details['base_discount_percent'] logger.info('Расчет покупки подписки на дней ( мес)', data=data['period_days'], months_in_period=months_in_period) base_log = f' Период: {base_price_original / 100}₽' if base_discount_total and base_discount_total > 0: base_log += f' → {base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)' logger.info(base_log) - if total_traffic_price > 0: - message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽' - if traffic_discount_total > 0: - message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_total / 100}₽)' - logger.info(message) - if total_servers_price > 0: - message = ( - f' Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽' + if details['total_traffic_price'] > 0: + traffic_msg = ( + f' Трафик: {details["traffic_price_per_month"] / 100}₽/мес' + f' × {months_in_period} = {details["total_traffic_price"] / 100}₽' ) - if total_servers_discount > 0: - message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' - logger.info(message) - if total_devices_price > 0: - message = ( - f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽' + if details['traffic_discount_total'] > 0: + traffic_msg += ( + f' (скидка {details["traffic_discount_percent"]}%: -{details["traffic_discount_total"] / 100}₽)' + ) + logger.info(traffic_msg) + if details['total_servers_price'] > 0: + servers_msg = ( + f' Серверы: {details["servers_price_per_month"] / 100}₽/мес' + f' × {months_in_period} = {details["total_servers_price"] / 100}₽' ) - if devices_discount_total > 0: - message += f' (скидка {devices_discount_percent}%: -{devices_discount_total / 100}₽)' - logger.info(message) + if details['servers_discount_total'] > 0: + servers_msg += ( + f' (скидка {details["servers_discount_percent"]}%: -{details["servers_discount_total"] / 100}₽)' + ) + logger.info(servers_msg) + if details['total_devices_price'] > 0: + devices_msg = ( + f' Устройства: {details["devices_price_per_month"] / 100}₽/мес' + f' × {months_in_period} = {details["total_devices_price"] / 100}₽' + ) + if details['devices_discount_total'] > 0: + devices_msg += ( + f' (скидка {details["devices_discount_percent"]}%: -{details["devices_discount_total"] / 100}₽)' + ) + logger.info(devices_msg) if promo_offer_discount_value > 0: logger.info( - '🎯 Промо-предложение: -₽ (%)', + 'Промо-предложение: -₽ (%)', promo_offer_discount_value=promo_offer_discount_value / 100, promo_offer_discount_percent=promo_offer_discount_percent, ) @@ -2953,7 +2880,16 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, # При возобновлении проверяем баланс if needs_resume: - daily_price = getattr(tariff, 'daily_price_kopeks', 0) + raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) + from app.database.crud.user import lock_user_for_pricing + from app.services.pricing_engine import PricingEngine + + db_user = await lock_user_for_pricing(db, db_user.id) + promo_group = PricingEngine.resolve_promo_group(db_user) + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_price = ( + PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price + ) if daily_price > 0 and db_user.balance_kopeks < daily_price: await callback.answer( texts.t( @@ -2966,7 +2902,6 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, if needs_resume: # Списываем суточную оплату ДО активации (чтобы не было бесплатного дня) - daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price > 0 and is_inactive: from app.database.crud.user import subtract_user_balance @@ -4147,13 +4082,14 @@ async def _extend_existing_subscription( ): """Продлевает существующую подписку.""" from app.database.crud.transaction import create_transaction - from app.database.crud.user import subtract_user_balance + from app.database.crud.user import lock_user_for_pricing, subtract_user_balance from app.database.models import TransactionType from app.services.subscription_service import SubscriptionService + db_user = await lock_user_for_pricing(db, db_user.id) texts = get_texts(db_user.language) - # Рассчитываем цену подписки + # Рассчитываем цену подписки (group discounts per-category) subscription_params = { 'period_days': period_days, 'device_limit': device_limit, @@ -4166,6 +4102,12 @@ async def _extend_existing_subscription( user=db_user, resolved_squad_uuid=squad_uuid, ) + + # PricingEngine already applies promo-offer discount inside calculate_classic_new_subscription_price. + # Only determine whether to consume the offer (zero it out after use). + from app.utils.promo_offer import get_user_active_promo_discount_percent + + consume_promo = get_user_active_promo_discount_percent(db_user) > 0 logger.warning( 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount= | device_limit', db_user_id=db_user.id, @@ -4212,7 +4154,7 @@ async def _extend_existing_subscription( 'device_limit': device_limit, 'traffic_limit_gb': traffic_limit_gb, 'squad_uuid': squad_uuid, - 'consume_promo_offer': False, + 'consume_promo_offer': consume_promo, } await user_cart_service.save_user_cart(db_user.id, cart_data) @@ -4233,7 +4175,7 @@ async def _extend_existing_subscription( db_user, price_kopeks, f'Продление подписки на {period_days} дней', - consume_promo_offer=False, # Простая покупка не использует промо-скидки + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 5947af01..99ff13ba 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -79,9 +79,14 @@ def format_tariffs_list_text( discount_icon = '' if is_daily: - # Для суточных тарифов показываем цену за день + # Для суточных тарифов показываем цену за день с учётом скидки промогруппы daily_price = getattr(tariff, 'daily_price_kopeks', 0) - price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день' + if db_user: + group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1) + if daily_discount > 0: + daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct) + discount_icon = '🔥' + price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}' else: # Для периодных тарифов показываем минимальную цену prices = tariff.period_prices or {} @@ -394,21 +399,42 @@ def _calculate_custom_tariff_price( return period_price, traffic_price, total_price -def format_custom_tariff_preview( +async def format_custom_tariff_preview( tariff: Tariff, days: int, traffic_gb: int, user_balance: int, + db_user: User | None = None, discount_percent: int = 0, group_pct: int = 0, offer_pct: int = 0, ) -> str: - """Форматирует предпросмотр покупки с кастомными параметрами.""" - period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb) + """Форматирует предпросмотр покупки с кастомными параметрами. - # Применяем скидку - if discount_percent > 0: - total_price = _apply_promo_discount(total_price, group_pct, offer_pct) + Uses PricingEngine when db_user is provided for accurate per-category discounts + (period, traffic addon). Falls back to manual calculation otherwise. + """ + if db_user is not None: + # Use PricingEngine — single source of truth for all discounts + from app.services.pricing_engine import pricing_engine + + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + days, + device_limit=tariff.device_limit, + custom_traffic_gb=traffic_gb if tariff.can_purchase_custom_traffic() else None, + user=db_user, + ) + period_price = result.base_price + traffic_price = result.traffic_price + total_price = result.final_total + has_discount = result.promo_group_discount > 0 or result.promo_offer_discount > 0 + else: + # Fallback: raw prices without discounts + period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb) + has_discount = discount_percent > 0 + if has_discount: + total_price = _apply_promo_discount(total_price, group_pct, offer_pct) traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb) @@ -433,7 +459,7 @@ def format_custom_tariff_preview( text += f'📱 Устройств: {tariff.device_limit}\n' - if discount_percent > 0: + if has_discount: text += f'\n🎁 Скидка: {discount_percent}%\n' text += f""" @@ -477,7 +503,9 @@ async def show_tariffs_list( return # Проверяем есть ли у пользователя скидки по периодам - promo_group = getattr(db_user, 'promo_group', None) + promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None + if promo_group is None: + promo_group = getattr(db_user, 'promo_group', None) has_period_discounts = False if promo_group: period_discounts = getattr(promo_group, 'period_discounts', None) @@ -514,7 +542,12 @@ async def select_tariff( if is_daily: # Для суточного тарифа показываем подтверждение без выбора периода - daily_price = getattr(tariff, 'daily_price_kopeks', 0) + raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) + group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1) + daily_price = ( + _apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price + ) + discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else '' user_balance = db_user.balance_kopeks or 0 traffic = format_traffic(tariff.traffic_limit_gb) @@ -525,7 +558,8 @@ async def select_tariff( f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n\n' - f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n' + f'💰 Цена: {format_price_kopeks(daily_price)}/день' + f'{discount_text}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n\n' f'ℹ️ Средства будут списываться автоматически раз в сутки.\n' f'Вы можете приостановить подписку в любой момент.', @@ -557,7 +591,8 @@ async def select_tariff( f'❌ Недостаточно средств\n\n' f'📦 Тариф: {tariff.name}\n' f'🔄 Тип: Суточный\n' - f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n' + f'💰 Цена: {format_price_kopeks(daily_price)}/день' + f'{discount_text}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' f'⚠️ Не хватает: {format_price_kopeks(missing)}\n\n' f'🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.', @@ -588,14 +623,13 @@ async def select_tariff( period_offer_pct=offer_pct, ) - preview_text = format_custom_tariff_preview( + preview_text = await format_custom_tariff_preview( tariff=tariff, days=initial_days, traffic_gb=initial_traffic, user_balance=user_balance, + db_user=db_user, discount_percent=discount_percent, - group_pct=group_pct, - offer_pct=offer_pct, ) await callback.message.edit_text( @@ -672,14 +706,13 @@ async def handle_custom_days_change( user_balance = db_user.balance_kopeks or 0 - preview_text = format_custom_tariff_preview( + preview_text = await format_custom_tariff_preview( tariff=tariff, days=new_days, traffic_gb=current_traffic, user_balance=user_balance, + db_user=db_user, discount_percent=discount_percent, - group_pct=group_pct, - offer_pct=offer_pct, ) await callback.message.edit_text( @@ -722,8 +755,6 @@ async def handle_custom_traffic_change( current_days = state_data.get('custom_days', tariff.min_days) current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) discount_percent = state_data.get('period_discount_percent', 0) - group_pct = state_data.get('period_group_pct', 0) - offer_pct = state_data.get('period_offer_pct', 0) # Применяем изменение new_traffic = current_traffic + delta @@ -733,14 +764,13 @@ async def handle_custom_traffic_change( user_balance = db_user.balance_kopeks or 0 - preview_text = format_custom_tariff_preview( + preview_text = await format_custom_tariff_preview( tariff=tariff, days=current_days, traffic_gb=new_traffic, user_balance=user_balance, + db_user=db_user, discount_percent=discount_percent, - group_pct=group_pct, - offer_pct=offer_pct, ) await callback.message.edit_text( @@ -777,28 +807,33 @@ async def handle_custom_confirm( await callback.answer('Тариф недоступен', show_alert=True) return + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + state_data = await state.get_data() custom_days = state_data.get('custom_days', tariff.min_days) custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) - discount_percent = state_data.get('period_discount_percent', 0) - group_pct = state_data.get('period_group_pct', 0) - offer_pct = state_data.get('period_offer_pct', 0) - # Рассчитываем цену (используем общую функцию) - period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, custom_days, custom_traffic) + # Calculate price via PricingEngine (single source of truth for all discounts) + from app.services.pricing_engine import pricing_engine + + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + custom_days, + device_limit=tariff.device_limit, + custom_traffic_gb=custom_traffic if tariff.can_purchase_custom_traffic() else None, + user=db_user, + ) + total_price = result.final_total # Проверяем, что цена за период валидна - if period_price == 0 and not tariff.can_purchase_custom_days(): - # Период не найден в period_prices - ошибка + if result.base_price == 0 and not tariff.can_purchase_custom_days(): await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True) return - # Применяем скидку к цене периода (не к трафику) - if discount_percent > 0: - period_price = _apply_promo_discount(period_price, group_pct, offer_pct) - total_price = period_price + traffic_price - - # Проверяем баланс + # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 if user_balance < total_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) @@ -807,7 +842,7 @@ async def handle_custom_confirm( texts = get_texts(db_user.language) # Save promo offer state before deduction (for restore on failure) - consume_promo = get_user_active_promo_discount_percent(db_user) > 0 + consume_promo = result.promo_offer_discount > 0 saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0 saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None saved_promo_expires = getattr(db_user, 'promo_offer_discount_expires_at', None) if consume_promo else None @@ -1014,14 +1049,13 @@ async def select_tariff_period_with_traffic( period_offer_pct=offer_pct, ) - preview_text = format_custom_tariff_preview( + preview_text = await format_custom_tariff_preview( tariff=tariff, days=period, traffic_gb=initial_traffic, user_balance=user_balance, + db_user=db_user, discount_percent=discount_percent, - group_pct=group_pct, - offer_pct=offer_pct, ) await callback.message.edit_text( @@ -1152,34 +1186,28 @@ async def confirm_tariff_purchase( await callback.answer('Тариф недоступен', show_alert=True) return - # Получаем цену - prices = tariff.period_prices or {} - base_price = prices.get(str(period), 0) + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + + # Calculate price via PricingEngine (single source of truth) + from app.services.pricing_engine import pricing_engine - # Add extra device cost if user has more devices than tariff's included limit existing_sub = await get_subscription_by_user_id(db, db_user.id) - device_price_per_unit = ( - tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE - ) - extra_devices = 0 + device_limit = None if existing_sub and existing_sub.tariff_id == tariff.id: - extra_devices = max(0, (existing_sub.device_limit or 0) - (tariff.device_limit or 0)) - devices_price = extra_devices * device_price_per_unit + device_limit = existing_sub.device_limit - # Apply discounts sequentially (matching PricingEngine): group first, then offer - subtotal = base_price + devices_price - promo_group = db_user.get_primary_promo_group() - group_discount_pct = promo_group.get_discount_percent('period', period) if promo_group else 0 - if group_discount_pct > 0: - subtotal = subtotal - subtotal * group_discount_pct // 100 + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=device_limit, + user=db_user, + ) + final_price = result.final_total - offer_discount_pct = get_user_active_promo_discount_percent(db_user) - if offer_discount_pct > 0: - subtotal = subtotal - subtotal * offer_discount_pct // 100 - - final_price = max(0, subtotal) - - # Проверяем баланс + # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 if user_balance < final_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) @@ -1188,7 +1216,7 @@ async def confirm_tariff_purchase( texts = get_texts(db_user.language) # Списываем баланс - consume_promo = get_user_active_promo_discount_percent(db_user) > 0 + consume_promo = result.promo_offer_discount > 0 # Save promo offer state before deduction (for restore on failure) saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0 saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None @@ -1382,9 +1410,26 @@ async def confirm_daily_tariff_purchase( await callback.answer('Некорректная цена тарифа', show_alert=True) return - # Проверяем баланс + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + + # Apply group + promo-offer discounts via PricingEngine (single source of truth) + from app.services.pricing_engine import pricing_engine + + pricing_result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period_days=1, + device_limit=tariff.device_limit, + user=db_user, + ) + final_daily_price = pricing_result.final_total + consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0 + + # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 - if user_balance < daily_price: + if user_balance < final_daily_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -1395,8 +1440,9 @@ async def confirm_daily_tariff_purchase( success = await subtract_user_balance( db, db_user, - daily_price, + final_daily_price, f'Покупка суточного тарифа {tariff.name} (первый день)', + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) if not success: @@ -1485,7 +1531,7 @@ async def confirm_daily_tariff_purchase( await add_user_balance( db, db_user, - daily_price, + final_daily_price, 'Возврат: ошибка покупки суточного тарифа', create_transaction=True, transaction_type=TransactionType.REFUND, @@ -1494,7 +1540,7 @@ async def confirm_daily_tariff_purchase( logger.critical( 'CRITICAL: не удалось вернуть средства после ошибки покупки суточного тарифа', user_id=db_user.id, - price_kopeks=daily_price, + price_kopeks=final_daily_price, refund_error=refund_error, ) await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) @@ -1518,7 +1564,7 @@ async def confirm_daily_tariff_purchase( db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=daily_price, + amount_kopeks=final_daily_price, description=f'Покупка суточного тарифа {tariff.name} (первый день)', ) @@ -1532,7 +1578,7 @@ async def confirm_daily_tariff_purchase( None, 1, # 1 день was_trial_conversion=False, - amount_kopeks=daily_price, + amount_kopeks=final_daily_price, purchase_type='renewal' if existing_subscription else 'first_purchase', ) except Exception as e: @@ -1555,7 +1601,7 @@ async def confirm_daily_tariff_purchase( f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n' - f'💰 Списано: {format_price_kopeks(daily_price)}\n\n' + f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n' f'ℹ️ Следующее списание через 24 часа.\n' f'Перейдите в раздел «Подписка» для подключения.', reply_markup=InlineKeyboardMarkup( @@ -1591,26 +1637,39 @@ def get_tariff_extend_keyboard( subscription_device_limit: int | None = None, ) -> InlineKeyboardMarkup: """Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам.""" + from app.services.pricing_engine import PricingEngine + texts = get_texts(language) buttons = [] + promo_group = PricingEngine.resolve_promo_group(db_user) if db_user else None + prices = tariff.period_prices or {} for period_str in sorted(prices.keys(), key=int): period = int(period_str) - price = prices[period_str] + base_price = prices[period_str] - # Добавляем стоимость дополнительных устройств + # Стоимость дополнительных устройств + devices_cost = 0 if subscription_device_limit is not None: - price += _calc_extra_devices_cost(tariff, subscription_device_limit, period) + devices_cost = _calc_extra_devices_cost(tariff, subscription_device_limit, period) - # Получаем скидку для конкретного периода - group_pct, offer_pct, discount_percent = 0, 0, 0 - if db_user: - group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) + # Per-category group discounts (period + devices separately, like PricingEngine) + period_pct = promo_group.get_discount_percent('period', period) if promo_group else 0 + devices_pct = promo_group.get_discount_percent('devices', period) if promo_group else 0 + offer_pct = get_user_active_promo_discount_percent(db_user) if db_user else 0 - if discount_percent > 0: - price = _apply_promo_discount(price, group_pct, offer_pct) - price_text = f'{format_price_kopeks(price)} 🔥−{discount_percent}%' + discounted_base = PricingEngine.apply_discount(base_price, period_pct) + discounted_devices = PricingEngine.apply_discount(devices_cost, devices_pct) + subtotal = discounted_base + discounted_devices + price = PricingEngine.apply_discount(subtotal, offer_pct) + + # Combined display discount + total_original = base_price + devices_cost + has_discount = price < total_original and total_original > 0 + if has_discount: + combined_pct = round((1 - price / total_original) * 100) + price_text = f'{format_price_kopeks(price)} 🔥−{combined_pct}%' else: price_text = format_price_kopeks(price) @@ -1662,7 +1721,9 @@ async def show_tariff_extend( traffic = format_traffic(tariff.traffic_limit_gb) # Проверяем есть ли у пользователя скидки по периодам - promo_group = getattr(db_user, 'promo_group', None) + promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None + if promo_group is None: + promo_group = getattr(db_user, 'promo_group', None) has_period_discounts = False if promo_group: period_discounts = getattr(promo_group, 'period_discounts', None) @@ -1716,14 +1777,21 @@ async def select_tariff_extend_period( subscription = await get_subscription_by_user_id(db, db_user.id) actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit - # Получаем скидку для выбранного периода - group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) + # Calculate price via PricingEngine (per-category discounts: period + devices) + from app.services.pricing_engine import pricing_engine - # Получаем цену (тариф + дополнительные устройства) - prices = tariff.period_prices or {} - base_price = prices.get(str(period), 0) - base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period) - final_price = _apply_promo_discount(base_price, group_pct, offer_pct) + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=actual_device_limit, + user=db_user, + ) + final_price = result.final_total + original_price = result.original_total + total_discount = result.promo_group_discount + result.promo_offer_discount + discount_percent = ( + round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0 + ) # Проверяем баланс user_balance = db_user.balance_kopeks or 0 @@ -1733,7 +1801,7 @@ async def select_tariff_extend_period( if user_balance >= final_price: discount_text = '' if discount_percent > 0: - discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(base_price - final_price)})' + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(total_discount)})' await callback.message.edit_text( f'✅ Подтверждение продления\n\n' @@ -1821,15 +1889,21 @@ async def confirm_tariff_extend( actual_device_limit = subscription.device_limit or tariff.device_limit - data = await state.get_data() - group_pct = data.get('extend_group_pct', 0) - offer_pct = data.get('extend_offer_pct', 0) + from app.database.crud.user import lock_user_for_pricing - # Получаем цену (тариф + дополнительные устройства) - prices = tariff.period_prices or {} - base_price = prices.get(str(period), 0) - base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period) - final_price = _apply_promo_discount(base_price, group_pct, offer_pct) + db_user = await lock_user_for_pricing(db, db_user.id) + + # Calculate price via PricingEngine (handles per-category discounts: period + devices) + from app.services.pricing_engine import pricing_engine + + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=actual_device_limit, + user=db_user, + ) + final_price = result.final_total + consume_promo = result.promo_offer_discount > 0 # Проверяем баланс user_balance = db_user.balance_kopeks or 0 @@ -1846,7 +1920,7 @@ async def confirm_tariff_extend( db_user, final_price, f'Продление тарифа {tariff.name} на {period} дней', - consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0, + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) if not success: @@ -1966,9 +2040,14 @@ def format_tariff_switch_list_text( discount_icon = '' if is_daily: - # Для суточных тарифов показываем цену за день + # Для суточных тарифов показываем цену за день с учётом скидки промогруппы daily_price = getattr(tariff, 'daily_price_kopeks', 0) - price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день' + if db_user: + group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1) + if daily_discount > 0: + daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct) + discount_icon = '🔥' + price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}' else: prices = tariff.period_prices or {} if prices: @@ -2124,7 +2203,9 @@ async def show_tariff_switch_list( current_tariff_name = current_tariff.name # Проверяем есть ли у пользователя скидки по периодам - promo_group = getattr(db_user, 'promo_group', None) + promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None + if promo_group is None: + promo_group = getattr(db_user, 'promo_group', None) has_period_discounts = False if promo_group: period_discounts = getattr(promo_group, 'period_discounts', None) @@ -2170,7 +2251,12 @@ async def select_tariff_switch( if is_daily: # Для суточного тарифа показываем подтверждение без выбора периода - daily_price = getattr(tariff, 'daily_price_kopeks', 0) + raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) + group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1) + daily_price = ( + _apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price + ) + discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else '' user_balance = db_user.balance_kopeks or 0 # Проверяем текущую подписку на оставшиеся дни @@ -2189,7 +2275,8 @@ async def select_tariff_switch( f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n\n' - f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n' + f'💰 Цена: {format_price_kopeks(daily_price)}/день' + f'{discount_text}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}' f'{days_warning}\n\n' f'ℹ️ Средства будут списываться автоматически раз в сутки.\n' @@ -2212,7 +2299,8 @@ async def select_tariff_switch( f'❌ Недостаточно средств\n\n' f'📦 Тариф: {tariff.name}\n' f'🔄 Тип: Суточный\n' - f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n' + f'💰 Цена: {format_price_kopeks(daily_price)}/день' + f'{discount_text}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' f'⚠️ Не хватает: {format_price_kopeks(missing)}' f'{days_warning}', @@ -2269,13 +2357,21 @@ async def select_tariff_switch_period( data = await state.get_data() current_tariff_id = data.get('current_tariff_id') - # Получаем скидку для выбранного периода - group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) + # Calculate price via PricingEngine (per-category discounts: period + devices for new tariff) + from app.services.pricing_engine import pricing_engine - # Получаем цену - prices = tariff.period_prices or {} - base_price = prices.get(str(period), 0) - final_price = _apply_promo_discount(base_price, group_pct, offer_pct) + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=tariff.device_limit or 0, + user=db_user, + ) + final_price = result.final_total + original_price = result.original_total + total_discount = result.promo_group_discount + result.promo_offer_discount + discount_percent = ( + round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0 + ) # Проверяем баланс user_balance = db_user.balance_kopeks or 0 @@ -2300,7 +2396,7 @@ async def select_tariff_switch_period( if user_balance >= final_price: discount_text = '' if discount_percent > 0: - discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(base_price - final_price)})' + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(total_discount)})' await callback.message.edit_text( f'✅ Подтверждение переключения тарифа\n\n' @@ -2354,13 +2450,30 @@ async def confirm_tariff_switch( await callback.answer('Тариф недоступен', show_alert=True) return - # Получаем скидку для выбранного периода - group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) + from app.database.crud.user import lock_user_for_pricing - # Получаем цену - prices = tariff.period_prices or {} - base_price = prices.get(str(period), 0) - final_price = _apply_promo_discount(base_price, group_pct, offer_pct) + db_user = await lock_user_for_pricing(db, db_user.id) + + # Проверяем наличие подписки (need device_limit for pricing) + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer('У вас нет активной подписки', show_alert=True) + return + + # Calculate price via PricingEngine (handles per-category discounts + extra devices) + from app.services.pricing_engine import pricing_engine + + effective_device_limit = ( + subscription.device_limit if subscription.tariff_id == tariff.id else (tariff.device_limit or 0) + ) + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period, + device_limit=effective_device_limit, + user=db_user, + ) + final_price = result.final_total + consume_promo = result.promo_offer_discount > 0 # Проверяем баланс user_balance = db_user.balance_kopeks or 0 @@ -2368,12 +2481,6 @@ async def confirm_tariff_switch( await callback.answer('Недостаточно средств на балансе', show_alert=True) return - # Проверяем наличие подписки - subscription = await get_subscription_by_user_id(db, db_user.id) - if not subscription: - await callback.answer('У вас нет активной подписки', show_alert=True) - return - texts = get_texts(db_user.language) try: @@ -2383,7 +2490,7 @@ async def confirm_tariff_switch( db_user, final_price, f'Смена тарифа на {tariff.name} ({period} дней)', - consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0, + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) if not success: @@ -2536,9 +2643,26 @@ async def confirm_daily_tariff_switch( await callback.answer('Некорректная цена тарифа', show_alert=True) return - # Проверяем баланс + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + + # Apply group + promo-offer discounts via PricingEngine (single source of truth) + from app.services.pricing_engine import pricing_engine + + pricing_result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period_days=1, + device_limit=tariff.device_limit, + user=db_user, + ) + final_daily_price = pricing_result.final_total + consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0 + + # Проверяем баланс (user already locked, balance is fresh) user_balance = db_user.balance_kopeks or 0 - if user_balance < daily_price: + if user_balance < final_daily_price: await callback.answer('Недостаточно средств на балансе', show_alert=True) return @@ -2555,8 +2679,9 @@ async def confirm_daily_tariff_switch( success = await subtract_user_balance( db, db_user, - daily_price, + final_daily_price, f'Смена на суточный тариф {tariff.name} (первый день)', + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) if not success: @@ -2639,7 +2764,7 @@ async def confirm_daily_tariff_switch( db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=daily_price, + amount_kopeks=final_daily_price, description=f'Смена на суточный тариф {tariff.name} (первый день)', ) @@ -2653,7 +2778,7 @@ async def confirm_daily_tariff_switch( None, 1, # 1 день was_trial_conversion=False, - amount_kopeks=daily_price, + amount_kopeks=final_daily_price, purchase_type='tariff_switch', ) except Exception as e: @@ -2669,7 +2794,7 @@ async def confirm_daily_tariff_switch( f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n' - f'💰 Списано: {format_price_kopeks(daily_price)}\n\n' + f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n' f'ℹ️ Следующее списание через 24 часа.', reply_markup=InlineKeyboardMarkup( inline_keyboard=[ @@ -2683,65 +2808,53 @@ async def confirm_daily_tariff_switch( except Exception as e: logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True) + await db.rollback() + # Compensating refund: balance was already committed by subtract_user_balance + try: + from app.database.crud.user import add_user_balance + + await add_user_balance( + db, + db_user, + final_daily_price, + 'Возврат: ошибка смены на суточный тариф', + create_transaction=True, + transaction_type=TransactionType.REFUND, + ) + except Exception as refund_error: + logger.critical( + 'CRITICAL: не удалось вернуть средства после ошибки смены на суточный тариф', + user_id=db_user.id, + price_kopeks=final_daily_price, + refund_error=refund_error, + ) await callback.answer('Произошла ошибка при смене тарифа', show_alert=True) # ==================== Мгновенное переключение тарифов (без выбора периода) ==================== -def _get_tariff_monthly_price(tariff: Tariff) -> int: - """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" - price = tariff.get_price_for_period(30) - if price is not None: - return price - - # Fallback: пропорционально пересчитываем из первого доступного периода - periods = tariff.get_available_periods() - if periods: - first_period = periods[0] - first_price = tariff.get_price_for_period(first_period) - if first_price: - return int(first_price * 30 / first_period) - - return 0 - - def _calculate_instant_switch_cost( current_tariff: Tariff, new_tariff: Tariff, remaining_days: int, db_user: User | None = None, ) -> tuple[int, bool]: - """ - Рассчитывает стоимость мгновенного переключения тарифа. - - Если новый тариф дороже - доплата пропорционально оставшимся дням. - Если дешевле или равен - бесплатно. - - Формула: (new_monthly - current_monthly) * remaining_days / 30 - Скидка применяется к обоим тарифам одинаково. + """Рассчитывает стоимость мгновенного переключения тарифа. + Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost(). Returns: (upgrade_cost_kopeks, is_upgrade) """ - current_monthly = _get_tariff_monthly_price(current_tariff) - new_monthly = _get_tariff_monthly_price(new_tariff) + from app.services.pricing_engine import pricing_engine - group_pct, offer_pct, discount_percent = 0, 0, 0 - if db_user: - group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, 30) - - if discount_percent > 0: - current_monthly = _apply_promo_discount(current_monthly, group_pct, offer_pct) - new_monthly = _apply_promo_discount(new_monthly, group_pct, offer_pct) - - price_diff = new_monthly - current_monthly - - if price_diff <= 0: - return 0, False - - upgrade_cost = int(price_diff * remaining_days / 30) - return upgrade_cost, True + result = pricing_engine.calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days, + user=db_user, + ) + return result.upgrade_cost, result.is_upgrade def format_instant_switch_list_text( @@ -2984,7 +3097,15 @@ async def preview_instant_switch( # Для суточного тарифа особая логика показа if is_new_daily: - daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + raw_daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + # Применяем групповую скидку + promo-offer для отображения + daily_group_pct, daily_offer_pct, daily_discount = _get_user_period_discount(db_user, 1) + daily_price = ( + _apply_promo_discount(raw_daily_price, daily_group_pct, daily_offer_pct) + if daily_discount > 0 + else raw_daily_price + ) + discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else '' user_balance = db_user.balance_kopeks or 0 if user_balance >= daily_price: @@ -2997,7 +3118,8 @@ async def preview_instant_switch( f' • Трафик: {traffic}\n' f' • Устройств: {new_tariff.device_limit}\n' f' • Тип: 🔄 Суточный\n\n' - f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n' + f'💰 Цена: {format_price_kopeks(daily_price)}/день' + f'{discount_text}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}' f'{daily_warning}\n\n' f'ℹ️ Средства будут списываться автоматически раз в сутки.', @@ -3010,7 +3132,8 @@ async def preview_instant_switch( f'❌ Недостаточно средств\n\n' f'📦 Тариф: {new_tariff.name}\n' f'🔄 Тип: Суточный\n' - f'💰 Цена: {format_price_kopeks(daily_price)}/день\n\n' + f'💰 Цена: {format_price_kopeks(daily_price)}/день' + f'{discount_text}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' f'⚠️ Не хватает: {format_price_kopeks(missing)}' f'{daily_warning}', @@ -3099,19 +3222,37 @@ async def confirm_instant_switch( await callback.answer('Тариф недоступен', show_alert=True) return - # Получаем данные из состояния - data = await state.get_data() - upgrade_cost = data.get('upgrade_cost', 0) - is_upgrade = data.get('is_upgrade', False) - remaining_days = data.get('remaining_days', 0) - # Проверяем подписку subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: await callback.answer('Подписка не найдена', show_alert=True) return - # Проверяем баланс если это upgrade + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + + # Recompute upgrade_cost under lock (FSM-stored value may be stale) + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None + if not current_tariff: + await callback.answer('Текущий тариф не найден', show_alert=True) + return + remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0 + + # Use full TariffSwitchResult to access offer_discount_pct for consume_promo_offer flag + from app.services.pricing_engine import pricing_engine + + switch_result = pricing_engine.calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days, + user=db_user, + ) + upgrade_cost = switch_result.upgrade_cost + is_upgrade = switch_result.is_upgrade + consume_promo = switch_result.offer_discount_pct > 0 + + # Проверяем баланс если это upgrade (use locked user's fresh balance) user_balance = db_user.balance_kopeks or 0 if is_upgrade and user_balance < upgrade_cost: await callback.answer('Недостаточно средств на балансе', show_alert=True) @@ -3121,13 +3262,14 @@ async def confirm_instant_switch( try: # Списываем баланс если это upgrade + # upgrade_cost includes both group + offer discounts from PricingEngine if is_upgrade and upgrade_cost > 0: success = await subtract_user_balance( db, db_user, upgrade_cost, f'Переключение на тариф {new_tariff.name}', - consume_promo_offer=get_user_active_promo_discount_percent(db_user) > 0, + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) if not success: @@ -3176,7 +3318,15 @@ async def confirm_instant_switch( if is_new_daily: # Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры - daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + # Apply group + promo-offer discounts via PricingEngine (single source of truth) + daily_pricing = await pricing_engine.calculate_tariff_purchase_price( + new_tariff, + period_days=1, + device_limit=new_tariff.device_limit, + user=db_user, + ) + daily_price = daily_pricing.final_total + consume_promo_for_daily = daily_pricing.breakdown.get('offer_discount_pct', 0) > 0 # Списываем первый день если ещё не списано (upgrade_cost был 0) if upgrade_cost == 0 and daily_price > 0: @@ -3186,6 +3336,7 @@ async def confirm_instant_switch( db_user, daily_price, f'Переключение на суточный тариф {new_tariff.name} (первый день)', + consume_promo_offer=consume_promo_for_daily, mark_as_paid_subscription=True, ) if not success: @@ -3199,6 +3350,22 @@ async def confirm_instant_switch( description=f'Переключение на суточный тариф {new_tariff.name} (первый день)', ) + # Уведомление админу о списании за первый день суточного тарифа + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, + was_trial_conversion=False, + amount_kopeks=daily_price, + purchase_type='tariff_switch', + ) + except Exception as e: + logger.error('Ошибка отправки уведомления админу', error=e) + subscription.end_date = datetime.now(UTC) + timedelta(days=1) subscription.is_trial = False subscription.is_daily_paused = False @@ -3266,7 +3433,6 @@ async def confirm_instant_switch( # Для суточного тарифа другое сообщение об успехе if is_new_daily: - daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) await callback.message.edit_text( f'🎉 Тариф успешно изменён!\n\n' f'📦 Новый тариф: {new_tariff.name}\n' diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index d811f53b..c0660008 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -19,18 +19,16 @@ from app.keyboards.inline import ( get_reset_traffic_confirm_keyboard, ) from app.localization.texts import get_texts +from app.services.pricing_engine import PricingEngine from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service from app.states import SubscriptionStates from app.utils.pricing_utils import ( - apply_percentage_discount, calculate_prorated_price, ) from .common import ( - _apply_addon_discount, - _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, @@ -84,7 +82,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A packages = tariff.get_traffic_topup_packages() period_hint_days = _get_period_hint_from_subscription(subscription) - traffic_discount_percent = _get_addon_discount_percent_for_user( + traffic_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'traffic', period_hint_days, @@ -136,7 +134,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) - traffic_discount_percent = _get_addon_discount_percent_for_user( + traffic_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'traffic', period_hint_days, @@ -261,6 +259,10 @@ async def confirm_reset_traffic(callback: types.CallbackQuery, db_user: User, db await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True) return + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + texts = get_texts(db_user.language) subscription = db_user.subscription @@ -471,16 +473,18 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes await callback.answer('⚠️ Цена для этого пакета не настроена', show_alert=True) return + # Lock user BEFORE price computation to prevent TOCTOU on group discount + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) + subscription = db_user.subscription + period_hint_days = _get_period_hint_from_subscription(subscription) - discount_result = _apply_addon_discount( - db_user, - 'traffic', + discounted_per_month, discount_per_month, traffic_discount_pct = PricingEngine.calculate_traffic_discount( base_price, + db_user, period_hint_days, ) - - discounted_per_month = discount_result['discounted'] - discount_per_month = discount_result['discount'] charged_days = 30 # На тарифах пакеты трафика покупаются на 1 месяц (30 дней), @@ -510,7 +514,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes 'traffic_gb': traffic_gb, 'price_kopeks': price, 'base_price_kopeks': discounted_per_month, - 'discount_percent': discount_result['percent'], + 'discount_percent': traffic_discount_pct, 'source': 'bot', 'description': f'Докупка {traffic_gb} ГБ трафика', } @@ -619,7 +623,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes if price > 0: success_text += f'\n💰 Списано: {texts.format_price(price)}' if total_discount_value > 0: - success_text += f' (скидка {discount_result["percent"]}%: -{texts.format_price(total_discount_value)})' + success_text += f' (скидка {traffic_discount_pct}%: -{texts.format_price(total_discount_value)})' await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) @@ -668,7 +672,7 @@ async def handle_switch_traffic(callback: types.CallbackQuery, db_user: User, db base_traffic = current_traffic - purchased_traffic period_hint_days = _get_period_hint_from_subscription(subscription) - traffic_discount_percent = _get_addon_discount_percent_for_user( + traffic_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'traffic', period_hint_days, @@ -722,17 +726,17 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d now = datetime.now(UTC) days_remaining = max(1, (subscription.end_date - now).days) period_hint_days = days_remaining if days_remaining > 0 else None - traffic_discount_percent = _get_addon_discount_percent_for_user( + traffic_discount_percent = PricingEngine.get_addon_discount_percent( db_user, 'traffic', period_hint_days, ) - discounted_old_per_month, _ = apply_percentage_discount( + discounted_old_per_month = PricingEngine.apply_discount( old_price_per_month, traffic_discount_percent, ) - discounted_new_per_month, _ = apply_percentage_discount( + discounted_new_per_month = PricingEngine.apply_discount( new_price_per_month, traffic_discount_percent, ) @@ -800,12 +804,35 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) - price_difference = int(callback_parts[4]) + + from app.database.crud.user import lock_user_for_pricing + + db_user = await lock_user_for_pricing(db, db_user.id) texts = get_texts(db_user.language) subscription = db_user.subscription current_traffic = subscription.traffic_limit_gb + # Recompute price under lock (callback-baked value may be stale) + purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0 + base_traffic = current_traffic - purchased_traffic + old_price_per_month = settings.get_traffic_price(base_traffic) + new_price_per_month = settings.get_traffic_price(new_traffic_gb) + days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) + traffic_discount_percent = PricingEngine.get_addon_discount_percent( + db_user, + 'traffic', + days_remaining, + ) + discounted_old = PricingEngine.apply_discount(old_price_per_month, traffic_discount_percent) + discounted_new = PricingEngine.apply_discount(new_price_per_month, traffic_discount_percent) + price_diff_per_month = discounted_new - discounted_old + if price_diff_per_month > 0: + price_difference = int(price_diff_per_month * days_remaining / 30) + price_difference = max(100, price_difference) + else: + price_difference = 0 + try: if price_difference > 0: success = await subtract_user_balance( diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index d2315256..c7f92cb5 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -123,11 +123,25 @@ class DailySubscriptionService: logger.warning('Тариф не найден для подписки', subscription_id=subscription.id) return 'error' - daily_price = tariff.daily_price_kopeks - if daily_price <= 0: + raw_daily_price = tariff.daily_price_kopeks + if raw_daily_price <= 0: logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id) return 'error' + # Lock user row to prevent TOCTOU between discount read and balance charge + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + + # Apply group discount to daily price (consistent with PricingEngine._calculate_switch_to_daily) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_price = ( + PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price + ) + # Проверяем баланс if user.balance_kopeks < daily_price: # Недостаточно средств - приостанавливаем подписку diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 1cac9c51..d20c16d2 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -118,7 +118,9 @@ async def validate_and_calculate( overrides = landing.discount_overrides or {} tariff_override = overrides.get(str(tariff_id)) effective_discount = tariff_override if tariff_override is not None else landing.discount_percent - price_kopeks = max(1, price_kopeks - (price_kopeks * effective_discount // 100)) + from app.services.pricing_engine import PricingEngine + + price_kopeks = max(1, PricingEngine.apply_discount(price_kopeks, effective_discount)) return tariff, price_kopeks @@ -283,6 +285,13 @@ async def fulfill_purchase( ) return purchase + squads = list(tariff.allowed_squads or []) + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if existing_subscription is not None: # Expired/inactive subscription — replace it existing_subscription.tariff_id = tariff.id @@ -292,7 +301,7 @@ async def fulfill_purchase( duration_days=purchase.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, is_trial=False, update_server_counters=True, ) @@ -304,7 +313,7 @@ async def fulfill_purchase( duration_days=purchase.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, tariff_id=tariff.id, update_server_counters=True, ) @@ -888,6 +897,13 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif existing_subscription = await get_subscription_by_user_id(db, user.id) subscription_service = SubscriptionService() + squads = list(tariff.allowed_squads or []) + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if existing_subscription is not None: subscription = await replace_subscription( db, @@ -895,7 +911,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif duration_days=purchase.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, is_trial=False, update_server_counters=True, commit=False, @@ -908,7 +924,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif duration_days=purchase.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, tariff_id=tariff.id, update_server_counters=True, commit=False, diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 95c59c7e..0ba7b0c8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1053,8 +1053,11 @@ class MonitoringService: autopay_period = 30 try: + from app.database.crud.user import lock_user_for_pricing from app.services.pricing_engine import pricing_engine + user = await lock_user_for_pricing(db, user.id) + pricing = await pricing_engine.calculate_renewal_price( db, subscription, diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index 6890642b..b95cd8b7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -260,6 +260,14 @@ class TelegramStarsMixin: logger.error('Не удалось активировать pending подписку пользователя', user_id=user.id) return False + # Consume promo-offer discount (invoice was created with discounted price) + try: + from app.utils.promo_offer import consume_user_promo_offer + + await consume_user_promo_offer(db, user.id) + except Exception as promo_error: + logger.warning('Ошибка потребления промо-оффера при Stars оплате', user_id=user.id, error=promo_error) + try: from app.services.subscription_service import SubscriptionService diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 8e589e50..9405ecc8 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -912,6 +912,18 @@ class YooKassaPaymentMixin: if subscription: logger.info('Подписка успешно активирована для пользователя', user_id=user.id) + # Consume promo-offer discount (invoice was created with discounted price) + try: + from app.utils.promo_offer import consume_user_promo_offer + + await consume_user_promo_offer(db, user.id) + except Exception as promo_error: + logger.warning( + 'Ошибка потребления промо-оффера при YooKassa оплате', + user_id=user.id, + error=promo_error, + ) + # Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки try: remnawave_user = await subscription_service.create_remnawave_user(db, subscription) diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index 8ec409de..cd95ba1e 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -242,12 +242,23 @@ class AutoPaymentVerificationService: ) for record in candidates: - refreshed = await run_manual_check( - session, - record.method, - record.local_id, - self._payment_service, - ) + try: + refreshed = await run_manual_check( + session, + record.method, + record.local_id, + self._payment_service, + ) + except Exception as check_error: + logger.error( + 'Ошибка проверки платежа, откатываем сессию', + method_display_name=method_display_name(record.method), + identifier=record.identifier, + error=check_error, + ) + if session.in_transaction(): + await session.rollback() + continue if not refreshed: logger.debug( @@ -972,6 +983,9 @@ async def run_manual_check( error=error, exc_info=True, ) + # Откатываем сессию чтобы не оставлять её в грязном состоянии + if db.in_transaction(): + await db.rollback() return None diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py index 579b9db7..9174a228 100644 --- a/app/services/pricing_engine.py +++ b/app/services/pricing_engine.py @@ -15,7 +15,7 @@ from app.utils.promo_offer import get_user_active_promo_discount_percent if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession - from app.database.models import Subscription, User + from app.database.models import Subscription, Tariff, User logger = structlog.get_logger(__name__) @@ -27,8 +27,9 @@ class TariffBreakdown: tariff_id: int extra_devices: int - group_discount_pct: int + group_discount_pct: dict[str, int] offer_discount_pct: int + months_in_period: int = 1 @dataclass(frozen=True) @@ -42,9 +43,14 @@ class ClassicBreakdown: base_traffic_gb: int purchased_traffic_gb: int extra_devices: int - # NB: dict[str, int] per-category (period/servers/traffic/devices), unlike TariffBreakdown's single int + # Per-category discount percents (period/servers/traffic/devices) group_discount_pct: dict[str, int] offer_discount_pct: int + # Original (pre-discount) prices — used by classic_pricing_to_purchase_details() + base_price_original: int = 0 + traffic_price_per_month: int = 0 + servers_price_per_month: int = 0 + devices_price_per_month: int = 0 @dataclass(frozen=True) @@ -68,6 +74,30 @@ class RenewalPricing: return self.final_total + self.promo_group_discount + self.promo_offer_discount +@dataclass(frozen=True) +class TariffSwitchResult: + """Immutable result of a tariff switch cost calculation.""" + + upgrade_cost: int # kopeks — amount to charge (0 if downgrade/same) + is_upgrade: bool # True if new tariff is more expensive + raw_cost: int # kopeks — cost before discounts (for UI display) + group_discount_pct: int + offer_discount_pct: int + new_period_days: int = 0 # 0 = keep current end date, >0 = set new subscription period + + @property + def discount_value(self) -> int: + """Сумма скидки в копейках.""" + return self.raw_cost - self.upgrade_cost + + @property + def effective_discount_pct(self) -> int: + """Эффективный процент скидки (стекинг group + offer).""" + if self.raw_cost <= 0: + return 0 + return round(self.discount_value * 100 / self.raw_cost) + + class PricingEngine: """Unified pricing engine for all subscription renewal calculations.""" @@ -93,6 +123,286 @@ class PricingEngine: offer_discount_value = after_group - after_offer return after_offer, group_discount_value, offer_discount_value + @staticmethod + def resolve_promo_group(user: User | None): + """Resolve primary promo group: get_primary_promo_group() first, fallback to user.promo_group.""" + if not user: + return None + if hasattr(user, 'get_primary_promo_group'): + pg = user.get_primary_promo_group() + if pg is not None: + return pg + return getattr(user, 'promo_group', None) + + @staticmethod + def get_addon_discount_percent( + user: User | None, + category: str, + period_days_hint: int | None = None, + *, + promo_group: PromoGroup | None = None, + ) -> int: + """Return addon discount percent for a given category. + + Uses promo_group.get_discount_percent() which handles is_default fallback. + Checks apply_discounts_to_addons flag. Returns 0 if no discount. + + If promo_group is provided explicitly, it takes precedence over + resolving from user (useful when caller already resolved the group). + """ + if promo_group is None: + if not user: + return 0 + promo_group = PricingEngine.resolve_promo_group(user) + + if not promo_group: + return 0 + + if not getattr(promo_group, 'apply_discounts_to_addons', True): + return 0 + + if hasattr(promo_group, 'get_discount_percent'): + return promo_group.get_discount_percent(category, period_days_hint) + + # Fallback for promo groups without get_discount_percent + mapping = { + 'traffic': 'traffic_discount_percent', + 'servers': 'server_discount_percent', + 'devices': 'device_discount_percent', + } + attr = mapping.get(category) + if attr: + return max(0, min(100, int(getattr(promo_group, attr, 0) or 0))) + return 0 + + @staticmethod + def calculate_traffic_discount( + base_price: int, + user: User | None, + period_days_hint: int | None = None, + ) -> tuple[int, int, int]: + """Apply traffic addon discount from user's promo group. + + Checks apply_discounts_to_addons flag. Uses integer arithmetic. + Uses get_discount_percent() for correct is_default fallback. + Returns: (final_price, discount_value, discount_percent). + """ + if not user or base_price <= 0: + return base_price, 0, 0 + + pct = PricingEngine.get_addon_discount_percent(user, 'traffic', period_days_hint) + if pct <= 0: + return base_price, 0, 0 + + final = PricingEngine.apply_discount(base_price, pct) + return final, base_price - final, pct + + # ------------------------------------------------------------------ + # Tariff switch + # ------------------------------------------------------------------ + + @staticmethod + def get_tariff_daily_rate_fraction(tariff: Tariff, target_days: int) -> tuple[int, int]: + """Дневная ставка тарифа как (price, period_days) для целочисленных вычислений. + + Возвращает числитель и знаменатель дроби price/period_days, + чтобы избежать float-ошибок в финансовых расчётах. + """ + periods = tariff.get_available_periods() + if not periods: + return 0, 1 + best_period = min(periods, key=lambda p: abs(p - target_days)) + price = tariff.get_price_for_period(best_period) + if not price or best_period <= 0: + return 0, 1 + return price, best_period + + def calculate_tariff_switch_cost( + self, + current_tariff: Tariff, + new_tariff: Tariff, + remaining_days: int, + *, + user: User | None = None, + ) -> TariffSwitchResult: + """Рассчитывает стоимость переключения тарифа. + + Автоматически определяет тип переключения: + - periodic→daily: оплата первого дня (daily_price_kopeks) + - daily→periodic: оплата кратчайшего периода нового тарифа + - periodic→periodic: пропорциональная разница дневных ставок × remaining_days + + Для всех типов переключений скидки (group + offer) применяются stacked. + """ + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + + # Daily tariff edge cases + if not current_is_daily and new_is_daily: + return self._calculate_switch_to_daily(new_tariff, remaining_days, user=user) + if current_is_daily and not new_is_daily: + return self._calculate_switch_from_daily(new_tariff, remaining_days, user=user) + if current_is_daily and new_is_daily: + # Daily → Daily: бесплатное переключение, cron начислит новую цену завтра + return TariffSwitchResult( + upgrade_cost=0, + is_upgrade=False, + raw_cost=0, + group_discount_pct=0, + offer_discount_pct=0, + new_period_days=1, + ) + + # --- Periodic → Periodic --- + + # Early return: нечего считать при нулевом остатке + if remaining_days <= 0: + return TariffSwitchResult( + upgrade_cost=0, + is_upgrade=False, + raw_cost=0, + group_discount_pct=0, + offer_discount_pct=0, + new_period_days=0, + ) + + # Целочисленная арифметика (без float round-trip): + # raw_cost = (new_p/new_d - cur_p/cur_d) * remaining + # = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d) + # Floor division (//) округляет дробные копейки вниз — в пользу пользователя. + cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days) + new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days) + + numerator = (new_price * cur_period - cur_price * new_period) * remaining_days + denominator = new_period * cur_period + raw_cost = max(0, numerator // denominator) + + if numerator <= 0: + return TariffSwitchResult( + upgrade_cost=0, + is_upgrade=False, + raw_cost=0, + group_discount_pct=0, + offer_discount_pct=0, + new_period_days=0, + ) + + # Resolve discounts via resolve_promo_group (get_primary_promo_group first) + group_pct = 0 + offer_pct = 0 + if user: + promo_group = self.resolve_promo_group(user) + if promo_group is not None: + best_period = min( + current_tariff.get_available_periods() or [30], + key=lambda p: abs(p - remaining_days), + ) + group_pct = promo_group.get_discount_percent('period', best_period) + offer_pct = get_user_active_promo_discount_percent(user) + + # Применяем stacked скидки к итоговой сумме напрямую (без float round-trip) + if group_pct > 0 or offer_pct > 0: + upgrade_cost, _, _ = self.apply_stacked_discounts(raw_cost, group_pct, offer_pct) + else: + upgrade_cost = raw_cost + + return TariffSwitchResult( + upgrade_cost=upgrade_cost, + is_upgrade=True, + raw_cost=raw_cost, + group_discount_pct=group_pct, + offer_discount_pct=offer_pct, + new_period_days=0, + ) + + def _calculate_switch_to_daily( + self, + new_tariff: Tariff, + remaining_days: int, + *, + user: User | None = None, + ) -> TariffSwitchResult: + """Periodic → Daily: оплата первого дня с group + offer discount.""" + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) or 0 + if daily_price <= 0: + return TariffSwitchResult( + upgrade_cost=0, + is_upgrade=False, + raw_cost=0, + group_discount_pct=0, + offer_discount_pct=0, + new_period_days=1, + ) + + group_pct = 0 + offer_pct = 0 + if user: + promo_group = self.resolve_promo_group(user) + if promo_group: + period_hint = remaining_days if remaining_days > 0 else 30 + group_pct = promo_group.get_discount_percent('period', period_hint) + offer_pct = get_user_active_promo_discount_percent(user) + + if group_pct > 0 or offer_pct > 0: + upgrade_cost, _, _ = self.apply_stacked_discounts(daily_price, group_pct, offer_pct) + else: + upgrade_cost = daily_price + + return TariffSwitchResult( + upgrade_cost=upgrade_cost, + is_upgrade=upgrade_cost > 0, + raw_cost=daily_price, + group_discount_pct=group_pct, + offer_discount_pct=offer_pct, + new_period_days=1, + ) + + def _calculate_switch_from_daily( + self, + new_tariff: Tariff, + remaining_days: int, + *, + user: User | None = None, + ) -> TariffSwitchResult: + """Daily → Periodic: оплата кратчайшего периода нового тарифа с group + offer discount.""" + min_period_days = 30 + min_period_price = 0 + if new_tariff.period_prices: + min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) + min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) or 0 + + if min_period_price <= 0: + return TariffSwitchResult( + upgrade_cost=0, + is_upgrade=False, + raw_cost=0, + group_discount_pct=0, + offer_discount_pct=0, + new_period_days=min_period_days, + ) + + group_pct = 0 + offer_pct = 0 + if user: + promo_group = self.resolve_promo_group(user) + if promo_group: + group_pct = promo_group.get_discount_percent('period', min_period_days) + offer_pct = get_user_active_promo_discount_percent(user) + + if group_pct > 0 or offer_pct > 0: + upgrade_cost, _, _ = self.apply_stacked_discounts(min_period_price, group_pct, offer_pct) + else: + upgrade_cost = min_period_price + + return TariffSwitchResult( + upgrade_cost=upgrade_cost, + is_upgrade=upgrade_cost > 0, + raw_cost=min_period_price, + group_discount_pct=group_pct, + offer_discount_pct=offer_pct, + new_period_days=min_period_days, + ) + async def _calculate_servers_price( self, country_uuids: list[str], @@ -222,37 +532,99 @@ class PricingEngine: ) -> RenewalPricing: """Price calculation when subscription is linked to a Tariff.""" tariff = subscription.tariff - period_prices: dict = tariff.period_prices or {} - base_price = int(period_prices.get(str(period_days), 0) or 0) + device_limit = subscription.device_limit or 0 + return await self._calculate_tariff_core( + tariff, + period_days, + device_limit, + user=user, + ) - # Extra devices above the tariff's included limit + async def _calculate_tariff_core( + self, + tariff: Tariff, + period_days: int, + device_limit: int, + *, + custom_traffic_gb: int | None = None, + user: User | None = None, + ) -> RenewalPricing: + """Core tariff pricing logic (raw params, no Subscription needed). + + Per-category discounts: + - 'period' → base tariff price + - 'devices' → extra device cost + Promo-offer discount applied on the discounted subtotal. + Device cost is monthly × months_in_period. + """ + months = calculate_months_from_days(period_days) + + # --- Base price --- + is_daily = getattr(tariff, 'is_daily', False) + if is_daily and period_days <= 1: + base_price = int(getattr(tariff, 'daily_price_kopeks', 0) or 0) + else: + period_prices: dict = tariff.period_prices or {} + base_price = int(period_prices.get(str(period_days), 0) or 0) + if base_price == 0 and hasattr(tariff, 'get_price_for_custom_days'): + if hasattr(tariff, 'can_purchase_custom_days') and tariff.can_purchase_custom_days(): + custom_price = tariff.get_price_for_custom_days(period_days) + if custom_price is not None: + base_price = int(custom_price) + + # --- Extra devices (monthly × months) --- device_price_per_unit = ( tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 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 + tariff_device_limit = tariff.device_limit or 0 + extra_devices = max(0, (device_limit or 0) - tariff_device_limit) + if is_daily and period_days <= 1: + devices_price = extra_devices * device_price_per_unit + else: + devices_price = extra_devices * device_price_per_unit * months - subtotal = base_price + devices_price + # --- Custom traffic (tariff add-on, uses addon discount path) --- + traffic_price = 0 + if custom_traffic_gb is not None and hasattr(tariff, 'get_price_for_custom_traffic'): + raw_traffic = tariff.get_price_for_custom_traffic(custom_traffic_gb) + if raw_traffic and raw_traffic > 0: + traffic_price = int(raw_traffic) - # 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) + # --- Per-category group discounts --- + period_pct = 0 + devices_pct = 0 + promo_group = self.resolve_promo_group(user) + if promo_group is not None: + period_pct = promo_group.get_discount_percent('period', period_days) + devices_pct = promo_group.get_discount_percent('devices', 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, - ) + discounted_base = self.apply_discount(base_price, period_pct) + discounted_devices = self.apply_discount(devices_price, devices_pct) + + # Traffic uses addon discount (checks apply_discounts_to_addons flag) + discounted_traffic = traffic_price + if traffic_price > 0 and user: + discounted_traffic, _, _ = self.calculate_traffic_discount(traffic_price, user) + + base_group_disc = base_price - discounted_base + devices_group_disc = devices_price - discounted_devices + traffic_group_disc = traffic_price - discounted_traffic + total_group_discount = base_group_disc + devices_group_disc + traffic_group_disc + + subtotal = discounted_base + discounted_devices + discounted_traffic + after_offer = self.apply_discount(subtotal, offer_pct) + offer_discount = subtotal - after_offer + final_total = after_offer breakdown = dataclasses.asdict( TariffBreakdown( tariff_id=tariff.id, extra_devices=extra_devices, - group_discount_pct=group_pct, + group_discount_pct={'period': period_pct, 'devices': devices_pct}, offer_discount_pct=offer_pct, + months_in_period=months, ) ) @@ -261,16 +633,17 @@ class PricingEngine: 'Negative final_total in tariff mode, clamping to 0', final_total=final_total, subtotal=subtotal, - group_pct=group_pct, + period_pct=period_pct, + devices_pct=devices_pct, offer_pct=offer_pct, ) return RenewalPricing( - base_price=base_price, + base_price=discounted_base, servers_price=0, - traffic_price=0, - devices_price=devices_price, - promo_group_discount=group_discount, + traffic_price=discounted_traffic, + devices_price=discounted_devices, + promo_group_discount=total_group_discount, promo_offer_discount=offer_discount, final_total=max(0, final_total), period_days=period_days, @@ -278,19 +651,45 @@ class PricingEngine: breakdown=breakdown, ) + async def calculate_tariff_purchase_price( + self, + tariff: Tariff, + period_days: int, + *, + device_limit: int | None = None, + custom_traffic_gb: int | None = None, + user: User | None = None, + ) -> RenewalPricing: + """Calculate price for a tariff purchase (new or renewal). + + Public method that delegates to _calculate_tariff_core. + If device_limit is None, uses the tariff's included limit (no extra devices). + """ + effective_device_limit = device_limit if device_limit is not None else (tariff.device_limit or 0) + return await self._calculate_tariff_core( + tariff, + period_days, + effective_device_limit, + custom_traffic_gb=custom_traffic_gb, + user=user, + ) + # ------------------------------------------------------------------ # Classic mode # ------------------------------------------------------------------ - async def _calculate_classic_mode( + async def _calculate_classic_core( self, db: AsyncSession, - subscription: Subscription, period_days: int, + connected_squads: list[str], + traffic_limit_gb: int, + device_limit: int, *, + purchased_traffic_gb: int = 0, user: User | None = None, ) -> RenewalPricing: - """Price calculation for legacy (non-tariff) subscriptions. + """Core classic-mode pricing logic (raw params, no Subscription needed). Uses CLASSIC_PERIOD_PRICES from settings, falling back to the global PERIOD_PRICES dict during migration. @@ -298,6 +697,7 @@ class PricingEngine: Per-category discounts (period, servers, traffic, devices) are applied separately to each component. Servers, traffic, and devices are monthly prices multiplied by months_in_period. + Promo-offer discount is applied on the subtotal. """ months = calculate_months_from_days(period_days) @@ -312,14 +712,13 @@ class PricingEngine: fallback_price_kopeks=base_price_original, ) - # --- Per-category discount percents --- + # --- Per-category discount percents (resolve_promo_group: get_primary_promo_group first) --- period_pct = 0 servers_pct = 0 traffic_pct = 0 devices_pct = 0 - promo_group = None - if user and getattr(user, 'promo_group', None) is not None: - promo_group = user.promo_group + promo_group = self.resolve_promo_group(user) + if promo_group is not None: period_pct = promo_group.get_discount_percent('period', period_days) servers_pct = promo_group.get_discount_percent('servers', period_days) traffic_pct = promo_group.get_discount_percent('traffic', period_days) @@ -331,7 +730,6 @@ class PricingEngine: base_price = self.apply_discount(base_price_original, period_pct) # --- Servers (monthly × months, with servers discount) --- - connected_squads: list[str] = subscription.connected_squads or [] promo_group_id = getattr(user, 'promo_group_id', None) if user else None servers_price_per_month, server_details = await self._calculate_servers_price( connected_squads, @@ -345,13 +743,6 @@ class PricingEngine: if settings.is_traffic_fixed(): traffic_limit_gb = settings.get_fixed_traffic_limit() purchased_traffic_gb = 0 - else: - traffic_limit_gb = ( - subscription.traffic_limit_gb - if subscription.traffic_limit_gb is not None - else settings.DEFAULT_TRAFFIC_LIMIT_GB - ) - purchased_traffic_gb = subscription.purchased_traffic_gb or 0 traffic_price_per_month = self._calculate_traffic_price(traffic_limit_gb, purchased_traffic_gb) discounted_traffic_per_month = self.apply_discount(traffic_price_per_month, traffic_pct) traffic_price = discounted_traffic_per_month * months @@ -359,7 +750,7 @@ class PricingEngine: # --- Devices (monthly × months, with devices discount) --- 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) + extra_devices = max(0, (device_limit or 0) - default_device_limit) devices_price_per_month = extra_devices * device_price_per_unit discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct) devices_price = discounted_devices_per_month * months @@ -386,7 +777,9 @@ class PricingEngine: ClassicBreakdown( months_in_period=months, servers=server_details, - servers_individual_prices=[d['price'] * months for d in valid_servers], + servers_individual_prices=[ + self.apply_discount(d['price'], servers_pct) * months for d in valid_servers + ], server_ids=[d['id'] for d in valid_servers], base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb), purchased_traffic_gb=purchased_traffic_gb, @@ -398,6 +791,10 @@ class PricingEngine: 'devices': devices_pct, }, offer_discount_pct=offer_pct, + base_price_original=base_price_original, + traffic_price_per_month=traffic_price_per_month, + servers_price_per_month=servers_price_per_month, + devices_price_per_month=devices_price_per_month, ) ) @@ -422,6 +819,116 @@ class PricingEngine: breakdown=breakdown, ) + async def _calculate_classic_mode( + self, + db: AsyncSession, + subscription: Subscription, + period_days: int, + *, + user: User | None = None, + ) -> RenewalPricing: + """Price calculation for legacy (non-tariff) subscriptions. + + Thin wrapper that extracts raw params from a Subscription + and delegates to _calculate_classic_core. + """ + connected_squads: list[str] = subscription.connected_squads or [] + traffic_limit_gb = ( + subscription.traffic_limit_gb + if subscription.traffic_limit_gb is not None + else settings.DEFAULT_TRAFFIC_LIMIT_GB + ) + purchased_traffic_gb = subscription.purchased_traffic_gb or 0 + device_limit = subscription.device_limit or 0 + + return await self._calculate_classic_core( + db, + period_days, + connected_squads, + traffic_limit_gb, + device_limit, + purchased_traffic_gb=purchased_traffic_gb, + user=user, + ) + + async def calculate_classic_new_subscription_price( + self, + db: AsyncSession, + period_days: int, + connected_squads: list[str], + traffic_limit_gb: int, + device_limit: int, + *, + user: User | None = None, + ) -> RenewalPricing: + """Calculate price for a NEW classic (non-tariff) subscription. + + Like calculate_renewal_price but without requiring an existing + Subscription object. purchased_traffic_gb is always 0. + """ + return await self._calculate_classic_core( + db, + period_days, + connected_squads, + traffic_limit_gb, + device_limit, + purchased_traffic_gb=0, + user=user, + ) + + @staticmethod + def classic_pricing_to_purchase_details(pricing: RenewalPricing) -> dict[str, Any]: + """Convert RenewalPricing to the legacy details dict format. + + The returned dict is compatible with build_preview_payload + in SubscriptionPurchaseService. + """ + bd = pricing.breakdown + months = bd.get('months_in_period', 1) or 1 + group_pct = bd.get('group_discount_pct', {}) + + base_price_original = bd.get('base_price_original', 0) + traffic_price_per_month = bd.get('traffic_price_per_month', 0) + servers_price_per_month = bd.get('servers_price_per_month', 0) + devices_price_per_month = bd.get('devices_price_per_month', 0) + + period_pct = group_pct.get('period', 0) + traffic_pct = group_pct.get('traffic', 0) + servers_pct = group_pct.get('servers', 0) + devices_pct = group_pct.get('devices', 0) + + base_discount_total = base_price_original - pricing.base_price + traffic_discount_total = ( + traffic_price_per_month - PricingEngine.apply_discount(traffic_price_per_month, traffic_pct) + ) * months + servers_discount_total = ( + servers_price_per_month - PricingEngine.apply_discount(servers_price_per_month, servers_pct) + ) * months + devices_discount_total = ( + devices_price_per_month - PricingEngine.apply_discount(devices_price_per_month, devices_pct) + ) * months + + return { + 'base_price': pricing.base_price, + 'base_price_original': base_price_original, + 'base_discount_percent': period_pct, + 'base_discount_total': base_discount_total, + 'traffic_price_per_month': traffic_price_per_month, + 'traffic_discount_percent': traffic_pct, + 'traffic_discount_total': traffic_discount_total, + 'total_traffic_price': pricing.traffic_price, + 'servers_price_per_month': servers_price_per_month, + 'servers_discount_percent': servers_pct, + 'servers_discount_total': servers_discount_total, + 'total_servers_price': pricing.servers_price, + 'devices_price_per_month': devices_price_per_month, + 'devices_discount_percent': devices_pct, + 'devices_discount_total': devices_discount_total, + 'total_devices_price': pricing.devices_price, + 'months_in_period': months, + 'servers_individual_prices': bd.get('servers_individual_prices', []), + } + # Module-level singleton — use this instead of PricingEngine() pricing_engine = PricingEngine() diff --git a/app/services/recurrent_payment_service.py b/app/services/recurrent_payment_service.py index 42162e1d..e9155187 100644 --- a/app/services/recurrent_payment_service.py +++ b/app/services/recurrent_payment_service.py @@ -224,8 +224,12 @@ async def _process_single_subscription( autopay_period = 30 try: + from app.database.crud.user import lock_user_for_pricing from app.services.pricing_engine import pricing_engine + # TOCTOU: lock user row before pricing to prevent concurrent promo/balance races + user = await lock_user_for_pricing(db, user.id) + pricing = await pricing_engine.calculate_renewal_price( db, subscription, diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index ae1ed28b..27554d82 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.subscription import extend_subscription from app.database.crud.transaction import create_transaction -from app.database.crud.user import get_user_by_id, subtract_user_balance +from app.database.crud.user import subtract_user_balance from app.database.models import Subscription, SubscriptionStatus, TransactionType, User from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService @@ -83,13 +83,11 @@ async def _prepare_auto_purchase( ) return None - # Перезагружаем user с нужными связями (user_promo_groups), + # Блокируем user с нужными связями (user_promo_groups) для защиты от TOCTOU, # т.к. после db.refresh() в payment-сервисах связи сбрасываются - fresh_user = await get_user_by_id(db, user.id) - if not fresh_user: - logger.warning('🔁 Автопокупка: не удалось перезагрузить пользователя', format_user_id=_format_user_id(user)) - return None - user = fresh_user + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) miniapp_service = MiniAppSubscriptionPurchaseService() context = await miniapp_service.build_options(db, user) @@ -141,11 +139,6 @@ def _safe_int(value: object | None, default: int = 0) -> int: return default -def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int: - """Применяет скидку промогруппы к цене тарифа.""" - return PricingEngine.apply_discount(price, discount_percent) - - async def _prepare_auto_extend_context( db: AsyncSession, user: User, @@ -187,7 +180,11 @@ async def _prepare_auto_extend_context( if tariff_id: tariff_id = _safe_int(tariff_id) + from app.database.crud.user import lock_user_for_pricing from app.services.pricing_engine import pricing_engine as _pricing_engine + from app.utils.promo_offer import get_user_active_promo_discount_percent + + user = await lock_user_for_pricing(db, user.id) try: pricing = await _pricing_engine.calculate_renewal_price( @@ -232,8 +229,6 @@ async def _prepare_auto_extend_context( traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0) squad_uuid = cart_data.get('squad_uuid') - from app.utils.promo_offer import get_user_active_promo_discount_percent - consume_promo_offer = get_user_active_promo_discount_percent(user) > 0 allowed_squads = cart_data.get('allowed_squads') @@ -625,44 +620,26 @@ async def _auto_purchase_tariff( ) return False - # Получаем актуальную цену тарифа - prices = tariff.period_prices or {} - base_price = prices.get(str(period_days)) - if base_price is None: - logger.warning( - '🔁 Автопокупка тарифа: период дней недоступен для тарифа', period_days=period_days, tariff_id=tariff_id - ) - return False + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing - final_price = int(base_price) - - # Проверяем есть ли уже подписка (нужно до расчёта цены для учёта доп. устройств) existing_subscription = await get_subscription_by_user_id(db, user.id) - # Добавляем стоимость докупленных устройств ДО скидки (как в cabinet) + user = await lock_user_for_pricing(db, user.id) + + # Calculate price via PricingEngine (single source of truth) + device_limit = None if existing_subscription and existing_subscription.tariff_id == tariff_id: - extra_devices = max(0, (existing_subscription.device_limit or 0) - (tariff.device_limit or 0)) - if extra_devices > 0: - device_price_per_unit = ( - tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE - ) - extra_devices_cost = extra_devices * device_price_per_unit - final_price += extra_devices_cost + device_limit = existing_subscription.device_limit - # Пересчитываем скидку из актуальных данных пользователя (не из stale корзины) - # Promo_group и promo_offer применяются последовательно (как в cabinet) - from app.utils.promo_offer import get_user_active_promo_discount_percent - - discount_percent = 0 - if hasattr(user, 'get_promo_discount'): - discount_percent = user.get_promo_discount('period', period_days) - - if discount_percent > 0: - final_price = _apply_promo_discount_for_tariff(final_price, discount_percent) - - promo_offer_percent = get_user_active_promo_discount_percent(user) - if promo_offer_percent > 0: - final_price = _apply_promo_discount_for_tariff(final_price, promo_offer_percent) + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + period_days, + device_limit=device_limit, + user=user, + ) + final_price = result.final_total + consume_promo = result.promo_offer_discount > 0 if user.balance_kopeks < final_price: logger.info( @@ -674,7 +651,6 @@ async def _auto_purchase_tariff( return False # Save promo offer state before deduction (for restore on failure) - consume_promo = promo_offer_percent > 0 saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0 saved_promo_source = getattr(user, 'promo_offer_discount_source', None) if consume_promo else None saved_promo_expires = getattr(user, 'promo_offer_discount_expires_at', None) if consume_promo else None @@ -978,12 +954,25 @@ async def _auto_purchase_daily_tariff( ) return False - if user.balance_kopeks < daily_price: + # Блокируем пользователя и применяем скидки (group + promo-offer) + from app.database.crud.user import lock_user_for_pricing + from app.utils.promo_offer import get_user_active_promo_discount_percent + + user = await lock_user_for_pricing(db, user.id) + + promo_group = user.get_primary_promo_group() + group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + offer_pct = get_user_active_promo_discount_percent(user) + + final_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, group_pct, offer_pct) + consume_promo = offer_pct > 0 + + if user.balance_kopeks < final_price: logger.info( '🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)', format_user_id=_format_user_id(user), balance_kopeks=user.balance_kopeks, - daily_price=daily_price, + final_price=final_price, ) return False @@ -993,8 +982,9 @@ async def _auto_purchase_daily_tariff( success = await subtract_user_balance( db, user, - daily_price, + final_price, description, + consume_promo_offer=consume_promo, mark_as_paid_subscription=True, ) if not success: @@ -1081,7 +1071,7 @@ async def _auto_purchase_daily_tariff( await add_user_balance( db, user, - daily_price, + final_price, 'Возврат: ошибка автопокупки суточного тарифа', create_transaction=True, transaction_type=TransactionType.REFUND, @@ -1089,13 +1079,13 @@ async def _auto_purchase_daily_tariff( logger.info( '💰 Автопокупка суточного тарифа: возврат средств после ошибки создания подписки', format_user_id=_format_user_id(user), - refund_kopeks=daily_price, + refund_kopeks=final_price, ) except Exception as refund_error: logger.critical( 'CRITICAL: Автопокупка суточного тарифа: не удалось вернуть средства', format_user_id=_format_user_id(user), - price_kopeks=daily_price, + price_kopeks=final_price, refund_error=refund_error, ) return False @@ -1106,7 +1096,7 @@ async def _auto_purchase_daily_tariff( db=db, user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=daily_price, + amount_kopeks=final_price, description=description, ) except Exception as error: @@ -1167,7 +1157,7 @@ async def _auto_purchase_daily_tariff( message = ( f'✅ Суточный тариф «{tariff.name}» активирован!\n\n' - f'💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n' + f'💰 Списано: {final_price / 100:.0f} ₽ за первый день\n' f'🔄 Средства будут списываться автоматически раз в сутки.\n\n' f'ℹ️ Вы можете приостановить подписку в любой момент.' ) @@ -1215,7 +1205,7 @@ async def _auto_purchase_daily_tariff( await notify_user_subscription_renewed( user_id=user.id, new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '', - amount_kopeks=daily_price, + amount_kopeks=final_price, ) else: # New subscription activation @@ -1244,28 +1234,19 @@ async def _auto_add_devices( """Auto-purchase devices from saved cart after balance topup.""" from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - from app.database.crud.user import subtract_user_balance + from app.database.crud.user import lock_user_for_pricing, subtract_user_balance from app.database.models import PaymentMethod + from app.utils.pricing_utils import apply_percentage_discount devices_to_add = _safe_int(cart_data.get('devices_to_add')) - price_kopeks = _safe_int(cart_data.get('price_kopeks')) + cart_price_kopeks = _safe_int(cart_data.get('price_kopeks')) - if devices_to_add <= 0 or price_kopeks <= 0: + if devices_to_add <= 0 or cart_price_kopeks <= 0: logger.warning( '🔁 Автопокупка устройств: некорректные данные корзины для пользователя (devices price=)', format_user_id=_format_user_id(user), devices_to_add=devices_to_add, - price_kopeks=price_kopeks, - ) - return False - - # Проверяем баланс - if user.balance_kopeks < price_kopeks: - logger.info( - '🔁 Автопокупка устройств: у пользователя недостаточно средств (<)', - format_user_id=_format_user_id(user), - balance_kopeks=user.balance_kopeks, - price_kopeks=price_kopeks, + cart_price_kopeks=cart_price_kopeks, ) return False @@ -1330,6 +1311,44 @@ async def _auto_add_devices( await user_cart_service.delete_user_cart(user.id) return False + # Lock user BEFORE price computation to prevent TOCTOU on promo-offer/group discount + user = await lock_user_for_pricing(db, user.id) + + # Recompute price fresh under lock (pricing config may have changed since cart was saved) + devices_price_per_month = devices_to_add * tariff_device_price + days_left = max(1, (subscription.end_date - datetime.now(UTC)).days) + devices_discount_percent = PricingEngine.get_addon_discount_percent( + user, + 'devices', + days_left, + ) + discounted_per_month, _ = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + price_kopeks = int(discounted_per_month * days_left / 30) + price_kopeks = max(100, price_kopeks) + + if price_kopeks != cart_price_kopeks: + logger.warning( + '🔁 Автопокупка устройств: пересчитанная цена отличается от корзины', + format_user_id=_format_user_id(user), + cart_price_kopeks=cart_price_kopeks, + recomputed_price_kopeks=price_kopeks, + devices_discount_percent=devices_discount_percent, + days_left=days_left, + ) + + # Проверяем баланс (с актуальной ценой) + if user.balance_kopeks < price_kopeks: + logger.info( + '🔁 Автопокупка устройств: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + price_kopeks=price_kopeks, + ) + return False + # Списываем баланс description = f'Покупка {devices_to_add} доп. устройств' try: @@ -1519,28 +1538,19 @@ async def _auto_add_traffic( from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from app.database.crud.subscription import add_subscription_traffic, get_subscription_by_user_id - from app.database.crud.user import subtract_user_balance + from app.database.crud.user import lock_user_for_pricing, subtract_user_balance from app.database.models import PaymentMethod + from app.utils.pricing_utils import calculate_prorated_price traffic_gb = _safe_int(cart_data.get('traffic_gb')) - price_kopeks = _safe_int(cart_data.get('price_kopeks')) + cart_price_kopeks = _safe_int(cart_data.get('price_kopeks')) - if traffic_gb <= 0 or price_kopeks <= 0: + if traffic_gb <= 0 or cart_price_kopeks <= 0: logger.warning( '🔁 Автопокупка трафика: некорректные данные корзины для пользователя (traffic_gb price=)', format_user_id=_format_user_id(user), traffic_gb=traffic_gb, - price_kopeks=price_kopeks, - ) - return False - - # Verify balance - if user.balance_kopeks < price_kopeks: - logger.info( - '🔁 Автопокупка трафика: у пользователя недостаточно средств (<)', - format_user_id=_format_user_id(user), - balance_kopeks=user.balance_kopeks, - price_kopeks=price_kopeks, + cart_price_kopeks=cart_price_kopeks, ) return False @@ -1572,6 +1582,72 @@ async def _auto_add_traffic( await user_cart_service.delete_user_cart(user.id) return False + # Lock user BEFORE price computation to prevent TOCTOU on promo-offer/group discount + user = await lock_user_for_pricing(db, user.id) + + # Recompute base price from tariff/settings (config may have changed since cart was saved) + tariff = None + if settings.is_tariffs_mode() and subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if tariff and tariff.can_topup_traffic(): + base_price = tariff.get_traffic_topup_price(traffic_gb) or 0 + else: + base_price = settings.get_traffic_topup_price(traffic_gb) + + if base_price <= 0 and traffic_gb != 0: + logger.warning( + '🔁 Автопокупка трафика: цена пакета не настроена, корзина удалена', + format_user_id=_format_user_id(user), + traffic_gb=traffic_gb, + ) + await user_cart_service.delete_user_cart(user.id) + return False + + # Apply traffic discount from promo group + period_hint_days: int | None = None + if subscription.end_date: + days_remaining = (subscription.end_date - datetime.now(UTC)).days + period_hint_days = days_remaining if days_remaining > 0 else None + + discounted_per_month, _, _ = PricingEngine.calculate_traffic_discount( + base_price, + user, + period_hint_days, + ) + + # Prorate for classic mode (tariff mode uses monthly price as-is) + is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id + if is_tariff_mode: + price_kopeks = discounted_per_month + elif subscription and subscription.end_date: + price_kopeks, _ = calculate_prorated_price(discounted_per_month, subscription.end_date) + else: + price_kopeks = discounted_per_month + + if cart_price_kopeks != price_kopeks: + logger.warning( + '🔁 Автопокупка трафика: пересчитанная цена отличается от корзины', + format_user_id=_format_user_id(user), + cart_price_kopeks=cart_price_kopeks, + recomputed_price_kopeks=price_kopeks, + base_price=base_price, + discounted_per_month=discounted_per_month, + period_hint_days=period_hint_days, + ) + + # Verify balance (with fresh price) + if user.balance_kopeks < price_kopeks: + logger.info( + '🔁 Автопокупка трафика: у пользователя недостаточно средств (<)', + format_user_id=_format_user_id(user), + balance_kopeks=user.balance_kopeks, + price_kopeks=price_kopeks, + ) + return False + # Deduct balance description = f'Докупка {traffic_gb} ГБ трафика' try: @@ -1801,6 +1877,11 @@ async def try_auto_extend_expired_after_topup( else: period_days = 30 + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Calculate renewal price via PricingEngine subscription_service = SubscriptionService() try: @@ -1870,10 +1951,8 @@ async def try_auto_extend_expired_after_topup( check_error=check_error, ) - # Determine if promo offer discount was applied (for consume flag) - from app.utils.promo_offer import get_user_active_promo_discount_percent - - consume_promo_offer = get_user_active_promo_discount_percent(user) > 0 + # Derive consume_promo_offer from PricingEngine result (user already locked above) + consume_promo_offer = pricing.promo_offer_discount > 0 # Save promo offer state before deduction (for restore on failure) saved_promo_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) if consume_promo_offer else 0 @@ -2142,11 +2221,25 @@ async def try_resume_disabled_daily_after_topup( if not tariff: return False - daily_price = getattr(tariff, 'daily_price_kopeks', 0) - if daily_price <= 0: + raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if raw_daily_price <= 0: return False - # Check balance + # Lock user row to prevent TOCTOU between discount read and balance charge + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + + # Apply group discount to daily price (consistent with PricingEngine._calculate_switch_to_daily) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_price = ( + PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price + ) + + # Check balance (uses locked user's balance_kopeks — safe from concurrent reads) if user.balance_kopeks < daily_price: logger.info( '🔄 Авто-возобновление daily: недостаточно средств', diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index ce25ab1b..ac1fb806 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -11,7 +11,6 @@ from app.config import PERIOD_PRICES, settings from app.database.crud.server_squad import ( add_user_to_servers, get_available_server_squads, - get_server_ids_by_uuids, get_server_squad_by_uuid, ) from app.database.crud.subscription import ( @@ -32,7 +31,6 @@ from app.utils.pricing_utils import ( format_period_description, validate_pricing_calculation, ) -from app.utils.promo_offer import get_user_active_promo_discount_percent logger = structlog.get_logger(__name__) @@ -279,18 +277,6 @@ def _apply_discount_to_monthly_component(amount_per_month: int, percent: int, mo } -def _get_promo_offer_discount_percent(user: User | None) -> int: - return get_user_active_promo_discount_percent(user) - - -def _apply_promo_offer_discount(user: User | None, amount: int) -> tuple[int, int, int]: - percent = _get_promo_offer_discount_percent(user) - if amount <= 0 or percent <= 0: - return amount, 0, 0 - discounted, discount_value = apply_percentage_discount(amount, percent) - return discounted, discount_value, percent - - def _build_server_option( server: ServerSquad, discount_percent: int, @@ -321,11 +307,9 @@ class MiniAppSubscriptionPurchaseService: currency = (getattr(user, 'balance_currency', None) or 'RUB').upper() texts = get_texts(getattr(user, 'language', None)) - # Exclude trial-only servers from purchase options available_servers = await get_available_server_squads( db, promo_group_id=getattr(user, 'promo_group_id', None), - exclude_trial_only=True, ) server_catalog: dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers} @@ -711,29 +695,30 @@ class MiniAppSubscriptionPurchaseService: get_texts(getattr(context.user, 'language', None)) months = selection.period.months - server_ids = await get_server_ids_by_uuids(db, selection.servers) + # PricingEngine — single source of truth (includes promo-offer internally). + # Server validation is done via breakdown (avoids a duplicate DB query). + from app.services.pricing_engine import PricingEngine, pricing_engine + + pricing = await pricing_engine.calculate_classic_new_subscription_price( + db, + selection.period.days, + list(selection.servers), + selection.traffic_value, + selection.devices, + user=context.user, + ) + + # Validate all requested servers were found + server_ids = pricing.breakdown.get('server_ids', []) if len(server_ids) != len(selection.servers): raise PurchaseValidationError('Some selected servers are not available', code='invalid_servers') - total_without_promo, details = await self._calculate_base_total( - db, - context.user, - selection, - server_ids, - ) + details = PricingEngine.classic_pricing_to_purchase_details(pricing) - base_original_total = ( - details['base_price_original'] - + details['traffic_price_per_month'] * months - + details['servers_price_per_month'] * months - + details['devices_price_per_month'] * months - ) - - final_total, promo_discount_value, promo_percent = _apply_promo_offer_discount( - context.user, total_without_promo - ) - - discounted_total = total_without_promo + base_original_total = pricing.original_total + discounted_total = pricing.final_total + pricing.promo_offer_discount # subtotal before offer + promo_discount_value = pricing.promo_offer_discount + promo_percent = pricing.breakdown.get('offer_discount_pct', 0) is_valid = validate_pricing_calculation( details.get('base_price', 0), @@ -755,30 +740,11 @@ class MiniAppSubscriptionPurchaseService: discounted_total=discounted_total, promo_discount_value=promo_discount_value, promo_discount_percent=promo_percent, - final_total=final_total, + final_total=pricing.final_total, months=months, details=details, ) - async def _calculate_base_total( - self, - db: AsyncSession, - user: User, - selection: PurchaseSelection, - server_ids: list[int], - ) -> tuple[int, dict[str, Any]]: - from app.database.crud.subscription import calculate_subscription_total_cost - - total_cost, details = await calculate_subscription_total_cost( - db, - selection.period.days, - selection.traffic_value, - server_ids, - selection.devices, - user=user, - ) - return total_cost, details - def build_preview_payload( self, context: PurchaseOptionsContext, diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index e3a997e4..40709174 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -10,12 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.server_squad import get_all_server_squads from app.database.crud.user import get_user_by_id -from app.database.models import PromoGroup, Subscription, SubscriptionStatus, User +from app.database.models import Subscription, SubscriptionStatus, User from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus -from app.utils.pricing_utils import ( - calculate_months_from_days, - resolve_discount_percent, -) from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) @@ -773,120 +769,6 @@ class SubscriptionService: default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices - async def calculate_subscription_price_with_months( - self, - period_days: int, - traffic_gb: int, - server_squad_ids: list[int], - devices: int, - db: AsyncSession, - *, - user: User | None = None, - promo_group: PromoGroup | None = None, - ) -> tuple[int, list[int]]: - from app.config import PERIOD_PRICES - from app.database.crud.server_squad import get_server_squad_by_id - - if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT: - raise ValueError(f'Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}') - - months_in_period = calculate_months_from_days(period_days) - - base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = resolve_discount_percent( - user, - promo_group, - 'period', - period_days=period_days, - ) - base_discount_total = base_price_original * period_discount_percent // 100 - base_price = base_price_original - base_discount_total - - promo_group = promo_group or (user.get_primary_promo_group() if user else None) - - traffic_price_per_month = settings.get_traffic_price(traffic_gb) - traffic_discount_percent = resolve_discount_percent( - user, - promo_group, - 'traffic', - period_days=period_days, - ) - traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 - discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month - total_traffic_price = discounted_traffic_per_month * months_in_period - - server_prices = [] - total_servers_price = 0 - servers_discount_percent = resolve_discount_percent( - user, - promo_group, - 'servers', - period_days=period_days, - ) - - for server_id in server_squad_ids: - server = await get_server_squad_by_id(db, server_id) - if server and server.is_available and not server.is_full: - server_price_per_month = server.price_kopeks - server_discount_per_month = server_price_per_month * servers_discount_percent // 100 - discounted_server_per_month = server_price_per_month - server_discount_per_month - server_price_total = discounted_server_per_month * months_in_period - server_prices.append(server_price_total) - total_servers_price += server_price_total - log_message = f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {months_in_period} мес = {server_price_total / 100}₽' - if server_discount_per_month > 0: - log_message += ( - f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period / 100}₽)' - ) - logger.debug(log_message) - else: - server_prices.append(0) - logger.warning('Сервер ID недоступен', server_id=server_id) - - additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = resolve_discount_percent( - user, - promo_group, - 'devices', - period_days=period_days, - ) - devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 - discounted_devices_per_month = devices_price_per_month - devices_discount_per_month - total_devices_price = discounted_devices_per_month * months_in_period - - total_price = base_price + total_traffic_price + total_servers_price + total_devices_price - - logger.debug( - 'Расчет стоимости новой подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period - ) - base_log = f' Период {period_days} дней: {base_price_original / 100}₽' - if base_discount_total > 0: - base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' - logger.debug(base_log) - if total_traffic_price > 0: - message = f' Трафик {traffic_gb} ГБ: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}₽' - if traffic_discount_per_month > 0: - message += ( - f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' - ) - logger.debug(message) - if total_servers_price > 0: - message = f' Серверы ({len(server_squad_ids)}): {total_servers_price / 100}₽' - if servers_discount_percent > 0: - message += f' (скидка {servers_discount_percent}% применяется ко всем серверам)' - logger.debug(message) - if total_devices_price > 0: - message = f' Устройства ({additional_devices}): {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}₽' - if devices_discount_per_month > 0: - message += ( - f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' - ) - logger.debug(message) - logger.debug('ИТОГО: ₽', total_price=total_price / 100) - - return total_price, server_prices - def _gb_to_bytes(self, gb: int | None) -> int: if not gb: # None or 0 return 0 diff --git a/app/services/yookassa_service.py b/app/services/yookassa_service.py index 3b47f63f..5d44d3be 100644 --- a/app/services/yookassa_service.py +++ b/app/services/yookassa_service.py @@ -133,9 +133,10 @@ class YooKassaService: ) loop = asyncio.get_running_loop() - response = await loop.run_in_executor( - None, lambda: YooKassaPayment.create(payment_request, idempotence_key) - ) + async with asyncio.timeout(30): + response = await loop.run_in_executor( + None, lambda: YooKassaPayment.create(payment_request, idempotence_key) + ) logger.info( 'Ответ YooKassa Payment.create: ID=, Status=, Paid', @@ -241,9 +242,10 @@ class YooKassaService: ) loop = asyncio.get_running_loop() - response = await loop.run_in_executor( - None, lambda: YooKassaPayment.create(payment_request, idempotence_key) - ) + async with asyncio.timeout(30): + response = await loop.run_in_executor( + None, lambda: YooKassaPayment.create(payment_request, idempotence_key) + ) logger.info( 'Ответ YooKassa Payment.create (СБП, redirect): ID=, Status=, Paid', @@ -288,7 +290,10 @@ class YooKassaService: logger.info('Получение информации о платеже YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa) loop = asyncio.get_running_loop() - payment_info_yk = await loop.run_in_executor(None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa)) + async with asyncio.timeout(30): + payment_info_yk = await loop.run_in_executor( + None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa) + ) if payment_info_yk: logger.info( @@ -415,9 +420,10 @@ class YooKassaService: ) loop = asyncio.get_running_loop() - response = await loop.run_in_executor( - None, lambda: YooKassaPayment.create(payment_request, idempotence_key) - ) + async with asyncio.timeout(30): + response = await loop.run_in_executor( + None, lambda: YooKassaPayment.create(payment_request, idempotence_key) + ) logger.info( 'Ответ YooKassa автоплатёж', diff --git a/app/utils/price_display.py b/app/utils/price_display.py index efc39178..71abd9b2 100644 --- a/app/utils/price_display.py +++ b/app/utils/price_display.py @@ -76,12 +76,10 @@ def calculate_user_price(user: User | None, base_price: int, period_days: int, c promo_offer_discount = get_user_active_promo_discount_percent(user) - # Apply both discounts sequentially (same as cabinet) - final_price = base_price - if group_discount > 0: - final_price = final_price - (final_price * group_discount) // 100 - if promo_offer_discount > 0: - final_price = final_price - (final_price * promo_offer_discount) // 100 + # Apply both discounts sequentially via PricingEngine + from app.services.pricing_engine import PricingEngine + + final_price, _, _ = PricingEngine.apply_stacked_discounts(base_price, group_discount, promo_offer_discount) # Effective combined discount percent if final_price < base_price: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 3fbbf283..9282e5da 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -10,6 +10,7 @@ from app.config import settings if TYPE_CHECKING: # pragma: no cover from app.database.models import PromoGroup, User + from app.services.pricing_engine import RenewalPricing logger = structlog.get_logger(__name__) @@ -84,32 +85,45 @@ async def compute_simple_subscription_price( user: Optional['User'] = None, resolved_squad_uuids: Sequence[str] | None = None, ) -> tuple[int, dict[str, Any]]: - """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" + """Вычисляет стоимость простой подписки с учетом всех доплат и скидок. + + Delegates to PricingEngine.calculate_classic_new_subscription_price() + and converts the RenewalPricing result to the legacy breakdown dict + expected by callers. + """ + from app.services.pricing_engine import PricingEngine period_days = int(params.get('period_days', 30) or 30) - attr_name = f'PRICE_{period_days}_DAYS' - base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) traffic_limit_raw = params.get('traffic_limit_gb') try: - traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None + traffic_limit_gb = int(traffic_limit_raw) if traffic_limit_raw is not None else 0 except (TypeError, ValueError): # pragma: no cover - defensive conversion - traffic_limit = None - - if traffic_limit is None or traffic_limit <= 0: - # Default simple subscriptions already include unlimited traffic. - traffic_price_original = 0 - else: - traffic_price_original = settings.get_traffic_price(traffic_limit) + traffic_limit_gb = 0 + # Treat None / non-positive as unlimited (0 GB → price = 0 in PricingEngine) + traffic_limit_gb = max(traffic_limit_gb, 0) device_limit_raw = params.get('device_limit', settings.DEFAULT_DEVICE_LIMIT) try: device_limit = int(device_limit_raw) except (TypeError, ValueError): # pragma: no cover - defensive conversion device_limit = settings.DEFAULT_DEVICE_LIMIT - additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) - devices_price_original = additional_devices * settings.PRICE_PER_DEVICE + # --- Resolve squad UUIDs from explicit arg or params --- + resolved_uuids: list[str] = [] + if resolved_squad_uuids: + resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid]) + else: + raw_squad = params.get('squad_uuid') + if isinstance(raw_squad, (list, tuple, set)): + resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid]) + elif raw_squad: + resolved_uuids.append(str(raw_squad)) + + # --- Resolve promo_group from params (backward compat) --- + # Callers may pass promo_group or promo_group_id via params dict. + # PricingEngine resolves promo_group from user internally, so we only + # need this for the applied_promo_group_id field in the breakdown. promo_group: PromoGroup | None = params.get('promo_group') if promo_group is None: @@ -122,131 +136,103 @@ async def compute_simple_subscription_price( if promo_group is None and user is not None: promo_group = user.get_primary_promo_group() - period_discount_percent = resolve_discount_percent( - user, - promo_group, - 'period', - period_days=period_days, - ) - base_discount = base_price_original * period_discount_percent // 100 - - traffic_discount_percent = resolve_discount_percent( - user, - promo_group, - 'traffic', - period_days=period_days, - ) - traffic_discount = traffic_price_original * traffic_discount_percent // 100 - - devices_discount_percent = resolve_discount_percent( - user, - promo_group, - 'devices', - period_days=period_days, - ) - devices_discount = devices_price_original * devices_discount_percent // 100 - - servers_discount_percent = resolve_discount_percent( - user, - promo_group, - 'servers', - period_days=period_days, + # --- Delegate to PricingEngine --- + engine = PricingEngine() + pricing = await engine.calculate_classic_new_subscription_price( + db, + period_days, + resolved_uuids, + traffic_limit_gb, + device_limit, + user=user, ) - resolved_uuids: list[str] = [] - if resolved_squad_uuids: - resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid]) - else: - raw_squad = params.get('squad_uuid') - if isinstance(raw_squad, (list, tuple, set)): - resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid]) - elif raw_squad: - resolved_uuids.append(str(raw_squad)) + # --- Build legacy breakdown dict from RenewalPricing + ClassicBreakdown --- + breakdown = _build_simple_subscription_breakdown(pricing, resolved_uuids, promo_group) - from app.database.crud.server_squad import get_server_squads_by_uuids + return pricing.final_total, breakdown - server_breakdown: list[dict[str, Any]] = [] - servers_price_original = 0 - servers_discount_total = 0 - if resolved_uuids: - servers = await get_server_squads_by_uuids(db, resolved_uuids) - server_map = {s.squad_uuid: s for s in servers} - else: - server_map = {} +def _build_simple_subscription_breakdown( + pricing: 'RenewalPricing', + resolved_uuids: list[str], + promo_group: Optional['PromoGroup'], +) -> dict[str, Any]: + """Convert PricingEngine's RenewalPricing to the legacy breakdown dict. - for squad_uuid in resolved_uuids: - server = server_map.get(squad_uuid) - if not server: - logger.warning('SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad', squad_uuid=squad_uuid) - server_breakdown.append( - { - 'uuid': squad_uuid, - 'name': None, - 'available': False, - 'original_price': 0, - 'discount': 0, - 'final_price': 0, - } - ) - continue + Preserves all keys that callers depend on: + base_price, base_discount, traffic_price, traffic_discount, + devices_price, devices_discount, servers_price, servers_discount, + servers_final, server_details, total_before_discount, total_discount, + resolved_squad_uuids, applied_promo_group_id, *_discount_percent. + """ + from app.services.pricing_engine import PricingEngine - if not server.is_available or server.is_full: - logger.warning( - 'SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad= | available= | full', - squad_uuid=squad_uuid, - is_available=server.is_available, - is_full=server.is_full, - ) - server_breakdown.append( - { - 'uuid': squad_uuid, - 'name': server.display_name, - 'available': False, - 'original_price': 0, - 'discount': 0, - 'final_price': 0, - } - ) - continue + bd = pricing.breakdown + months = bd.get('months_in_period', 1) or 1 + group_pct: dict[str, int] = bd.get('group_discount_pct', {}) - original_price = server.price_kopeks - discount_value = original_price * servers_discount_percent // 100 - final_price = original_price - discount_value + # Original (pre-discount) prices from ClassicBreakdown + base_price_original: int = bd.get('base_price_original', 0) + traffic_price_per_month: int = bd.get('traffic_price_per_month', 0) + servers_price_per_month: int = bd.get('servers_price_per_month', 0) + devices_price_per_month: int = bd.get('devices_price_per_month', 0) - servers_price_original += original_price - servers_discount_total += discount_value + # Per-category discount percents + period_discount_percent: int = group_pct.get('period', 0) + traffic_discount_percent: int = group_pct.get('traffic', 0) + servers_discount_percent: int = group_pct.get('servers', 0) + devices_discount_percent: int = group_pct.get('devices', 0) - server_breakdown.append( + # Total original prices (traffic/servers/devices are monthly × months) + traffic_price_total = traffic_price_per_month * months + servers_price_total = servers_price_per_month * months + devices_price_total = devices_price_per_month * months + + # Discount values + base_discount = base_price_original - pricing.base_price + traffic_discount = traffic_price_total - pricing.traffic_price + servers_discount = servers_price_total - pricing.servers_price + devices_discount = devices_price_total - pricing.devices_price + + total_before_discount = base_price_original + traffic_price_total + servers_price_total + devices_price_total + # Group discounts only (promo_offer_discount is separate and already + # reflected in final_total but NOT in per-category values above). + total_discount = base_discount + traffic_discount + servers_discount + devices_discount + + # Build server_details in legacy format from PricingEngine's server list + server_details: list[dict[str, Any]] = [] + servers_final = 0 + for srv in bd.get('servers', []): + original_price = srv.get('price', 0) + status = srv.get('status', 'available') + is_available = status == 'available' + final_price = PricingEngine.apply_discount(original_price, servers_discount_percent) if is_available else 0 + discount_value = original_price - final_price if is_available else 0 + servers_final += final_price + + server_details.append( { - 'uuid': squad_uuid, - 'name': server.display_name, - 'available': True, - 'original_price': original_price, + 'uuid': srv.get('uuid', ''), + 'name': None, # PricingEngine._calculate_servers_price doesn't return display_name + 'available': is_available, + 'original_price': original_price if is_available else 0, 'discount': discount_value, 'final_price': final_price, } ) - total_before_discount = ( - base_price_original + traffic_price_original + devices_price_original + servers_price_original - ) - - total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total - - total_price = max(0, total_before_discount - total_discount) - - breakdown = { + return { 'base_price': base_price_original, 'base_discount': base_discount, - 'traffic_price': traffic_price_original, + 'traffic_price': traffic_price_total, 'traffic_discount': traffic_discount, - 'devices_price': devices_price_original, + 'devices_price': devices_price_total, 'devices_discount': devices_discount, - 'servers_price': servers_price_original, - 'servers_discount': servers_discount_total, - 'servers_final': sum(item['final_price'] for item in server_breakdown), - 'server_details': server_breakdown, + 'servers_price': servers_price_total, + 'servers_discount': servers_discount, + 'servers_final': servers_final, + 'server_details': server_details, 'total_before_discount': total_before_discount, 'total_discount': total_discount, 'resolved_squad_uuids': resolved_uuids, @@ -257,8 +243,6 @@ async def compute_simple_subscription_price( 'servers_discount_percent': servers_discount_percent, } - return total_price, breakdown - def _pluralize_days_ru(n: int) -> str: """Склонение слова 'день' по числу: 1 день, 2 дня, 5 дней.""" diff --git a/app/utils/promo_offer.py b/app/utils/promo_offer.py index ada2292b..a7aa482c 100644 --- a/app/utils/promo_offer.py +++ b/app/utils/promo_offer.py @@ -35,6 +35,44 @@ def get_user_active_promo_discount_percent(user: User | None) -> int: return max(0, min(100, percent)) +async def consume_user_promo_offer(db: AsyncSession, user_id: int) -> bool: + """Consume the user's one-shot promo-offer discount (zeroes out the fields). + + Used by external payment fulfillment handlers (Stars, YooKassa) + where subtract_user_balance (which normally consumes the offer) is not called. + Returns True if an offer was actually consumed. + """ + from app.database.crud.promo_offer_log import log_promo_offer_action + + result = await db.execute(select(User).where(User.id == user_id).with_for_update()) + user = result.scalar_one_or_none() + if not user: + return False + + current_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) + if current_percent <= 0: + return False + + offer_id = getattr(user, 'promo_offer_discount_source', None) + user.promo_offer_discount_percent = 0 + user.promo_offer_discount_source = None + user.promo_offer_discount_expires_at = None + await db.flush() + + try: + await log_promo_offer_action( + db, + user_id=user_id, + offer_id=offer_id, + action='consumed_external_payment', + discount_percent=current_percent, + ) + except Exception: + pass # Non-critical logging + + return True + + def _format_time_left(seconds_left: int, language: str) -> str: total_minutes = max(1, math.ceil(seconds_left / 60)) days, remainder_minutes = divmod(total_minutes, 60 * 24) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9d3de893..5ba65703 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -58,6 +58,7 @@ from app.database.models import ( from app.services.faq_service import FaqService from app.services.maintenance_service import maintenance_service from app.services.payment_service import PaymentService, get_wata_payment_by_link_id +from app.services.pricing_engine import PricingEngine from app.services.privacy_policy_service import PrivacyPolicyService from app.services.promo_offer_service import promo_offer_service from app.services.promocode_service import PromoCodeService @@ -210,12 +211,11 @@ _CRYPTOBOT_FALLBACK_RATE = 95.0 def _get_tariff_monthly_price(tariff) -> int: - """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" + """Получает месячную цену тарифа (30 дней) для отображения в UI.""" price = tariff.get_price_for_period(30) if price is not None: return price - # Fallback: пропорционально пересчитываем из первого доступного периода periods = tariff.get_available_periods() if periods: first_period = periods[0] @@ -3341,6 +3341,15 @@ async def get_subscription_details( is_daily_paused = getattr(subscription, 'is_daily_paused', False) daily_tariff_name = tariff.name daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + # Применяем скидку промогруппы + promo-offer для отображения + if daily_price_kopeks > 0: + _promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None + _group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0 + _offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + if _group_pct > 0 or _offer_pct > 0: + daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts( + daily_price_kopeks, _group_pct, _offer_pct + ) daily_price_label = settings.format_price(daily_price_kopeks) + '/день' if daily_price_kopeks > 0 else None # Оставшееся время подписки (показываем даже при паузе) if subscription.end_date: @@ -3510,21 +3519,10 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 - # Получаем скидку на трафик из промогруппы - traffic_discount_percent = 0 - promo_group = ( - ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - if user - else None - ) - if promo_group: - apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) - if apply_to_addons: - traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + # Скидка на трафик через PricingEngine + from app.services.pricing_engine import PricingEngine, pricing_engine + + promo_group = PricingEngine.resolve_promo_group(user) if user else None # Лимит докупки трафика max_topup_traffic_gb = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 @@ -3547,9 +3545,12 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - continue base_price = packages[gb] - # Применяем скидку - if traffic_discount_percent > 0: - discounted_price = int(base_price * (100 - traffic_discount_percent) / 100) + # Применяем скидку через PricingEngine + discounted_price, _discount_val, traffic_discount_pct = pricing_engine.calculate_traffic_discount( + base_price, + user, + ) + if traffic_discount_pct > 0: traffic_topup_packages.append( MiniAppTrafficTopupPackage( gb=gb, @@ -3557,7 +3558,7 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - price_label=settings.format_price(discounted_price), original_price_kopeks=base_price, original_price_label=settings.format_price(base_price), - discount_percent=traffic_discount_percent, + discount_percent=traffic_discount_pct, ) ) else: @@ -3577,15 +3578,9 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - # Применяем скидку промогруппы для 30-дневного периода if promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - if int(k) == 30: - discount = max(0, min(100, int(v))) - monthly_price = int(monthly_price * (100 - discount) / 100) - break - except (TypeError, ValueError): - pass + discount = promo_group.get_discount_percent('period', 30) + if discount > 0: + monthly_price = PricingEngine.apply_discount(monthly_price, discount) return MiniAppCurrentTariff( id=tariff.id, @@ -4613,32 +4608,6 @@ async def _prepare_subscription_renewal_options( return periods, pricing_map, recommended_option[0].id -def _get_addon_discount_percent_for_user( - user: User | None, - category: str, - period_days_hint: int | None = None, -) -> int: - if user is None: - return 0 - - promo_group = getattr(user, 'promo_group', None) - if promo_group is None: - return 0 - - if not getattr(promo_group, 'apply_discounts_to_addons', True): - return 0 - - try: - percent = user.get_promo_discount(category, period_days_hint) - except AttributeError: - return 0 - - try: - return int(percent) - except (TypeError, ValueError): - return 0 - - def _get_period_hint_from_subscription( subscription: Subscription | None, ) -> int | None: @@ -4916,21 +4885,9 @@ async def _build_subscription_settings( ) -> MiniAppSubscriptionSettings: period_hint_days = _get_period_hint_from_subscription(subscription) months_remaining = max(1, math.ceil((period_hint_days or 0) / 30)) - servers_discount = _get_addon_discount_percent_for_user( - user, - 'servers', - period_hint_days, - ) - traffic_discount = _get_addon_discount_percent_for_user( - user, - 'traffic', - period_hint_days, - ) - devices_discount = _get_addon_discount_percent_for_user( - user, - 'devices', - period_hint_days, - ) + servers_discount = PricingEngine.get_addon_discount_percent(user, 'servers', period_hint_days) + traffic_discount = PricingEngine.get_addon_discount_percent(user, 'traffic', period_hint_days) + devices_discount = PricingEngine.get_addon_discount_percent(user, 'devices', period_hint_days) current_servers, server_options, _ = await _prepare_server_catalog( db, @@ -5193,6 +5150,10 @@ async def submit_subscription_renewal_endpoint( detail={'code': 'period_unavailable', 'message': 'Selected renewal period is not available'}, ) + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + try: pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period_days, user=user) except HTTPException: @@ -5450,6 +5411,10 @@ async def subscription_purchase_endpoint( db: AsyncSession = Depends(get_db_session), ) -> MiniAppSubscriptionPurchaseResponse: user = await _authorize_miniapp_user(payload.init_data, db) + + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) context = await purchase_service.build_options(db, user) selection_payload = _merge_purchase_selection_from_request(payload) @@ -5593,12 +5558,13 @@ async def update_subscription_servers_endpoint( message='No changes', ) + # Lock user BEFORE price computation to prevent TOCTOU on promo discount + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + period_hint_days = _get_period_hint_from_subscription(subscription) - servers_discount = _get_addon_discount_percent_for_user( - user, - 'servers', - period_hint_days, - ) + servers_discount = PricingEngine.get_addon_discount_percent(user, 'servers', period_hint_days) _, _, catalog = await _prepare_server_catalog( db, @@ -5816,23 +5782,18 @@ async def update_subscription_traffic_endpoint( days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) period_hint_days = days_remaining - traffic_discount = _get_addon_discount_percent_for_user( - user, - 'traffic', - period_hint_days, - ) + + # Lock user BEFORE discount computation to prevent TOCTOU on promo group + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + traffic_discount = PricingEngine.get_addon_discount_percent(user, 'traffic', period_hint_days) old_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) new_price_per_month = settings.get_traffic_price(new_traffic) - discounted_old_per_month, _ = apply_percentage_discount( - old_price_per_month, - traffic_discount, - ) - discounted_new_per_month, _ = apply_percentage_discount( - new_price_per_month, - traffic_discount, - ) + discounted_old_per_month = PricingEngine.apply_discount(old_price_per_month, traffic_discount) + discounted_new_per_month = PricingEngine.apply_discount(new_price_per_month, traffic_discount) price_difference_per_month = discounted_new_per_month - discounted_old_per_month total_price_difference = 0 @@ -5998,16 +5959,15 @@ async def update_subscription_devices_endpoint( price_per_month = chargeable_diff * tariff_device_price days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) period_hint_days = days_remaining - devices_discount = _get_addon_discount_percent_for_user( - user, - 'devices', - period_hint_days, - ) - discounted_per_month, _ = apply_percentage_discount( - price_per_month, - devices_discount, - ) + # Lock user BEFORE price computation to prevent TOCTOU on promo discount + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + + devices_discount = PricingEngine.get_addon_discount_percent(user, 'devices', period_hint_days) + + discounted_per_month = PricingEngine.apply_discount(price_per_month, devices_discount) price_to_charge, charged_days = calculate_prorated_price( discounted_per_month, subscription.end_date, @@ -6150,27 +6110,22 @@ async def _build_tariff_model( ) ) - # Получаем скидки промогруппы по периодам - period_discounts = {} - if promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - period_discounts[int(k)] = max(0, min(100, int(v))) - except (TypeError, ValueError): - pass - periods: list[MiniAppTariffPeriod] = [] if tariff.period_prices: for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): period_days = int(period_str) - # Применяем скидку промогруппы - discount_percent = period_discounts.get(period_days, 0) - if discount_percent > 0: - price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) + # Применяем скидку промогруппы + promo-offer (stacked) + group_pct = promo_group.get_discount_percent('period', period_days) if promo_group else 0 + offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + if group_pct > 0 or offer_pct > 0: + price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(original_price_kopeks, group_pct, offer_pct) + # Комбинированный процент для отображения + remaining = (100 - group_pct) * (100 - offer_pct) + discount_percent = 100 - remaining // 100 else: price_kopeks = original_price_kopeks + discount_percent = 0 months = max(1, period_days // 30) per_month = price_kopeks // months if months > 0 else price_kopeks @@ -6197,31 +6152,31 @@ async def _build_tariff_model( is_switch_free = None if current_tariff and current_tariff.id != tariff.id: - current_is_daily = getattr(current_tariff, 'is_daily', False) - new_is_daily = getattr(tariff, 'is_daily', False) - - if current_is_daily and not new_is_daily: - # Переключение С суточного НА периодный - полная оплата нового тарифа - # Берём минимальную цену из периодов нового тарифа - min_period_price = None - if periods: - min_period_price = min(p.price_kopeks for p in periods) - if min_period_price and min_period_price > 0: - switch_cost_kopeks = min_period_price - switch_cost_label = settings.format_price(min_period_price) - is_upgrade = True # Показываем как платный переход - is_switch_free = False - elif remaining_days > 0: - # Обычный расчёт для периодных тарифов - cost, upgrade = _calculate_tariff_switch_cost(current_tariff, tariff, remaining_days, promo_group, user) - switch_cost_kopeks = cost - switch_cost_label = settings.format_price(cost) if cost > 0 else None - is_upgrade = upgrade - is_switch_free = cost == 0 + # PricingEngine обрабатывает все случаи: periodic↔periodic, daily→periodic, periodic→daily + result = _calculate_tariff_switch(current_tariff, tariff, remaining_days, user=user) + switch_cost_kopeks = result.upgrade_cost + switch_cost_label = settings.format_price(result.upgrade_cost) if result.upgrade_cost > 0 else None + is_upgrade = result.is_upgrade + is_switch_free = result.upgrade_cost == 0 # Суточный тариф is_daily = getattr(tariff, 'is_daily', False) - daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + raw_daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_kopeks = raw_daily_price_kopeks + + # Применяем скидку промогруппы + promo-offer для суточного тарифа (period_hint=1) + if is_daily and daily_price_kopeks > 0: + daily_group_pct = ( + promo_group.get_discount_percent('period', 1) + if promo_group and hasattr(promo_group, 'get_discount_percent') + else 0 + ) + daily_offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + if daily_group_pct > 0 or daily_offer_pct > 0: + daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts( + raw_daily_price_kopeks, daily_group_pct, daily_offer_pct + ) + daily_price_label = ( settings.format_price(daily_price_kopeks) + '/день' if is_daily and daily_price_kopeks > 0 else None ) @@ -6250,26 +6205,31 @@ async def _build_tariff_model( ) -async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None) -> MiniAppCurrentTariff: +async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None, user=None) -> MiniAppCurrentTariff: """Создаёт модель текущего тарифа.""" servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 monthly_price = _get_tariff_monthly_price(tariff) - # Применяем скидку промогруппы для 30-дневного периода - if promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - if int(k) == 30: - discount = max(0, min(100, int(v))) - monthly_price = int(monthly_price * (100 - discount) / 100) - break - except (TypeError, ValueError): - pass + # Применяем скидку промогруппы + promo-offer для 30-дневного периода + group_pct = promo_group.get_discount_percent('period', 30) if promo_group else 0 + offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + if group_pct > 0 or offer_pct > 0: + monthly_price, _, _ = PricingEngine.apply_stacked_discounts(monthly_price, group_pct, offer_pct) # Суточный тариф is_daily = getattr(tariff, 'is_daily', False) - daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + raw_daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_kopeks = raw_daily_price_kopeks + + # Применяем скидку промогруппы + promo-offer для суточного тарифа (period_hint=1) + if is_daily and daily_price_kopeks > 0: + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_offer_pct = get_user_active_promo_discount_percent(user) if user else 0 + if daily_group_pct > 0 or daily_offer_pct > 0: + daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts( + raw_daily_price_kopeks, daily_group_pct, daily_offer_pct + ) + daily_price_label = ( settings.format_price(daily_price_kopeks) + '/день' if is_daily and daily_price_kopeks > 0 else None ) @@ -6310,11 +6270,9 @@ async def get_tariffs_endpoint( ) # Получаем промогруппу пользователя (с приоритетом) - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) promo_group_id = promo_group.id if promo_group else None # Получаем тарифы, доступные пользователю @@ -6335,7 +6293,7 @@ async def get_tariffs_endpoint( if current_tariff_id: current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff: - current_tariff_model = await _build_current_tariff_model(db, current_tariff, promo_group) + current_tariff_model = await _build_current_tariff_model(db, current_tariff, promo_group, user=user) # Формируем список тарифов tariff_models: list[MiniAppTariff] = [] @@ -6398,12 +6356,14 @@ async def purchase_tariff_endpoint( }, ) + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + from app.services.pricing_engine import PricingEngine, pricing_engine + + user = await lock_user_for_pricing(db, user.id) + # Проверяем доступность тарифа для пользователя - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) + promo_group = PricingEngine.resolve_promo_group(user) promo_group_id = promo_group.id if promo_group else None if not tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( @@ -6414,67 +6374,28 @@ async def purchase_tariff_endpoint( }, ) - # Получаем цену + # For daily tariffs, force period_days=1 (protect against client manipulation) is_daily_tariff = getattr(tariff, 'is_daily', False) if is_daily_tariff: - # Для суточного тарифа принудительно 1 день (защита от манипуляций с period_days) payload.period_days = 1 - # Для суточного тарифа берём daily_price_kopeks (первый день) - base_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) - if base_price_kopeks <= 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - 'code': 'invalid_daily_price', - 'message': 'Daily tariff has no price configured', - }, - ) - else: - # Для обычного тарифа получаем цену за выбранный период - base_price_kopeks = tariff.get_price_for_period(payload.period_days) - if base_price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - 'code': 'invalid_period', - 'message': 'Invalid period for this tariff', - }, - ) - # Add extra device cost if user renews same tariff with purchased extra devices + # Calculate price via PricingEngine (single source of truth) subscription = getattr(user, 'subscription', None) - if not is_daily_tariff and subscription and subscription.tariff_id == tariff.id: - device_price_per_unit = ( - tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE - ) - extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0)) - base_price_kopeks += extra_devices * device_price_per_unit + device_limit = None + if subscription and subscription.tariff_id == tariff.id: + device_limit = subscription.device_limit - # Применяем скидку промогруппы (только для обычных тарифов, не для суточных) - price_kopeks = base_price_kopeks - discount_percent = 0 - if not is_daily_tariff and promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - if int(k) == payload.period_days: - discount_percent = max(0, min(100, int(v))) - break - except (TypeError, ValueError): - pass - if discount_percent > 0: - from app.services.pricing_engine import PricingEngine - - price_kopeks = PricingEngine.apply_discount(base_price_kopeks, discount_percent) - - # Apply personal promo_offer discount on top of group discount - consume_promo_offer = False - if not is_daily_tariff: - promo_offer_pct = get_user_active_promo_discount_percent(user) - if promo_offer_pct > 0: - offer_discount_value = price_kopeks * promo_offer_pct // 100 - price_kopeks = price_kopeks - offer_discount_value - consume_promo_offer = True + result = await pricing_engine.calculate_tariff_purchase_price( + tariff, + payload.period_days, + device_limit=device_limit, + user=user, + ) + price_kopeks = result.final_total + consume_promo_offer = result.promo_offer_discount > 0 + bd = result.breakdown + group_pcts = bd.get('group_discount_pct', {}) + discount_percent = group_pcts.get('period', 0) # Проверяем баланс if user.balance_kopeks < price_kopeks: @@ -6615,70 +6536,28 @@ async def purchase_tariff_endpoint( ) -def _get_user_period_discount(user, period_days: int) -> int: - """Получает скидку пользователя на период (унифицировано с ботом).""" - promo_group = getattr(user, 'promo_group', None) if user else None - - if promo_group: - discount = promo_group.get_discount_percent('period', period_days) - if discount > 0: - return discount - - personal_discount = get_user_active_promo_discount_percent(user) if user else 0 - return personal_discount - - -def _apply_promo_discount(price: int, discount_percent: int) -> int: - """Применяет скидку к цене (через PricingEngine для единообразия).""" - from app.services.pricing_engine import PricingEngine - - return PricingEngine.apply_discount(price, discount_percent) - - -def _calculate_tariff_switch_cost( +def _calculate_tariff_switch( current_tariff, new_tariff, remaining_days: int, - promo_group=None, user=None, -) -> tuple[int, bool]: - """ - Рассчитывает стоимость переключения тарифа. - Логика унифицирована с ботом (tariff_purchase.py). - - Формула: (new_monthly - current_monthly) * remaining_days / 30 - Скидка применяется к обоим тарифам одинаково. +): + """Рассчитывает стоимость переключения тарифа. + Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost(). + PricingEngine автоматически определяет тип переключения + (periodic↔periodic, daily→periodic, periodic→daily). Returns: - (cost_kopeks, is_upgrade) - стоимость доплаты и флаг апгрейда + TariffSwitchResult """ - current_monthly = _get_tariff_monthly_price(current_tariff) - new_monthly = _get_tariff_monthly_price(new_tariff) + from app.services.pricing_engine import pricing_engine - discount_percent = _get_user_period_discount(user, 30) if user else 0 - - # Fallback на promo_group.period_discounts если user не передан - if discount_percent == 0 and promo_group: - raw_discounts = getattr(promo_group, 'period_discounts', None) or {} - for k, v in raw_discounts.items(): - try: - if int(k) == 30: - discount_percent = max(0, min(100, int(v))) - break - except (TypeError, ValueError): - pass - - if discount_percent > 0: - current_monthly = _apply_promo_discount(current_monthly, discount_percent) - new_monthly = _apply_promo_discount(new_monthly, discount_percent) - - price_diff = new_monthly - current_monthly - - if price_diff <= 0: - return 0, False - - upgrade_cost = int(price_diff * remaining_days / 30) - return upgrade_cost, True + return pricing_engine.calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days, + user=user, + ) @router.post('/subscription/tariff/switch/preview') @@ -6725,11 +6604,9 @@ async def preview_tariff_switch_endpoint( ) # Проверяем доступность тарифа для пользователя - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) promo_group_id = promo_group.id if promo_group else None if not new_tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( @@ -6743,22 +6620,10 @@ async def preview_tariff_switch_endpoint( delta = subscription.end_date - datetime.now(UTC) remaining_days = max(0, delta.days) - # Рассчитываем стоимость переключения - current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False - new_is_daily = getattr(new_tariff, 'is_daily', False) - - if current_is_daily and not new_is_daily: - # Переключение С суточного НА периодный - полная оплата нового тарифа - # Берём минимальную цену из периодов нового тарифа - min_period_price = 0 - if new_tariff.period_prices: - min_period_price = min(new_tariff.period_prices.values()) - upgrade_cost = min_period_price - is_upgrade = min_period_price > 0 - else: - upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( - current_tariff, new_tariff, remaining_days, promo_group, user - ) + # Рассчитываем стоимость переключения (PricingEngine обрабатывает все случаи: periodic↔periodic, daily↔periodic) + switch_result = _calculate_tariff_switch(current_tariff, new_tariff, remaining_days, user=user) + upgrade_cost = switch_result.upgrade_cost + is_upgrade = switch_result.is_upgrade balance = user.balance_kopeks or 0 has_enough = balance >= upgrade_cost @@ -6836,11 +6701,9 @@ async def switch_tariff_endpoint( ) # Проверяем доступность тарифа - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) promo_group_id = promo_group.id if promo_group else None if not new_tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( @@ -6848,35 +6711,26 @@ async def switch_tariff_endpoint( detail={'code': 'tariff_not_available', 'message': 'Tariff not available'}, ) + # Lock user BEFORE price computation to prevent TOCTOU on promo offer + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + # Рассчитываем оставшиеся дни remaining_days = 0 if subscription.end_date and subscription.end_date > datetime.now(UTC): delta = subscription.end_date - datetime.now(UTC) remaining_days = max(0, delta.days) - # Рассчитываем стоимость + # Рассчитываем стоимость (PricingEngine обрабатывает все случаи) + switch_result = _calculate_tariff_switch(current_tariff, new_tariff, remaining_days, user=user) + upgrade_cost = switch_result.upgrade_cost + new_period_days = switch_result.new_period_days + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False new_is_daily = getattr(new_tariff, 'is_daily', False) switching_from_daily = current_is_daily and not new_is_daily - if switching_from_daily: - # Переключение С суточного НА периодный - полная оплата нового тарифа (минимальный период) - min_period_days = 30 # По умолчанию месяц - min_period_price = 0 - if new_tariff.period_prices: - # Находим минимальный период и его цену - min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) - min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) - upgrade_cost = min_period_price - is_upgrade = min_period_price > 0 - # remaining_days для нового тарифа будет равен min_period_days после покупки - new_period_days = min_period_days - else: - upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( - current_tariff, new_tariff, remaining_days, promo_group, user - ) - new_period_days = 0 # Не меняем дату окончания - # Списываем доплату если апгрейд if upgrade_cost > 0: if user.balance_kopeks < upgrade_cost: @@ -6899,6 +6753,7 @@ async def switch_tariff_endpoint( user, upgrade_cost, description, + consume_promo_offer=switch_result.offer_discount_pct > 0, mark_as_paid_subscription=True, commit=False, ) @@ -7143,20 +6998,18 @@ async def purchase_traffic_topup_endpoint( base_price_kopeks = packages[payload.gb] - # Применяем скидку промогруппы на трафик - traffic_discount_percent = 0 - promo_group = ( - user.get_primary_promo_group() - if hasattr(user, 'get_primary_promo_group') - else getattr(user, 'promo_group', None) - ) - if promo_group: - apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) - if apply_to_addons: - traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + # Lock user BEFORE price computation to prevent TOCTOU on promo discount + from app.database.crud.user import lock_user_for_pricing - if traffic_discount_percent > 0: - base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + user = await lock_user_for_pricing(db, user.id) + + # Применяем скидку промогруппы на трафик через PricingEngine + from app.services.pricing_engine import pricing_engine + + base_price_kopeks, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount( + base_price_kopeks, + user, + ) # Пропорциональный расчет цены с учетом оставшегося времени подписки final_price, days_charged = calculate_prorated_price( @@ -7280,7 +7133,21 @@ async def toggle_daily_subscription_pause_endpoint( new_paused_state = not is_currently_paused subscription.is_daily_paused = new_paused_state - daily_price = getattr(tariff, 'daily_price_kopeks', 0) + raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) + + # Lock user BEFORE price computation to prevent TOCTOU on promo discount + from app.database.crud.user import lock_user_for_pricing + + user = await lock_user_for_pricing(db, user.id) + + # Apply group discount to daily price (consistent with DailySubscriptionService and resume-after-topup) + from app.services.pricing_engine import PricingEngine + + promo_group = PricingEngine.resolve_promo_group(user) + daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0 + daily_price = ( + PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price + ) # Если снимаем с паузы, проверяем баланс и списываем оплату if not new_paused_state: diff --git a/docs/project_structure_reference.md b/docs/project_structure_reference.md index 97db091e..b54b8190 100644 --- a/docs/project_structure_reference.md +++ b/docs/project_structure_reference.md @@ -127,7 +127,7 @@ Функции: нет - `app/database/crud/subscription.py` — Python-модуль Классы: нет - Функции: `_get_discount_percent` + Функции: нет (ранее `_get_discount_percent` — удалена при консолидации в PricingEngine; см. `PricingEngine.resolve_promo_group()` и `PromoGroup.get_discount_percent()`) - `app/database/crud/subscription_conversion.py` — Python-модуль Классы: нет Функции: нет diff --git a/tests/test_pricing_engine.py b/tests/test_pricing_engine.py index 1021e267..aa7f27d5 100644 --- a/tests/test_pricing_engine.py +++ b/tests/test_pricing_engine.py @@ -265,6 +265,7 @@ class TestCalculateRenewalPriceTariffMode: subscription.purchased_traffic_gb = 0 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_offer_discount_percent = 0 user.promo_offer_expires_at = None with ( @@ -293,6 +294,7 @@ class TestCalculateRenewalPriceTariffMode: subscription.purchased_traffic_gb = 0 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_offer_discount_percent = 0 user.promo_offer_expires_at = None with ( @@ -319,6 +321,7 @@ class TestCalculateRenewalPriceTariffMode: subscription.device_limit = 4 # 2 extra devices user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None with ( patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.settings') as ms, @@ -344,13 +347,14 @@ class TestCalculateRenewalPriceTariffMode: promo_group.get_discount_percent.return_value = 10 user = MagicMock() user.promo_group = promo_group + user.get_primary_promo_group.return_value = 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.base_price == 18000 # 20000 discounted by 10% assert result.promo_group_discount == 2000 # After group: 18000, then 5% off 18000 = 900 assert result.promo_offer_discount == 900 @@ -367,9 +371,12 @@ class TestCalculateRenewalPriceTariffMode: subscription.tariff.device_limit = 1 subscription.tariff.device_price_kopeks = None subscription.tariff.id = 1 + subscription.tariff.is_daily = False + subscription.tariff.can_purchase_custom_days.return_value = False subscription.device_limit = 1 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None with ( patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.settings') as ms, @@ -395,6 +402,7 @@ class TestCalculateRenewalPriceTariffMode: sub.device_limit = 2 # less than tariff's 5 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0): result = await engine.calculate_renewal_price(db, sub, 30, user=user) @@ -441,6 +449,7 @@ class TestCalculateRenewalPriceClassicMode: subscription.device_limit = 2 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None user.promo_offer_discount_percent = 0 user.promo_offer_expires_at = None @@ -479,6 +488,7 @@ class TestCalculateRenewalPriceClassicMode: promo_group.get_discount_percent.return_value = 20 user = MagicMock() user.promo_group = promo_group + user.get_primary_promo_group.return_value = promo_group user.promo_group_id = 1 user.promo_offer_discount_percent = 10 user.promo_offer_expires_at = None @@ -511,6 +521,7 @@ class TestCalculateRenewalPriceClassicMode: subscription.device_limit = 1 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None with ( patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), @@ -539,6 +550,7 @@ class TestCalculateRenewalPriceClassicMode: subscription.device_limit = 5 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None with ( patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), @@ -569,6 +581,7 @@ class TestCalculateRenewalPriceClassicMode: subscription.device_limit = 1 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-found') s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-found2') @@ -610,6 +623,7 @@ class TestCalculateRenewalPriceClassicMode: subscription.device_limit = 1 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None with ( patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), @@ -640,6 +654,7 @@ class TestCalculateRenewalPriceClassicMode: subscription.device_limit = 1 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None with ( patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), @@ -669,6 +684,7 @@ class TestCalculateRenewalPriceClassicMode: sub.device_limit = 1 user = MagicMock() user.promo_group = None + user.get_primary_promo_group.return_value = None user.promo_group_id = None server = _make_server(price_kopeks=3000, squad_uuid='uuid-s1') @@ -717,6 +733,7 @@ class TestCalculateRenewalPriceClassicMode: promo_group.get_discount_percent = MagicMock(side_effect=discount_by_category) user.promo_group = promo_group + user.get_primary_promo_group.return_value = promo_group user.promo_group_id = 1 server = _make_server(price_kopeks=6000, squad_uuid='uuid-s1') @@ -898,6 +915,7 @@ class TestOriginalPriceIdentity: promo_group = MagicMock() promo_group.get_discount_percent = MagicMock(return_value=25) user.promo_group = promo_group + user.get_primary_promo_group.return_value = promo_group with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=15): result = await engine.calculate_renewal_price(db, sub, 30, user=user) @@ -922,6 +940,7 @@ class TestOriginalPriceIdentity: promo_group = MagicMock() promo_group.get_discount_percent = MagicMock(return_value=20) user.promo_group = promo_group + user.get_primary_promo_group.return_value = promo_group user.promo_group_id = 1 server = _make_server(price_kopeks=4000, squad_uuid='uuid-s1') @@ -965,6 +984,9 @@ class TestOriginalPriceIdentity: promo_group = MagicMock() promo_group.get_discount_percent = MagicMock(return_value=10) user.promo_group = promo_group + user.get_primary_promo_group.return_value = promo_group + sub.tariff.is_daily = False + sub.tariff.can_purchase_custom_days.return_value = False with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5): result = await engine.calculate_renewal_price(db, sub, 30, user=user) assert result.original_total == 20000 # undiscounted subtotal