refactor: централизация всех расчётов цен в PricingEngine

- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price()
- Мигрирован compute_simple_subscription_price на делегацию в PricingEngine
- Мигрирован handle_custom_confirm на calculate_tariff_purchase_price()
- Мигрированы daily confirm handlers (confirm_daily_tariff_purchase,
  confirm_daily_tariff_switch, confirm_instant_switch daily path)
- Мигрирован gift.py на calculate_tariff_purchase_price()
- Мигрированы FSM cache prices (select_period, select_devices, toggle_country)
- Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix)
- Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic
- Исправлено двойное применение promo-offer в simple_subscription (критический баг)
- Унифицирован daily price display (group+offer) на всех 6 поверхностях
- PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg
- PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount
- Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user)
- Miniapp period_discounts: исправлен доступ через get_discount_percent()
This commit is contained in:
Fringg
2026-03-16 03:10:22 +03:00
parent f80912e444
commit 8d3cd50098
36 changed files with 2422 additions and 2224 deletions
+22 -36
View File
@@ -22,7 +22,6 @@ from app.database.models import (
Tariff, Tariff,
TransactionType, TransactionType,
User, User,
UserPromoGroup,
) )
from app.services.guest_purchase_service import ( from app.services.guest_purchase_service import (
GuestPurchaseError, GuestPurchaseError,
@@ -112,15 +111,17 @@ async def get_gift_config(
price = base_price price = base_price
# Apply promo group discount # Apply promo group discount
from app.services.pricing_engine import PricingEngine
promo_group_discount = 0 promo_group_discount = 0
if promo_group: if promo_group:
promo_group_discount = promo_group.get_discount_percent('period', days) promo_group_discount = promo_group.get_discount_percent('period', days)
if promo_group_discount > 0: 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) # Apply active promo offer discount (stacks on top)
if promo_offer_discount_percent > 0: 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 # Ensure minimum price of 1 kopek after all discounts
price = max(1, price) price = max(1, price)
@@ -249,43 +250,28 @@ async def create_gift_purchase(
detail='Tariff not found or inactive', detail='Tariff not found or inactive',
) )
price_kopeks = tariff.get_price_for_period(body.period_days) # Validate that period has a configured price before locking
if price_kopeks is None: if tariff.get_price_for_period(body.period_days) is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail='Price is not configured for this period', detail='Price is not configured for this period',
) )
# Lock user row to prevent concurrent promo offer double-spend # Lock user BEFORE price computation to prevent TOCTOU on promo offer
locked_result = await db.execute( from app.database.crud.user import lock_user_for_pricing
select(User)
.options( user = await lock_user_for_pricing(db, user.id)
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.promo_group), from app.services.pricing_engine import pricing_engine
)
.where(User.id == user.id) pricing_result = await pricing_engine.calculate_tariff_purchase_price(
.with_for_update() tariff,
.execution_options(populate_existing=True) body.period_days,
device_limit=tariff.device_limit,
user=user,
) )
user = locked_result.scalar_one() price_kopeks = max(1, pricing_result.final_total)
consume_promo = pricing_result.promo_offer_discount > 0
# 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)
# Determine buyer contact info # Determine buyer contact info
if user.email: if user.email:
@@ -420,7 +406,7 @@ async def create_gift_purchase(
) )
# Consume promo offer discount before committing gateway 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_percent = 0
user.promo_offer_discount_source = None user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None user.promo_offer_discount_expires_at = None
@@ -485,7 +471,7 @@ async def create_gift_purchase(
price_kopeks, price_kopeks,
description=f'Gift: {tariff.name} ({body.period_days}d)', description=f'Gift: {tariff.name} ({body.period_days}d)',
create_transaction=False, create_transaction=False,
consume_promo_offer=promo_offer_discount_percent > 0, consume_promo_offer=consume_promo,
) )
if not balance_ok: if not balance_ok:
await db.rollback() await db.rollback()
+3 -1
View File
@@ -342,7 +342,9 @@ async def _load_landing_tariffs(
effective_discount = tariff_override if tariff_override is not None else discount.percent effective_discount = tariff_override if tariff_override is not None else discount.percent
original_price_kopeks = price original_price_kopeks = price
original_price_label = settings.format_price(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( periods.append(
LandingTariffPeriod( LandingTariffPeriod(
+189 -326
View File
@@ -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.services.user_cart_service import user_cart_service
from app.utils.cache import RateLimitCache, cache, cache_key from app.utils.cache import RateLimitCache, cache, cache_key
from app.utils.pricing_utils import format_period_description 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 ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.subscription import ( from ..schemas.subscription import (
@@ -68,29 +67,14 @@ router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription'])
def _get_addon_discount_percent( def _get_addon_discount_percent(
user: User, user: User | None,
category: str, category: str,
period_days: int | None = None, period_days_hint: int | None = None,
) -> int: ) -> 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 return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
"""
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
def _apply_addon_discount( 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( def _subscription_to_response(
subscription: Subscription, subscription: Subscription,
servers: list[ServerInfo] | None = None, servers: list[ServerInfo] | None = None,
tariff_name: str | None = None, tariff_name: str | None = None,
traffic_purchases: list[dict[str, Any]] | None = None, traffic_purchases: list[dict[str, Any]] | None = None,
user: User | None = None,
) -> SubscriptionData: ) -> SubscriptionData:
"""Convert Subscription model to response.""" """Convert Subscription model to response."""
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -200,6 +169,18 @@ def _subscription_to_response(
traffic_reset_mode = None traffic_reset_mode = None
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff: if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None) 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 if not tariff_name: # Only set if not passed as parameter
tariff_name = getattr(subscription.tariff, 'name', None) tariff_name = getattr(subscription.tariff, 'name', None)
traffic_reset_mode = ( 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) return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
@@ -401,6 +384,11 @@ async def renew_subscription(
detail='Selected renewal period is not available', 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 # Unified pricing via PricingEngine
pricing = await pricing_engine.calculate_renewal_price( pricing = await pricing_engine.calculate_renewal_price(
db, db,
@@ -724,6 +712,11 @@ async def purchase_traffic(
subscription.end_date, 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 # Apply discount from promo group using proper method
period_hint_days = days_charged if days_charged > 0 else 30 period_hint_days = days_charged if days_charged > 0 else 30
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days) 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 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 # Apply discount from promo group
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30) discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
total_price = discount_result['discounted'] total_price = discount_result['discounted']
@@ -1363,7 +1361,7 @@ async def activate_trial(
except Exception as e: except Exception as e:
logger.error('Failed to send trial activation notification', error=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) ============ # ============ 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 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 original_price = base_tariff_price + extra_devices_cost
discount_percent = 0
discount_amount = 0 discount_amount = 0
final_price = original_price
if promo_group: if promo_group:
discount_percent = promo_group.get_discount_percent('period', period_days) period_pct = promo_group.get_discount_percent('period', period_days)
if discount_percent > 0: devices_pct = promo_group.get_discount_percent('devices', period_days)
discount_amount = original_price * discount_percent // 100 discounted_base = (
final_price = original_price - discount_amount 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 per_month = final_price // months if months > 0 else final_price
original_per_month = original_price // months if months > 0 else original_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} ГБ' 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) daily_price = getattr(tariff, 'daily_price_kopeks', 0)
original_daily_price = daily_price original_daily_price = daily_price
daily_discount_percent = 0 daily_discount_percent = 0
if promo_group and daily_price > 0: if daily_price > 0:
# For daily tariffs, use period discount with period_days=1 from app.services.pricing_engine import PricingEngine
daily_discount_percent = promo_group.get_discount_percent('period', 1) from app.utils.promo_offer import get_user_active_promo_discount_percent
if daily_discount_percent > 0:
discount_amount = daily_price * daily_discount_percent // 100 daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
daily_price = daily_price - discount_amount 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 # Apply discount to custom price_per_day if applicable
price_per_day = tariff.price_per_day_kopeks 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: 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 custom_days_discount_percent = promo_group.get_discount_percent('period', 30) # Use 30-day rate as base
if custom_days_discount_percent > 0: if custom_days_discount_percent > 0:
discount_amount = price_per_day * custom_days_discount_percent // 100 price_per_day = pricing_engine.apply_discount(price_per_day, custom_days_discount_percent)
price_per_day = price_per_day - discount_amount
# Apply discount to device price if applicable # Apply discount to device price if applicable
device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0 device_price = tariff.device_price_kopeks if tariff.device_price_kopeks is not None else 0
original_device_price = device_price original_device_price = device_price
device_discount_percent = 0 device_discount_percent = 0
if promo_group and device_price > 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: if device_discount_percent > 0:
discount_amount = device_price * device_discount_percent // 100 device_price = pricing_engine.apply_discount(device_price, device_discount_percent)
device_price = device_price - discount_amount
# Показываем реальное количество устройств (с докупленными) для текущего тарифа # Показываем реальное количество устройств (с докупленными) для текущего тарифа
actual_device_limit = tariff.device_limit actual_device_limit = tariff.device_limit
@@ -1703,6 +1716,9 @@ async def submit_purchase(
) )
try: 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) context = await purchase_service.build_options(db, user)
# Convert request to dict for parsing # Convert request to dict for parsing
@@ -1774,7 +1790,7 @@ async def submit_purchase(
return { return {
'success': True, 'success': True,
'message': result['message'], 'message': result['message'],
'subscription': _subscription_to_response(subscription), 'subscription': _subscription_to_response(subscription, user=user),
'was_trial_conversion': result.get('was_trial_conversion', False), 'was_trial_conversion': result.get('was_trial_conversion', False),
} }
@@ -1854,6 +1870,11 @@ async def purchase_tariff(
detail='Tariff not found or inactive', 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 # 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 = 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 promo_group_id = promo_group.id if promo_group else None
@@ -1865,105 +1886,43 @@ async def purchase_tariff(
# Handle daily tariffs specially # Handle daily tariffs specially
is_daily_tariff = getattr(tariff, 'is_daily', False) is_daily_tariff = getattr(tariff, 'is_daily', False)
discount_percent = 0
original_price = 0
if is_daily_tariff: 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 period_days = 1
else: else:
period_days = request.period_days 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 # Determine traffic limit (custom traffic support)
# 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
traffic_limit_gb = tariff.traffic_limit_gb 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(): if request.traffic_gb is not None and tariff.can_purchase_custom_traffic():
# Custom traffic requested custom_traffic_gb = request.traffic_gb
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
traffic_limit_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) existing_subscription = await get_subscription_by_user_id(db, user.id)
extra_devices = 0 device_limit = None
effective_device_limit = tariff.device_limit effective_device_limit = tariff.device_limit
if existing_subscription and existing_subscription.tariff_id == tariff.id: 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)) device_limit = existing_subscription.device_limit
if extra_devices > 0: if (existing_subscription.device_limit or 0) > (tariff.device_limit or 0):
effective_device_limit = existing_subscription.device_limit 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 = ( # Calculate price via PricingEngine (single source of truth)
tariff.device_price_kopeks result = await pricing_engine.calculate_tariff_purchase_price(
if tariff.device_price_kopeks is not None tariff,
else settings.PRICE_PER_DEVICE period_days,
) device_limit=device_limit,
months = calculate_months_from_days(period_days) custom_traffic_gb=custom_traffic_gb,
extra_devices_cost = extra_devices * device_price_per_month * months user=user,
# Применяем скидку промогруппы на устройства )
if promo_group and extra_devices_cost > 0: price_kopeks = result.final_total
devices_discount_pct = promo_group.get_discount_percent('devices', period_days) original_price = result.original_total
if devices_discount_pct > 0: bd = result.breakdown
extra_devices_cost = extra_devices_cost - (extra_devices_cost * devices_discount_pct // 100) group_pcts = bd.get('group_discount_pct', {})
price_kopeks += extra_devices_cost discount_percent = group_pcts.get('period', 0)
promo_offer_discount_percent = bd.get('offer_discount_pct', 0)
# Apply promo offer discount (temporary discount from promo offers) promo_offer_discount_value = result.promo_offer_discount
price_before_promo_offer = price_kopeks price_before_promo_offer = price_kopeks + promo_offer_discount_value
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
# Check balance # Check balance
if user.balance_kopeks < price_kopeks: if user.balance_kopeks < price_kopeks:
@@ -2141,7 +2100,7 @@ async def purchase_tariff(
response = { response = {
'success': True, 'success': True,
'message': f"Тариф '{tariff.name}' успешно активирован", 'message': f"Тариф '{tariff.name}' успешно активирован",
'subscription': _subscription_to_response(subscription), 'subscription': _subscription_to_response(subscription, user=user),
'tariff_id': tariff.id, 'tariff_id': tariff.id,
'tariff_name': tariff.name, 'tariff_name': tariff.name,
'charged_amount': price_kopeks, '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 = int(base_price_per_month * days_left / total_days)
base_price_prorated = max(100, base_price_prorated) # Minimum 1 ruble 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 # Apply discount from promo group
period_hint_days = days_left period_hint_days = days_left
discount_result = _apply_addon_discount(user, 'devices', base_price_prorated, period_hint_days) 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']) await db.refresh(user, ['subscription'])
promo_group_id = user.promo_group_id 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)
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True)
connected_squads = [] connected_squads = []
days_left = 0 days_left = 0
@@ -2989,11 +2952,10 @@ async def get_available_countries(
delta = user.subscription.end_date - datetime.now(UTC) delta = user.subscription.end_date - datetime.now(UTC)
days_left = max(0, delta.days) days_left = max(0, delta.days)
# Get discount from promo group # Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
servers_discount_percent = 0 from app.services.pricing_engine import PricingEngine
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group: servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
servers_discount_percent = promo_group.get_discount_percent('servers', None)
countries = [] countries = []
for server in available_servers: for server in available_servers:
@@ -3076,8 +3038,7 @@ async def update_countries(
current_countries = user.subscription.connected_squads or [] current_countries = user.subscription.connected_squads or []
promo_group_id = user.promo_group_id 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)
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True)
allowed_country_ids = {server.squad_uuid for server in available_servers} allowed_country_ids = {server.squad_uuid for server in available_servers}
# Validate selected countries # Validate selected countries
@@ -3097,15 +3058,19 @@ async def update_countries(
'connected_squads': current_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 # Calculate cost for added servers
total_cost = 0 total_cost = 0
added_names = [] added_names = []
removed_names = [] removed_names = []
servers_discount_percent = 0 from app.services.pricing_engine import PricingEngine
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if promo_group: servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
servers_discount_percent = promo_group.get_discount_percent('servers', None)
added_server_prices = [] added_server_prices = []
@@ -3903,82 +3868,18 @@ async def preview_tariff_switch(
delta = user.subscription.end_date - datetime.now(UTC) delta = user.subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days) remaining_days = max(0, delta.days)
# Calculate switch cost # Calculate switch cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False switch_result = pricing_engine.calculate_tariff_switch_cost(
new_is_daily = getattr(new_tariff, 'is_daily', False) current_tariff,
switching_to_daily = not current_is_daily and new_is_daily new_tariff,
switching_from_daily = current_is_daily and not new_is_daily remaining_days,
user=user,
def get_monthly_price(tariff) -> int: )
"""Get 30-day price from tariff, or calculate from closest period.""" upgrade_cost = switch_result.upgrade_cost
if not tariff or not tariff.period_prices: is_upgrade = switch_result.is_upgrade
return 0 base_upgrade_cost = switch_result.raw_cost
# Try to get 30-day price directly discount_value = switch_result.discount_value
if '30' in tariff.period_prices: period_discount_percent = switch_result.effective_discount_pct
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
balance = user.balance_kopeks or 0 balance = user.balance_kopeks or 0
has_enough = balance >= upgrade_cost has_enough = balance >= upgrade_cost
@@ -4090,88 +3991,41 @@ async def switch_tariff(
detail='Tariff not available', 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 # Calculate remaining days
remaining_days = 0 remaining_days = 0
if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC): if subscription.end_date and subscription.end_date > datetime.now(UTC):
delta = user.subscription.end_date - datetime.now(UTC) delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days) remaining_days = max(0, delta.days)
# Calculate cost # Calculate cost (PricingEngine handles all cases: periodic↔periodic, daily→periodic, periodic→daily)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False 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) 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_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 if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
period_discount_percent = _get_period_discount_percent(user, remaining_days if remaining_days > 0 else 30) raise HTTPException(
base_upgrade_cost = 0 status_code=status.HTTP_400_BAD_REQUEST,
discount_value = 0 detail='Daily tariff has invalid price',
)
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
# Charge if upgrade # Charge if upgrade
if upgrade_cost > 0: if upgrade_cost > 0:
@@ -4202,6 +4056,7 @@ async def switch_tariff(
user, user,
upgrade_cost, upgrade_cost,
description, description,
consume_promo_offer=switch_result.offer_discount_pct > 0,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
commit=False, commit=False,
) )
@@ -4359,7 +4214,7 @@ async def switch_tariff(
'success': True, 'success': True,
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'" 'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
+ (' (devices reset)' if devices_reset else ''), + (' (devices reset)' if devices_reset else ''),
'subscription': _subscription_to_response(subscription), 'subscription': _subscription_to_response(subscription, user=user),
'old_tariff_name': old_tariff_name, 'old_tariff_name': old_tariff_name,
'new_tariff_id': new_tariff.id, 'new_tariff_id': new_tariff.id,
'new_tariff_name': new_tariff.name, 'new_tariff_name': new_tariff.name,
@@ -4426,7 +4281,21 @@ async def toggle_subscription_pause(
new_paused_state = not is_currently_paused new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state 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 resuming, check balance and charge
if not new_paused_state: if not new_paused_state:
@@ -4568,22 +4437,16 @@ async def switch_traffic_package(
# Upgrade - charge difference # Upgrade - charge difference
price_diff = new_price - current_price price_diff = new_price - current_price
# Apply promo discount # Lock user row to prevent TOCTOU on promo-offer state
traffic_discount_percent = 0 from app.database.crud.user import lock_user_for_pricing
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))
)
if traffic_discount_percent > 0: user = await lock_user_for_pricing(db, user.id)
price_diff = int(price_diff * (100 - traffic_discount_percent) / 100)
# Apply promo discount via PricingEngine
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
price_diff,
user,
)
# Prorated calculation # Prorated calculation
final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date) final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date)
+6 -2
View File
@@ -141,8 +141,12 @@ async def get_available_server_squads(
.order_by(ServerSquad.sort_order, ServerSquad.display_name) .order_by(ServerSquad.sort_order, ServerSquad.display_name)
) )
if exclude_trial_only: # НЕ фильтруем по is_trial_eligible — это поле означает "доступен для триала",
query = query.where(ServerSquad.is_trial_eligible.is_(False)) # а НЕ "только для триала". Сквад может быть одновременно триальным и платным.
# Фильтр exclude_trial_only убирал единственный доступный сквад, из-за чего
# пользователи без триала получали пустой connected_squads при покупке.
# Параметр exclude_trial_only сохранён для обратной совместимости, но не используется.
# TODO: если нужна логика "только для триала", добавить отдельное поле is_trial_only
if promo_group_id is not None: if promo_group_id is not None:
query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id) query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id)
+55 -219
View File
@@ -1,6 +1,5 @@
from collections.abc import Iterable from collections.abc import Iterable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Optional
import structlog import structlog
from sqlalchemy import and_, delete, func, select from sqlalchemy import and_, delete, func, select
@@ -11,7 +10,6 @@ from sqlalchemy.orm.exc import StaleDataError
from app.config import settings from app.config import settings
from app.database.crud.notification import clear_notifications from app.database.crud.notification import clear_notifications
from app.database.models import ( from app.database.models import (
PromoGroup,
Subscription, Subscription,
SubscriptionServer, SubscriptionServer,
SubscriptionStatus, SubscriptionStatus,
@@ -20,7 +18,6 @@ from app.database.models import (
User, User,
UserStatus, UserStatus,
) )
from app.utils.pricing_utils import calculate_months_from_days
from app.utils.timezone import format_local_datetime from app.utils.timezone import format_local_datetime
@@ -221,6 +218,23 @@ async def create_paid_subscription(
if device_limit is None: if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT 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( subscription = Subscription(
user_id=user_id, user_id=user_id,
status=SubscriptionStatus.ACTIVE.value, status=SubscriptionStatus.ACTIVE.value,
@@ -229,7 +243,7 @@ async def create_paid_subscription(
end_date=end_date, end_date=end_date,
traffic_limit_gb=traffic_limit_gb, traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit, device_limit=device_limit,
connected_squads=connected_squads or [], connected_squads=final_squads,
autopay_enabled=settings.is_autopay_enabled_by_default(), autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id, tariff_id=tariff_id,
@@ -249,7 +263,7 @@ async def create_paid_subscription(
status=subscription.status, status=subscription.status,
) )
squad_uuids = list(connected_squads or []) squad_uuids = list(final_squads)
if update_server_counters and squad_uuids: if update_server_counters and squad_uuids:
try: try:
from app.database.crud.server_squad import ( from app.database.crud.server_squad import (
@@ -299,7 +313,25 @@ async def replace_subscription(
current_time = datetime.now(UTC) current_time = datetime.now(UTC)
old_squads = set(subscription.connected_squads or []) 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_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 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) logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit)
if connected_squads is not None: if connected_squads is not None:
old_squads = subscription.connected_squads # Не перезаписываем существующие сквады пустым списком
subscription.connected_squads = connected_squads if connected_squads or not subscription.connected_squads:
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=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 полей при смене тарифа # Обработка daily полей при смене тарифа
if is_tariff_change and tariff_id is not None: if is_tariff_change and tariff_id is not None:
@@ -1198,212 +1238,6 @@ async def add_subscription_servers(
return subscription 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]: async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) -> list[int]:
result = await db.execute( result = await db.execute(
select(SubscriptionServer.server_squad_id).where(SubscriptionServer.subscription_id == subscription_id) 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) # is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)), (Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
# Баланс пользователя >= суточной цены тарифа # Баланс пользователя > 0 (permissive pre-filter;
User.balance_kopeks >= Tariff.daily_price_kopeks, # 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.is_trial.is_(False),
# Только недавно экспайренные # Только недавно экспайренные
Subscription.updated_at >= recovery_threshold, Subscription.updated_at >= recovery_threshold,
# Баланс достаточен для списания # Баланс > 0 (permissive pre-filter;
User.balance_kopeks >= Tariff.daily_price_kopeks, # actual discounted price check happens in _process_single_charge)
User.balance_kopeks > 0,
) )
) )
) )
+20
View File
@@ -528,6 +528,26 @@ async def add_user_balance_by_id(
return False 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( async def subtract_user_balance(
db: AsyncSession, db: AsyncSession,
user: User, user: User,
+42 -14
View File
@@ -1103,13 +1103,27 @@ async def confirm_button_selection(callback: types.CallbackQuery, db_user: User,
await callback.message.delete() await callback.message.delete()
except Exception: except Exception:
pass pass
await callback.bot.send_photo( # Telegram ограничивает caption до 1024 символов
chat_id=callback.message.chat.id, if len(preview_text) <= 1024:
photo=media_file_id, await callback.bot.send_photo(
caption=preview_text, chat_id=callback.message.chat.id,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), photo=media_file_id,
parse_mode='HTML', 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: else:
# Если нет file_id, используем safe редактирование # Если нет file_id, используем safe редактирование
await safe_edit_or_send_text( await safe_edit_or_send_text(
@@ -1244,13 +1258,27 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
'video': 'video', 'video': 'video',
'document': 'document', 'document': 'document',
}[media_type] }[media_type]
await send_method( # Telegram ограничивает caption до 1024 символов
chat_id=telegram_id, if len(message_text) <= 1024:
**{media_kwarg: media_file_id}, await send_method(
caption=message_text, chat_id=telegram_id,
parse_mode='HTML', **{media_kwarg: media_file_id},
reply_markup=broadcast_keyboard, 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: else:
# Неизвестный media_type — отправляем как текст # Неизвестный media_type — отправляем как текст
await callback.bot.send_message( await callback.bot.send_message(
+48 -3
View File
@@ -4457,6 +4457,11 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
subscription_service = SubscriptionService() 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: try:
price_kopeks = await _calculate_subscription_period_price( price_kopeks = await _calculate_subscription_period_price(
db, db,
@@ -4914,7 +4919,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
user_id = int(parts[4]) user_id = int(parts[4])
tariff_id = int(parts[5]) tariff_id = int(parts[5])
period = int(parts[6]) period = int(parts[6])
price_kopeks = int(parts[7]) price_kopeks_from_callback = int(parts[7])
user_service = UserService() user_service = UserService()
profile = await user_service.get_user_profile(db, user_id) 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) await callback.answer('❌ Тариф недоступен', show_alert=True)
return 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: if target_user.balance_kopeks < price_kopeks:
await callback.answer('❌ Недостаточно средств на балансе', show_alert=True) await callback.answer('❌ Недостаточно средств на балансе', show_alert=True)
return return
@@ -4942,7 +4988,6 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User,
from app.database.crud.subscription import ( from app.database.crud.subscription import (
create_paid_subscription, create_paid_subscription,
extend_subscription, extend_subscription,
get_subscription_by_user_id,
) )
from app.database.crud.transaction import create_transaction from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance from app.database.crud.user import subtract_user_balance
+55 -113
View File
@@ -18,7 +18,6 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.states import BalanceStates from app.states import BalanceStates
from app.utils.decorators import error_handler from app.utils.decorators import error_handler
from app.utils.price_display import calculate_user_price
logger = structlog.get_logger(__name__) 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. 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: Args:
language: User's language for formatting 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(): if not settings.is_quick_amount_buttons_enabled():
return [] return []
from app.config import PERIOD_PRICES
from app.database.crud.subscription import get_subscription_by_user_id from app.database.crud.subscription import get_subscription_by_user_id
from app.database.database import AsyncSessionLocal 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) texts = get_texts(language)
tariff = None buttons = []
tariff_prices = None
tariff_periods = None
devices_price_per_month = 0
servers_per_month_prices: list[int] = []
traffic_price_per_month = 0
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id) 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: if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id tariff = subscription.tariff
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices: if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()} tariff_periods = sorted(int(k) for k in tariff.period_prices.keys())
tariff_periods = sorted(tariff_prices.keys())
# Получаем стоимость устройств, серверов и трафика из подписки if tariff_periods:
if subscription and not subscription.is_trial: periods = tariff_periods[:6]
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
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]
else: else:
base_price_kopeks = PERIOD_PRICES.get(period, 0) periods = settings.get_available_subscription_periods()[:6]
if base_price_kopeks > 0: for period in periods:
# Базовая цена периода с промо-скидками try:
price_info = calculate_user_price(user, base_price_kopeks, period, 'period') if tariff and tariff_periods and period in tariff_periods:
result = await pricing_engine.calculate_tariff_purchase_price(
months = calculate_months_from_days(period) tariff,
period,
# Стоимость устройств со скидкой device_limit=subscription.device_limit if subscription else None,
devices_addon = 0 user=user,
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}'
) )
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: else:
button_text = f'{texts.format_price(total_price)}{period_label}' 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 = [] keyboard_rows = []
for i in range(0, len(buttons), 2): for i in range(0, len(buttons), 2):
+23 -7
View File
@@ -1249,7 +1249,7 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
""" """
texts = get_texts(db_user.language) 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.subscription import create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance 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: if not connected_squads and available_servers:
connected_squads = [available_servers[0].squad_uuid] 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 balance = db_user.balance_kopeks
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) 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_price = 0
best_pricing = None # Cache pricing result for reuse in finalize() best_pricing = None # Cache pricing result for reuse in finalize()
# Для продления используем PricingEngine (единый расчёт для всех поверхностей). # PricingEngine единый расчёт для всех поверхностей (и продление, и новая подписка).
from app.services.pricing_engine import pricing_engine from app.services.pricing_engine import pricing_engine
renewal_service = SubscriptionRenewalService() if subscription else None 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) pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=db_user)
price = pricing_result.final_total price = pricing_result.final_total
else: else:
price, _ = await subscription_service.calculate_subscription_price_with_months( new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user db,
period,
connected_squads,
traffic_limit_gb,
device_limit,
user=db_user,
) )
price = new_pricing.final_total
if price <= balance: if price <= balance:
best_period = period best_period = period
best_price = price 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_pricing = await pricing_engine.calculate_renewal_price(db, subscription, min_period, user=db_user)
min_price = min_pricing.final_total min_price = min_pricing.final_total
else: else:
min_price, _ = await subscription_service.calculate_subscription_price_with_months( min_new_pricing = await pricing_engine.calculate_classic_new_subscription_price(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user db,
min_period,
connected_squads,
traffic_limit_gb,
device_limit,
user=db_user,
) )
min_price = min_new_pricing.final_total
missing = min_price - balance missing = min_price - balance
await callback.answer( await callback.answer(
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100}'), 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: else:
# Списать баланс ДО создания подписки (чтобы не было orphaned subscription при неудаче) # Списать баланс ДО создания подписки (чтобы не было orphaned subscription при неудаче)
consume_promo = get_user_active_promo_discount_percent(db_user) > 0
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
db_user, db_user,
best_price, best_price,
f'Активация подписки на {best_period} дней', f'Активация подписки на {best_period} дней',
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
consume_promo_offer=consume_promo,
) )
if not success: if not success:
await callback.answer('❌ Недостаточно средств', show_alert=True) await callback.answer('❌ Недостаточно средств', show_alert=True)
+37 -9
View File
@@ -401,13 +401,25 @@ async def handle_simple_subscription_pay_with_balance(
state_data=data, 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( price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db, db,
subscription_params, subscription_params,
user=db_user, user=db_user,
resolved_squad_uuid=resolved_squad_uuid, 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 total_required = price_kopeks
logger.warning( logger.warning(
'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance', '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: try:
# Списываем средства с баланса пользователя # Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней' purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней'
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
db_user, db_user,
price_kopeks, price_kopeks,
purchase_description, purchase_description,
consume_promo_offer=False, consume_promo_offer=consume_promo,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
@@ -840,7 +850,7 @@ async def handle_simple_subscription_payment_method(
state_data=data, state_data=data,
) )
# Рассчитываем цену подписки # Рассчитываем цену подписки (group discounts per-category)
price_kopeks, _ = await _calculate_simple_subscription_price( price_kopeks, _ = await _calculate_simple_subscription_price(
db, db,
subscription_params, subscription_params,
@@ -848,6 +858,14 @@ async def handle_simple_subscription_payment_method(
resolved_squad_uuid=resolved_squad_uuid, 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': if payment_method == 'stars':
# Оплата через Telegram Stars # Оплата через Telegram Stars
order = await purchase_service.create_subscription_order( order = await purchase_service.create_subscription_order(
@@ -2121,13 +2139,25 @@ async def confirm_simple_subscription_purchase(
state_data=data, 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( price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db, db,
subscription_params, subscription_params,
user=db_user, user=db_user,
resolved_squad_uuid=resolved_squad_uuid, 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 total_required = price_kopeks
logger.warning( logger.warning(
'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user= | period= | base= | traffic= | devices= | servers= | discount= | total_required= | balance', '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: try:
# Списываем средства с баланса пользователя # Списываем средства с баланса пользователя
from app.database.crud.user import subtract_user_balance
purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней' purchase_description = f'Оплата подписки на {subscription_params["period_days"]} дней'
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
db_user, db_user,
price_kopeks, price_kopeks,
purchase_description, purchase_description,
consume_promo_offer=False, consume_promo_offer=consume_promo,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
-37
View File
@@ -56,43 +56,6 @@ def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str
return template 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: def _get_promo_offer_discount_percent(user: User | None) -> int:
return get_user_active_promo_discount_percent(user) return get_user_active_promo_discount_percent(user)
+29 -32
View File
@@ -5,9 +5,9 @@ from aiogram import types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession 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.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.database.models import TransactionType, User
from app.keyboards.inline import ( from app.keyboards.inline import (
get_back_keyboard, get_back_keyboard,
@@ -17,6 +17,7 @@ from app.keyboards.inline import (
get_manage_countries_keyboard, get_manage_countries_keyboard,
) )
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.services.pricing_engine import PricingEngine, pricing_engine
from app.services.subscription_checkout_service import ( from app.services.subscription_checkout_service import (
save_subscription_checkout_draft, save_subscription_checkout_draft,
should_offer_checkout_resume, should_offer_checkout_resume,
@@ -28,7 +29,7 @@ from app.utils.pricing_utils import (
calculate_prorated_price, 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 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 current_countries = subscription.connected_squads
period_hint_days = _get_period_hint_from_subscription(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, db_user,
'servers', 'servers',
period_hint_days, 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) await state.update_data(countries=current_selected)
period_hint_days = _get_period_hint_from_subscription(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, db_user,
'servers', 'servers',
period_hint_days, 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) days_to_pay = max(1, (subscription.end_date - now).days)
period_hint_days = days_to_pay if days_to_pay > 0 else None 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, db_user,
'servers', 'servers',
period_hint_days, period_hint_days,
@@ -496,31 +502,18 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True) await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True)
return 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['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 state.set_data(data)
await callback.message.edit_reply_markup( await callback.message.edit_reply_markup(
@@ -700,7 +693,7 @@ async def handle_add_country_to_subscription(
total_price = 0 total_price = 0
subscription = db_user.subscription subscription = db_user.subscription
period_hint_days = _get_period_hint_from_subscription(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, db_user,
'servers', 'servers',
period_hint_days, period_hint_days,
@@ -808,12 +801,16 @@ async def confirm_add_countries_to_subscription(
await callback.answer('⚠️ Изменения не обнаружены', show_alert=True) await callback.answer('⚠️ Изменения не обнаружены', show_alert=True)
return 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 total_price = 0
new_countries_names = [] new_countries_names = []
removed_countries_names = [] removed_countries_names = []
period_hint_days = _get_period_hint_from_subscription(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, db_user,
'servers', 'servers',
period_hint_days, period_hint_days,
+42 -8
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database.crud.transaction import create_transaction 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.database.models import Subscription, TransactionType, User
from app.keyboards.inline import ( from app.keyboards.inline import (
get_app_selection_keyboard, get_app_selection_keyboard,
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
get_specific_app_keyboard, get_specific_app_keyboard,
) )
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.services.pricing_engine import PricingEngine
from app.services.remnawave_service import RemnaWaveService from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service from app.services.user_cart_service import user_cart_service
@@ -33,7 +34,6 @@ from app.utils.subscription_utils import (
) )
from .common import ( from .common import (
_get_addon_discount_percent_for_user,
_get_period_hint_from_subscription, _get_period_hint_from_subscription,
get_apps_for_platform_async, get_apps_for_platform_async,
get_device_name, get_device_name,
@@ -174,7 +174,7 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
current_devices = subscription.device_limit current_devices = subscription.device_limit
period_hint_days = _get_period_hint_from_subscription(subscription) 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, db_user,
'devices', 'devices',
period_hint_days, 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) days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user( devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user, db_user,
'devices', 'devices',
period_hint_days, 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) days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user( devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user, db_user,
'devices', 'devices',
period_hint_days, 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): async def execute_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
callback_parts = callback.data.split('_') callback_parts = callback.data.split('_')
new_devices_count = int(callback_parts[3]) 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) texts = get_texts(db_user.language)
subscription = db_user.subscription subscription = db_user.subscription
@@ -514,12 +515,15 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
show_alert=True, show_alert=True,
) )
return return
price_per_device = tariff_device_price
elif not settings.is_devices_selection_enabled(): elif not settings.is_devices_selection_enabled():
await callback.answer( await callback.answer(
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
show_alert=True, show_alert=True,
) )
return return
else:
price_per_device = settings.PRICE_PER_DEVICE
# Проверяем минимальное количество устройств на тарифе # Проверяем минимальное количество устройств на тарифе
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1 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 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: try:
if price > 0: if price > 0:
success = await subtract_user_balance( 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 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) 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) days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user( devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user, db_user,
'devices', 'devices',
period_hint_days, 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) days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user( devices_discount_percent = PricingEngine.get_addon_discount_percent(
db_user, db_user,
'devices', 'devices',
period_hint_days, period_hint_days,
+104 -239
View File
@@ -4,18 +4,15 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession 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.database.models import User
from app.utils.pricing_utils import ( from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description, format_period_description,
validate_pricing_calculation,
) )
from app.utils.timezone import format_local_datetime from app.utils.timezone import format_local_datetime
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger from .common import logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback from .countries import _get_available_countries, _get_countries_info
from .devices import get_current_devices_count from .devices import get_current_devices_count
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint 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], data: dict[str, Any],
texts, texts,
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
from app.database.database import AsyncSessionLocal
from app.services.pricing_engine import PricingEngine, pricing_engine
summary_data = dict(data) summary_data = dict(data)
if 'period_days' not in summary_data: if 'period_days' not in summary_data:
raise KeyError('period_days missing from subscription data — FSM state likely expired') raise KeyError('period_days missing from subscription data — FSM state likely expired')
countries = await _get_available_countries(db_user.promo_group_id) period_days = summary_data['period_days']
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)
# --- Resolve device limit (same logic as before) ---
devices_selection_enabled = settings.is_devices_selection_enabled() devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: int | None = None
if devices_selection_enabled: if devices_selection_enabled:
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else: else:
@@ -109,54 +42,75 @@ async def _prepare_subscription_summary(
devices_selected = settings.DEFAULT_DEVICE_LIMIT devices_selected = settings.DEFAULT_DEVICE_LIMIT
else: else:
devices_selected = forced_disabled_limit devices_selected = forced_disabled_limit
summary_data['devices'] = devices_selected 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 = ( discounted_monthly_additions = (
traffic_component['discounted_per_month'] traffic_discounted_per_month + servers_discounted_per_month + devices_discounted_per_month
+ discounted_servers_price_per_month
+ devices_component['discounted_per_month']
) )
is_valid = validate_pricing_calculation( # --- Promo offer discount (already computed by PricingEngine) ---
base_price, promo_offer_discount = pricing.promo_offer_discount
discounted_monthly_additions, offer_pct = bd.get('offer_discount_pct', 0)
months_in_period, # subtotal before promo offer = final_total + promo_offer_discount
total_price, subtotal_before_offer = pricing.final_total + promo_offer_discount
) total_price = pricing.final_total
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']
summary_data['total_price'] = total_price summary_data['total_price'] = total_price
if promo_offer_component['discount'] > 0: if promo_offer_discount > 0:
summary_data['promo_offer_discount_percent'] = promo_offer_component['percent'] summary_data['promo_offer_discount_percent'] = offer_pct
summary_data['promo_offer_discount_value'] = promo_offer_component['discount'] summary_data['promo_offer_discount_value'] = promo_offer_discount
summary_data['total_price_before_promo_offer'] = original_total_price summary_data['total_price_before_promo_offer'] = subtotal_before_offer
else: else:
summary_data.pop('promo_offer_discount_percent', None) summary_data.pop('promo_offer_discount_percent', None)
summary_data.pop('promo_offer_discount_value', None) summary_data.pop('promo_offer_discount_value', None)
summary_data.pop('total_price_before_promo_offer', 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['months_in_period'] = months_in_period
summary_data['base_price'] = base_price summary_data['base_price'] = base_price
summary_data['base_price_original'] = base_price_original 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['base_discount_total'] = base_discount_total
summary_data['final_traffic_gb'] = final_traffic_gb summary_data['final_traffic_gb'] = final_traffic_gb
summary_data['traffic_price_per_month'] = traffic_price_per_month summary_data['traffic_price_per_month'] = traffic_price_per_month
summary_data['traffic_discount_percent'] = traffic_component['discount_percent'] summary_data['traffic_discount_percent'] = traffic_discount_percent
summary_data['traffic_discount_total'] = traffic_component['discount_total'] summary_data['traffic_discount_total'] = traffic_discount_total
summary_data['traffic_discounted_price_per_month'] = traffic_component['discounted_per_month'] summary_data['traffic_discounted_price_per_month'] = traffic_discounted_per_month
summary_data['total_traffic_price'] = total_traffic_price summary_data['total_traffic_price'] = total_traffic_price
summary_data['servers_price_per_month'] = countries_price_per_month summary_data['servers_price_per_month'] = servers_price_per_month
summary_data['countries_price_per_month'] = countries_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_percent'] = servers_discount_percent
summary_data['servers_discount_total'] = total_servers_discount summary_data['servers_discount_total'] = servers_discount_total
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month summary_data['servers_discounted_price_per_month'] = servers_discounted_per_month
summary_data['total_servers_price'] = total_countries_price summary_data['total_servers_price'] = total_servers_price
summary_data['total_countries_price'] = total_countries_price summary_data['total_countries_price'] = total_servers_price
summary_data['devices_price_per_month'] = devices_price_per_month summary_data['devices_price_per_month'] = devices_price_per_month
summary_data['devices_discount_percent'] = devices_component['discount_percent'] summary_data['devices_discount_percent'] = devices_discount_percent
summary_data['devices_discount_total'] = devices_component['discount_total'] summary_data['devices_discount_total'] = devices_discount_total
summary_data['devices_discounted_price_per_month'] = devices_component['discounted_per_month'] summary_data['devices_discounted_price_per_month'] = devices_discounted_per_month
summary_data['total_devices_price'] = total_devices_price summary_data['total_devices_price'] = total_devices_price
summary_data['discounted_monthly_additions'] = discounted_monthly_additions 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 settings.is_traffic_fixed():
if final_traffic_gb == 0: if final_traffic_gb == 0:
traffic_display = 'Безлимитный' traffic_display = 'Безлимитный'
@@ -192,6 +149,13 @@ async def _prepare_subscription_summary(
else: else:
traffic_display = f'{summary_data.get("traffic_gb", 0)} ГБ' 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 = [] details_lines = []
# Добавляем строку базового периода только если цена не равна 0 # Добавляем строку базового периода только если цена не равна 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(traffic_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_traffic_price)}' f' = {texts.format_price(total_traffic_price)}'
) )
if traffic_component['discount_total'] > 0: if traffic_discount_total > 0:
traffic_line += ( traffic_line += f' (скидка {traffic_discount_percent}%: -{texts.format_price(traffic_discount_total)})'
f' (скидка {traffic_component["discount_percent"]}%:'
f' -{texts.format_price(traffic_component["discount_total"])})'
)
details_lines.append(traffic_line) details_lines.append(traffic_line)
if total_countries_price > 0: if total_servers_price > 0:
servers_line = ( servers_line = (
f'- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}' f'- Серверы: {texts.format_price(servers_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_countries_price)}' f' = {texts.format_price(total_servers_price)}'
) )
if total_servers_discount > 0: if servers_discount_total > 0:
servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(total_servers_discount)})' servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(servers_discount_total)})'
details_lines.append(servers_line) details_lines.append(servers_line)
if devices_selection_enabled and total_devices_price > 0: if devices_selection_enabled and total_devices_price > 0:
devices_line = ( devices_line = (
f'- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}' f'- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}'
f' = {texts.format_price(total_devices_price)}' f' = {texts.format_price(total_devices_price)}'
) )
if devices_component['discount_total'] > 0: if devices_discount_total > 0:
devices_line += ( devices_line += f' (скидка {devices_discount_percent}%: -{texts.format_price(devices_discount_total)})'
f' (скидка {devices_component["discount_percent"]}%:'
f' -{texts.format_price(devices_component["discount_total"])})'
)
details_lines.append(devices_line) details_lines.append(devices_line)
if promo_offer_component['discount'] > 0: if promo_offer_discount > 0:
details_lines.append( details_lines.append(
texts.t( texts.t(
'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT', 'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT',
'- Промо-предложение: -{amount} ({percent}% дополнительно)', '- Промо-предложение: -{amount} ({percent}% дополнительно)',
).format( ).format(
amount=texts.format_price(promo_offer_component['discount']), amount=texts.format_price(promo_offer_discount),
percent=promo_offer_component['percent'], percent=offer_pct,
) )
) )
@@ -309,114 +267,21 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
if subscription.is_trial: if subscription.is_trial:
return 0 return 0
from app.config import settings from app.services.pricing_engine import pricing_engine
from app.database.crud.tariff import get_tariff_by_id
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
try: try:
owner = subscription.user owner = subscription.user
except AttributeError: except AttributeError:
owner = None owner = None
promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None result = await pricing_engine.calculate_renewal_price(db, subscription, 30, user=owner)
total_cost = result.final_total
# В тарифном режиме цена тарифа уже включает серверы и трафик
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)
logger.info('Monthly subscription cost', subscription_id=subscription.id, total_cost_kopeks=total_cost)
return total_cost return total_cost
except Exception as e: except Exception as e:
logger.error('Ошибка расчета стоимости подписки', error=e) logger.error('Error calculating subscription cost', error=e)
return 0 return 0
+128 -186
View File
@@ -9,7 +9,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings from app.config import settings
from app.database.crud.subscription import ( from app.database.crud.subscription import (
create_paid_subscription, create_paid_subscription,
create_pending_trial_subscription, create_pending_trial_subscription,
@@ -37,6 +37,7 @@ from app.keyboards.inline import (
) )
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService 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.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_checkout_service import ( from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft, clear_subscription_checkout_draft,
@@ -99,7 +100,6 @@ from app.handlers.simple_subscription import (
from app.states import SubscriptionStates from app.states import SubscriptionStates
from app.utils.price_display import PriceInfo, format_price_text from app.utils.price_display import PriceInfo, format_price_text
from app.utils.pricing_utils import ( from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days, calculate_months_from_days,
format_period_description, format_period_description,
) )
@@ -343,8 +343,23 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
] ]
if is_daily: if is_daily:
# Для суточного тарифа показываем цену и прогресс-бар # Для суточного тарифа показываем цену с учётом скидки промогруппы + promo-offer
daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100 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} ₽/день') 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) await callback.answer('⚠ У вас нет активной подписки', show_alert=True)
return return
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine from app.services.pricing_engine import pricing_engine
from app.services.subscription_renewal_service import SubscriptionRenewalChargeError, SubscriptionRenewalService 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) months_in_period = calculate_months_from_days(days)
try: try:
@@ -1884,7 +1901,7 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
await callback.answer() 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]) period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language) 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) await callback.answer(texts.t('PERIOD_NOT_AVAILABLE', '❌ Этот период больше недоступен'), show_alert=True)
return return
# Получаем цену с защитой от KeyError
period_price = PERIOD_PRICES.get(period_days, 0)
data = await state.get_data() data = await state.get_data()
data['period_days'] = period_days data['period_days'] = period_days
data['total_price'] = period_price
if settings.is_traffic_fixed(): 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() 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) await state.set_data(data)
if settings.is_traffic_selectable(): if settings.is_traffic_selectable():
@@ -1958,7 +1980,7 @@ async def select_period(callback: types.CallbackQuery, state: FSMContext, db_use
await callback.answer() 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) texts = get_texts(db_user.language)
if not settings.is_devices_selection_enabled(): 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() data = await state.get_data()
# Получаем цену периода с защитой от KeyError
period_days = data.get('period_days') period_days = data.get('period_days')
if not period_days or period_days not in PERIOD_PRICES: if not period_days:
await callback.answer( await callback.answer(
texts.t('PERIOD_NOT_AVAILABLE', '❌ Период больше недоступен, начните заново'), show_alert=True texts.t('PERIOD_NOT_AVAILABLE', '❌ Период больше недоступен, начните заново'), show_alert=True
) )
return 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) previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
data['devices'] = devices 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) await state.set_data(data)
if devices != previous_devices: 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)) 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 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') period_days = data.get('period_days')
if period_days is None: if period_days is None:
await callback.message.edit_text( await callback.message.edit_text(
@@ -2059,62 +2079,8 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
) )
await callback.answer() await callback.answer()
return 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() devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: int | None = None forced_disabled_limit: int | None = None
if devices_selection_enabled: if devices_selection_enabled:
@@ -2126,95 +2092,42 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
else: else:
devices_selected = forced_disabled_limit devices_selected = forced_disabled_limit
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) # --- Resolve traffic ---
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
if settings.is_traffic_fixed(): if settings.is_traffic_fixed():
final_traffic_gb = settings.get_fixed_traffic_limit() 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: else:
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb')) final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb', 0))
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)
if 'traffic_discount_percent' in data: # --- Resolve connected squads ---
traffic_discount_percent = data.get('traffic_discount_percent', 0) connected_squads = list(data.get('countries', []))
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)
cached_total_price = data.get('total_price', 0) cached_total_price = data.get('total_price', 0)
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
# Всегда пересчитываем monthly_additions из компонентов для безопасности # Lock user BEFORE promo-offer read to prevent TOCTOU
discounted_monthly_additions = ( from app.database.crud.user import lock_user_for_pricing
discounted_traffic_price_per_month + discounted_servers_price_per_month + discounted_devices_price_per_month
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)
# Вычисляем ожидаемую цену до промо-скидки из компонентов final_price = pricing_result.final_total
calculated_total_before_promo = base_price + (discounted_monthly_additions * months_in_period) 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)
# Получаем сохраненную цену до промо-скидки или используем вычисленную # --- Price validation: block if price increased significantly vs cached FSM price ---
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_difference = final_price - cached_total_price price_difference = final_price - cached_total_price
if price_difference > 0: if price_difference > 0:
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽ 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, final_price=final_price / 100,
) )
# Используем пересчитанную цену # --- Logging ---
validation_total_price = calculated_total_before_promo 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) logger.info('Расчет покупки подписки на дней ( мес)', data=data['period_days'], months_in_period=months_in_period)
base_log = f' Период: {base_price_original / 100}' base_log = f' Период: {base_price_original / 100}'
if base_discount_total and base_discount_total > 0: if base_discount_total and base_discount_total > 0:
base_log += f'{base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)' base_log += f'{base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)'
logger.info(base_log) logger.info(base_log)
if total_traffic_price > 0: if details['total_traffic_price'] > 0:
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}' traffic_msg = (
if traffic_discount_total > 0: f' Трафик: {details["traffic_price_per_month"] / 100}₽/мес'
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_total / 100})' f' × {months_in_period} = {details["total_traffic_price"] / 100}'
logger.info(message)
if total_servers_price > 0:
message = (
f' Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}'
) )
if total_servers_discount > 0: if details['traffic_discount_total'] > 0:
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' traffic_msg += (
logger.info(message) f' (скидка {details["traffic_discount_percent"]}%: -{details["traffic_discount_total"] / 100}₽)'
if total_devices_price > 0: )
message = ( logger.info(traffic_msg)
f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}' 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: if details['servers_discount_total'] > 0:
message += f' (скидка {devices_discount_percent}%: -{devices_discount_total / 100}₽)' servers_msg += (
logger.info(message) 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: if promo_offer_discount_value > 0:
logger.info( logger.info(
'🎯 Промо-предложение: -₽ (%)', 'Промо-предложение: -₽ (%)',
promo_offer_discount_value=promo_offer_discount_value / 100, promo_offer_discount_value=promo_offer_discount_value / 100,
promo_offer_discount_percent=promo_offer_discount_percent, 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: 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: if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer( await callback.answer(
texts.t( texts.t(
@@ -2966,7 +2902,6 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
if needs_resume: if needs_resume:
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня) # Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and is_inactive: if daily_price > 0 and is_inactive:
from app.database.crud.user import subtract_user_balance 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.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.database.models import TransactionType
from app.services.subscription_service import SubscriptionService from app.services.subscription_service import SubscriptionService
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
# Рассчитываем цену подписки # Рассчитываем цену подписки (group discounts per-category)
subscription_params = { subscription_params = {
'period_days': period_days, 'period_days': period_days,
'device_limit': device_limit, 'device_limit': device_limit,
@@ -4166,6 +4102,12 @@ async def _extend_existing_subscription(
user=db_user, user=db_user,
resolved_squad_uuid=squad_uuid, 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( logger.warning(
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount= | device_limit', 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user= | total= | base= | traffic= | devices= | servers= | discount= | device_limit',
db_user_id=db_user.id, db_user_id=db_user.id,
@@ -4212,7 +4154,7 @@ async def _extend_existing_subscription(
'device_limit': device_limit, 'device_limit': device_limit,
'traffic_limit_gb': traffic_limit_gb, 'traffic_limit_gb': traffic_limit_gb,
'squad_uuid': squad_uuid, '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) await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -4233,7 +4175,7 @@ async def _extend_existing_subscription(
db_user, db_user,
price_kopeks, price_kopeks,
f'Продление подписки на {period_days} дней', f'Продление подписки на {period_days} дней',
consume_promo_offer=False, # Простая покупка не использует промо-скидки consume_promo_offer=consume_promo,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
+354 -188
View File
@@ -79,9 +79,14 @@ def format_tariffs_list_text(
discount_icon = '' discount_icon = ''
if is_daily: if is_daily:
# Для суточных тарифов показываем цену за день # Для суточных тарифов показываем цену за день с учётом скидки промогруппы
daily_price = getattr(tariff, 'daily_price_kopeks', 0) 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: else:
# Для периодных тарифов показываем минимальную цену # Для периодных тарифов показываем минимальную цену
prices = tariff.period_prices or {} prices = tariff.period_prices or {}
@@ -394,21 +399,42 @@ def _calculate_custom_tariff_price(
return period_price, traffic_price, total_price return period_price, traffic_price, total_price
def format_custom_tariff_preview( async def format_custom_tariff_preview(
tariff: Tariff, tariff: Tariff,
days: int, days: int,
traffic_gb: int, traffic_gb: int,
user_balance: int, user_balance: int,
db_user: User | None = None,
discount_percent: int = 0, discount_percent: int = 0,
group_pct: int = 0, group_pct: int = 0,
offer_pct: int = 0, offer_pct: int = 0,
) -> str: ) -> str:
"""Форматирует предпросмотр покупки с кастомными параметрами.""" """Форматирует предпросмотр покупки с кастомными параметрами.
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb)
# Применяем скидку Uses PricingEngine when db_user is provided for accurate per-category discounts
if discount_percent > 0: (period, traffic addon). Falls back to manual calculation otherwise.
total_price = _apply_promo_discount(total_price, group_pct, offer_pct) """
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) 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' text += f'📱 Устройств: {tariff.device_limit}\n'
if discount_percent > 0: if has_discount:
text += f'\n🎁 <b>Скидка: {discount_percent}%</b>\n' text += f'\n🎁 <b>Скидка: {discount_percent}%</b>\n'
text += f""" text += f"""
@@ -477,7 +503,9 @@ async def show_tariffs_list(
return 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 has_period_discounts = False
if promo_group: if promo_group:
period_discounts = getattr(promo_group, 'period_discounts', None) period_discounts = getattr(promo_group, 'period_discounts', None)
@@ -514,7 +542,12 @@ async def select_tariff(
if is_daily: 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 user_balance = db_user.balance_kopeks or 0
traffic = format_traffic(tariff.traffic_limit_gb) traffic = format_traffic(tariff.traffic_limit_gb)
@@ -525,7 +558,8 @@ async def select_tariff(
f'📊 Трафик: {traffic}\n' f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n' f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: <b>Суточный</b>\n\n' f'🔄 Тип: <b>Суточный</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n' f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n\n'
f'ℹ️ Средства будут списываться автоматически раз в сутки.\n' f'ℹ️ Средства будут списываться автоматически раз в сутки.\n'
f'Вы можете приостановить подписку в любой момент.', f'Вы можете приостановить подписку в любой момент.',
@@ -557,7 +591,8 @@ async def select_tariff(
f'❌ <b>Недостаточно средств</b>\n\n' f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n' f'📦 Тариф: <b>{tariff.name}</b>\n'
f'🔄 Тип: Суточный\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(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>\n\n' f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>\n\n'
f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>', f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>',
@@ -588,14 +623,13 @@ async def select_tariff(
period_offer_pct=offer_pct, period_offer_pct=offer_pct,
) )
preview_text = format_custom_tariff_preview( preview_text = await format_custom_tariff_preview(
tariff=tariff, tariff=tariff,
days=initial_days, days=initial_days,
traffic_gb=initial_traffic, traffic_gb=initial_traffic,
user_balance=user_balance, user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent, discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
) )
await callback.message.edit_text( await callback.message.edit_text(
@@ -672,14 +706,13 @@ async def handle_custom_days_change(
user_balance = db_user.balance_kopeks or 0 user_balance = db_user.balance_kopeks or 0
preview_text = format_custom_tariff_preview( preview_text = await format_custom_tariff_preview(
tariff=tariff, tariff=tariff,
days=new_days, days=new_days,
traffic_gb=current_traffic, traffic_gb=current_traffic,
user_balance=user_balance, user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent, discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
) )
await callback.message.edit_text( 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_days = state_data.get('custom_days', tariff.min_days)
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
discount_percent = state_data.get('period_discount_percent', 0) 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 new_traffic = current_traffic + delta
@@ -733,14 +764,13 @@ async def handle_custom_traffic_change(
user_balance = db_user.balance_kopeks or 0 user_balance = db_user.balance_kopeks or 0
preview_text = format_custom_tariff_preview( preview_text = await format_custom_tariff_preview(
tariff=tariff, tariff=tariff,
days=current_days, days=current_days,
traffic_gb=new_traffic, traffic_gb=new_traffic,
user_balance=user_balance, user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent, discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
) )
await callback.message.edit_text( await callback.message.edit_text(
@@ -777,28 +807,33 @@ async def handle_custom_confirm(
await callback.answer('Тариф недоступен', show_alert=True) await callback.answer('Тариф недоступен', show_alert=True)
return 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() state_data = await state.get_data()
custom_days = state_data.get('custom_days', tariff.min_days) custom_days = state_data.get('custom_days', tariff.min_days)
custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) 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)
# Рассчитываем цену (используем общую функцию) # Calculate price via PricingEngine (single source of truth for all discounts)
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, custom_days, custom_traffic) 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(): if result.base_price == 0 and not tariff.can_purchase_custom_days():
# Период не найден в period_prices - ошибка
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True) await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
return return
# Применяем скидку к цене периода (не к трафику) # Проверяем баланс (user already locked, balance is fresh)
if discount_percent > 0:
period_price = _apply_promo_discount(period_price, group_pct, offer_pct)
total_price = period_price + traffic_price
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0 user_balance = db_user.balance_kopeks or 0
if user_balance < total_price: if user_balance < total_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True) await callback.answer('Недостаточно средств на балансе', show_alert=True)
@@ -807,7 +842,7 @@ async def handle_custom_confirm(
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
# Save promo offer state before deduction (for restore on failure) # 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_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_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 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, period_offer_pct=offer_pct,
) )
preview_text = format_custom_tariff_preview( preview_text = await format_custom_tariff_preview(
tariff=tariff, tariff=tariff,
days=period, days=period,
traffic_gb=initial_traffic, traffic_gb=initial_traffic,
user_balance=user_balance, user_balance=user_balance,
db_user=db_user,
discount_percent=discount_percent, discount_percent=discount_percent,
group_pct=group_pct,
offer_pct=offer_pct,
) )
await callback.message.edit_text( await callback.message.edit_text(
@@ -1152,34 +1186,28 @@ async def confirm_tariff_purchase(
await callback.answer('Тариф недоступен', show_alert=True) await callback.answer('Тариф недоступен', show_alert=True)
return return
# Получаем цену # Lock user BEFORE price computation to prevent TOCTOU on promo offer
prices = tariff.period_prices or {} from app.database.crud.user import lock_user_for_pricing
base_price = prices.get(str(period), 0)
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) existing_sub = await get_subscription_by_user_id(db, db_user.id)
device_price_per_unit = ( device_limit = None
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE
)
extra_devices = 0
if existing_sub and existing_sub.tariff_id == tariff.id: 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)) device_limit = existing_sub.device_limit
devices_price = extra_devices * device_price_per_unit
# Apply discounts sequentially (matching PricingEngine): group first, then offer result = await pricing_engine.calculate_tariff_purchase_price(
subtotal = base_price + devices_price tariff,
promo_group = db_user.get_primary_promo_group() period,
group_discount_pct = promo_group.get_discount_percent('period', period) if promo_group else 0 device_limit=device_limit,
if group_discount_pct > 0: user=db_user,
subtotal = subtotal - subtotal * group_discount_pct // 100 )
final_price = result.final_total
offer_discount_pct = get_user_active_promo_discount_percent(db_user) # Проверяем баланс (user already locked, balance is fresh)
if offer_discount_pct > 0:
subtotal = subtotal - subtotal * offer_discount_pct // 100
final_price = max(0, subtotal)
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0 user_balance = db_user.balance_kopeks or 0
if user_balance < final_price: if user_balance < final_price:
await callback.answer('Недостаточно средств на балансе', show_alert=True) await callback.answer('Недостаточно средств на балансе', show_alert=True)
@@ -1188,7 +1216,7 @@ async def confirm_tariff_purchase(
texts = get_texts(db_user.language) 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) # 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_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_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) await callback.answer('Некорректная цена тарифа', show_alert=True)
return 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 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) await callback.answer('Недостаточно средств на балансе', show_alert=True)
return return
@@ -1395,8 +1440,9 @@ async def confirm_daily_tariff_purchase(
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
db_user, db_user,
daily_price, final_daily_price,
f'Покупка суточного тарифа {tariff.name} (первый день)', f'Покупка суточного тарифа {tariff.name} (первый день)',
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -1485,7 +1531,7 @@ async def confirm_daily_tariff_purchase(
await add_user_balance( await add_user_balance(
db, db,
db_user, db_user,
daily_price, final_daily_price,
'Возврат: ошибка покупки суточного тарифа', 'Возврат: ошибка покупки суточного тарифа',
create_transaction=True, create_transaction=True,
transaction_type=TransactionType.REFUND, transaction_type=TransactionType.REFUND,
@@ -1494,7 +1540,7 @@ async def confirm_daily_tariff_purchase(
logger.critical( logger.critical(
'CRITICAL: не удалось вернуть средства после ошибки покупки суточного тарифа', 'CRITICAL: не удалось вернуть средства после ошибки покупки суточного тарифа',
user_id=db_user.id, user_id=db_user.id,
price_kopeks=daily_price, price_kopeks=final_daily_price,
refund_error=refund_error, refund_error=refund_error,
) )
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
@@ -1518,7 +1564,7 @@ async def confirm_daily_tariff_purchase(
db, db,
user_id=db_user.id, user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT, type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price, amount_kopeks=final_daily_price,
description=f'Покупка суточного тарифа {tariff.name} (первый день)', description=f'Покупка суточного тарифа {tariff.name} (первый день)',
) )
@@ -1532,7 +1578,7 @@ async def confirm_daily_tariff_purchase(
None, None,
1, # 1 день 1, # 1 день
was_trial_conversion=False, was_trial_conversion=False,
amount_kopeks=daily_price, amount_kopeks=final_daily_price,
purchase_type='renewal' if existing_subscription else 'first_purchase', purchase_type='renewal' if existing_subscription else 'first_purchase',
) )
except Exception as e: except Exception as e:
@@ -1555,7 +1601,7 @@ async def confirm_daily_tariff_purchase(
f'📊 Трафик: {traffic}\n' f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n' f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n' f'🔄 Тип: Суточный\n'
f'💰 Списано: {format_price_kopeks(daily_price)}\n\n' f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n'
f'ℹ️ Следующее списание через 24 часа.\n' f'ℹ️ Следующее списание через 24 часа.\n'
f'Перейдите в раздел «Подписка» для подключения.', f'Перейдите в раздел «Подписка» для подключения.',
reply_markup=InlineKeyboardMarkup( reply_markup=InlineKeyboardMarkup(
@@ -1591,26 +1637,39 @@ def get_tariff_extend_keyboard(
subscription_device_limit: int | None = None, subscription_device_limit: int | None = None,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам.""" """Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
from app.services.pricing_engine import PricingEngine
texts = get_texts(language) texts = get_texts(language)
buttons = [] buttons = []
promo_group = PricingEngine.resolve_promo_group(db_user) if db_user else None
prices = tariff.period_prices or {} prices = tariff.period_prices or {}
for period_str in sorted(prices.keys(), key=int): for period_str in sorted(prices.keys(), key=int):
period = int(period_str) period = int(period_str)
price = prices[period_str] base_price = prices[period_str]
# Добавляем стоимость дополнительных устройств # Стоимость дополнительных устройств
devices_cost = 0
if subscription_device_limit is not None: 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)
# Получаем скидку для конкретного периода # Per-category group discounts (period + devices separately, like PricingEngine)
group_pct, offer_pct, discount_percent = 0, 0, 0 period_pct = promo_group.get_discount_percent('period', period) if promo_group else 0
if db_user: devices_pct = promo_group.get_discount_percent('devices', period) if promo_group else 0
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) offer_pct = get_user_active_promo_discount_percent(db_user) if db_user else 0
if discount_percent > 0: discounted_base = PricingEngine.apply_discount(base_price, period_pct)
price = _apply_promo_discount(price, group_pct, offer_pct) discounted_devices = PricingEngine.apply_discount(devices_cost, devices_pct)
price_text = f'{format_price_kopeks(price)} 🔥−{discount_percent}%' 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: else:
price_text = format_price_kopeks(price) price_text = format_price_kopeks(price)
@@ -1662,7 +1721,9 @@ async def show_tariff_extend(
traffic = format_traffic(tariff.traffic_limit_gb) 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 has_period_discounts = False
if promo_group: if promo_group:
period_discounts = getattr(promo_group, 'period_discounts', None) 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) 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 actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
# Получаем скидку для выбранного периода # Calculate price via PricingEngine (per-category discounts: period + devices)
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) from app.services.pricing_engine import pricing_engine
# Получаем цену (тариф + дополнительные устройства) result = await pricing_engine.calculate_tariff_purchase_price(
prices = tariff.period_prices or {} tariff,
base_price = prices.get(str(period), 0) period,
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period) device_limit=actual_device_limit,
final_price = _apply_promo_discount(base_price, group_pct, offer_pct) 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 user_balance = db_user.balance_kopeks or 0
@@ -1733,7 +1801,7 @@ async def select_tariff_extend_period(
if user_balance >= final_price: if user_balance >= final_price:
discount_text = '' discount_text = ''
if discount_percent > 0: 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( await callback.message.edit_text(
f'✅ <b>Подтверждение продления</b>\n\n' f'✅ <b>Подтверждение продления</b>\n\n'
@@ -1821,15 +1889,21 @@ async def confirm_tariff_extend(
actual_device_limit = subscription.device_limit or tariff.device_limit actual_device_limit = subscription.device_limit or tariff.device_limit
data = await state.get_data() from app.database.crud.user import lock_user_for_pricing
group_pct = data.get('extend_group_pct', 0)
offer_pct = data.get('extend_offer_pct', 0)
# Получаем цену (тариф + дополнительные устройства) db_user = await lock_user_for_pricing(db, db_user.id)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0) # Calculate price via PricingEngine (handles per-category discounts: period + devices)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period) from app.services.pricing_engine import pricing_engine
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
consume_promo = result.promo_offer_discount > 0
# Проверяем баланс # Проверяем баланс
user_balance = db_user.balance_kopeks or 0 user_balance = db_user.balance_kopeks or 0
@@ -1846,7 +1920,7 @@ async def confirm_tariff_extend(
db_user, db_user,
final_price, final_price,
f'Продление тарифа {tariff.name} на {period} дней', 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, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -1966,9 +2040,14 @@ def format_tariff_switch_list_text(
discount_icon = '' discount_icon = ''
if is_daily: if is_daily:
# Для суточных тарифов показываем цену за день # Для суточных тарифов показываем цену за день с учётом скидки промогруппы
daily_price = getattr(tariff, 'daily_price_kopeks', 0) 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: else:
prices = tariff.period_prices or {} prices = tariff.period_prices or {}
if prices: if prices:
@@ -2124,7 +2203,9 @@ async def show_tariff_switch_list(
current_tariff_name = current_tariff.name 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 has_period_discounts = False
if promo_group: if promo_group:
period_discounts = getattr(promo_group, 'period_discounts', None) period_discounts = getattr(promo_group, 'period_discounts', None)
@@ -2170,7 +2251,12 @@ async def select_tariff_switch(
if is_daily: 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 user_balance = db_user.balance_kopeks or 0
# Проверяем текущую подписку на оставшиеся дни # Проверяем текущую подписку на оставшиеся дни
@@ -2189,7 +2275,8 @@ async def select_tariff_switch(
f'📊 Трафик: {traffic}\n' f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n' f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: <b>Суточный</b>\n\n' f'🔄 Тип: <b>Суточный</b>\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n' f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}'
f'{days_warning}\n\n' f'{days_warning}\n\n'
f'ℹ️ Средства будут списываться автоматически раз в сутки.\n' f'ℹ️ Средства будут списываться автоматически раз в сутки.\n'
@@ -2212,7 +2299,8 @@ async def select_tariff_switch(
f'❌ <b>Недостаточно средств</b>\n\n' f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n' f'📦 Тариф: <b>{tariff.name}</b>\n'
f'🔄 Тип: Суточный\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(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>' f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>'
f'{days_warning}', f'{days_warning}',
@@ -2269,13 +2357,21 @@ async def select_tariff_switch_period(
data = await state.get_data() data = await state.get_data()
current_tariff_id = data.get('current_tariff_id') current_tariff_id = data.get('current_tariff_id')
# Получаем скидку для выбранного периода # Calculate price via PricingEngine (per-category discounts: period + devices for new tariff)
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period) from app.services.pricing_engine import pricing_engine
# Получаем цену result = await pricing_engine.calculate_tariff_purchase_price(
prices = tariff.period_prices or {} tariff,
base_price = prices.get(str(period), 0) period,
final_price = _apply_promo_discount(base_price, group_pct, offer_pct) 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 user_balance = db_user.balance_kopeks or 0
@@ -2300,7 +2396,7 @@ async def select_tariff_switch_period(
if user_balance >= final_price: if user_balance >= final_price:
discount_text = '' discount_text = ''
if discount_percent > 0: 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( await callback.message.edit_text(
f'✅ <b>Подтверждение переключения тарифа</b>\n\n' f'✅ <b>Подтверждение переключения тарифа</b>\n\n'
@@ -2354,13 +2450,30 @@ async def confirm_tariff_switch(
await callback.answer('Тариф недоступен', show_alert=True) await callback.answer('Тариф недоступен', show_alert=True)
return return
# Получаем скидку для выбранного периода from app.database.crud.user import lock_user_for_pricing
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
# Получаем цену db_user = await lock_user_for_pricing(db, db_user.id)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0) # Проверяем наличие подписки (need device_limit for pricing)
final_price = _apply_promo_discount(base_price, group_pct, offer_pct) 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 user_balance = db_user.balance_kopeks or 0
@@ -2368,12 +2481,6 @@ async def confirm_tariff_switch(
await callback.answer('Недостаточно средств на балансе', show_alert=True) await callback.answer('Недостаточно средств на балансе', show_alert=True)
return 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) texts = get_texts(db_user.language)
try: try:
@@ -2383,7 +2490,7 @@ async def confirm_tariff_switch(
db_user, db_user,
final_price, final_price,
f'Смена тарифа на {tariff.name} ({period} дней)', 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, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -2536,9 +2643,26 @@ async def confirm_daily_tariff_switch(
await callback.answer('Некорректная цена тарифа', show_alert=True) await callback.answer('Некорректная цена тарифа', show_alert=True)
return 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 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) await callback.answer('Недостаточно средств на балансе', show_alert=True)
return return
@@ -2555,8 +2679,9 @@ async def confirm_daily_tariff_switch(
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
db_user, db_user,
daily_price, final_daily_price,
f'Смена на суточный тариф {tariff.name} (первый день)', f'Смена на суточный тариф {tariff.name} (первый день)',
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -2639,7 +2764,7 @@ async def confirm_daily_tariff_switch(
db, db,
user_id=db_user.id, user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT, type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price, amount_kopeks=final_daily_price,
description=f'Смена на суточный тариф {tariff.name} (первый день)', description=f'Смена на суточный тариф {tariff.name} (первый день)',
) )
@@ -2653,7 +2778,7 @@ async def confirm_daily_tariff_switch(
None, None,
1, # 1 день 1, # 1 день
was_trial_conversion=False, was_trial_conversion=False,
amount_kopeks=daily_price, amount_kopeks=final_daily_price,
purchase_type='tariff_switch', purchase_type='tariff_switch',
) )
except Exception as e: except Exception as e:
@@ -2669,7 +2794,7 @@ async def confirm_daily_tariff_switch(
f'📊 Трафик: {traffic}\n' f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n' f'📱 Устройств: {tariff.device_limit}\n'
f'🔄 Тип: Суточный\n' f'🔄 Тип: Суточный\n'
f'💰 Списано: {format_price_kopeks(daily_price)}\n\n' f'💰 Списано: {format_price_kopeks(final_daily_price)}\n\n'
f'ℹ️ Следующее списание через 24 часа.', f'ℹ️ Следующее списание через 24 часа.',
reply_markup=InlineKeyboardMarkup( reply_markup=InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
@@ -2683,65 +2808,53 @@ async def confirm_daily_tariff_switch(
except Exception as e: except Exception as e:
logger.error('Ошибка при смене на суточный тариф', error=e, exc_info=True) 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) 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( def _calculate_instant_switch_cost(
current_tariff: Tariff, current_tariff: Tariff,
new_tariff: Tariff, new_tariff: Tariff,
remaining_days: int, remaining_days: int,
db_user: User | None = None, db_user: User | None = None,
) -> tuple[int, bool]: ) -> tuple[int, bool]:
""" """Рассчитывает стоимость мгновенного переключения тарифа.
Рассчитывает стоимость мгновенного переключения тарифа.
Если новый тариф дороже - доплата пропорционально оставшимся дням.
Если дешевле или равен - бесплатно.
Формула: (new_monthly - current_monthly) * remaining_days / 30
Скидка применяется к обоим тарифам одинаково.
Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost().
Returns: Returns:
(upgrade_cost_kopeks, is_upgrade) (upgrade_cost_kopeks, is_upgrade)
""" """
current_monthly = _get_tariff_monthly_price(current_tariff) from app.services.pricing_engine import pricing_engine
new_monthly = _get_tariff_monthly_price(new_tariff)
group_pct, offer_pct, discount_percent = 0, 0, 0 result = pricing_engine.calculate_tariff_switch_cost(
if db_user: current_tariff,
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, 30) new_tariff,
remaining_days,
if discount_percent > 0: user=db_user,
current_monthly = _apply_promo_discount(current_monthly, group_pct, offer_pct) )
new_monthly = _apply_promo_discount(new_monthly, group_pct, offer_pct) return result.upgrade_cost, result.is_upgrade
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
def format_instant_switch_list_text( def format_instant_switch_list_text(
@@ -2984,7 +3097,15 @@ async def preview_instant_switch(
# Для суточного тарифа особая логика показа # Для суточного тарифа особая логика показа
if is_new_daily: 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 user_balance = db_user.balance_kopeks or 0
if user_balance >= daily_price: if user_balance >= daily_price:
@@ -2997,7 +3118,8 @@ async def preview_instant_switch(
f' • Трафик: {traffic}\n' f' • Трафик: {traffic}\n'
f' • Устройств: {new_tariff.device_limit}\n' f' • Устройств: {new_tariff.device_limit}\n'
f' • Тип: 🔄 Суточный\n\n' f' • Тип: 🔄 Суточный\n\n'
f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>\n\n' f'💰 <b>Цена: {format_price_kopeks(daily_price)}/день</b>'
f'{discount_text}\n\n'
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}'
f'{daily_warning}\n\n' f'{daily_warning}\n\n'
f'ℹ️ Средства будут списываться автоматически раз в сутки.', f'ℹ️ Средства будут списываться автоматически раз в сутки.',
@@ -3010,7 +3132,8 @@ async def preview_instant_switch(
f'❌ <b>Недостаточно средств</b>\n\n' f'❌ <b>Недостаточно средств</b>\n\n'
f'📦 Тариф: <b>{new_tariff.name}</b>\n' f'📦 Тариф: <b>{new_tariff.name}</b>\n'
f'🔄 Тип: Суточный\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(user_balance)}\n'
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>' f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>'
f'{daily_warning}', f'{daily_warning}',
@@ -3099,19 +3222,37 @@ async def confirm_instant_switch(
await callback.answer('Тариф недоступен', show_alert=True) await callback.answer('Тариф недоступен', show_alert=True)
return 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) subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription: if not subscription:
await callback.answer('Подписка не найдена', show_alert=True) await callback.answer('Подписка не найдена', show_alert=True)
return 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 user_balance = db_user.balance_kopeks or 0
if is_upgrade and user_balance < upgrade_cost: if is_upgrade and user_balance < upgrade_cost:
await callback.answer('Недостаточно средств на балансе', show_alert=True) await callback.answer('Недостаточно средств на балансе', show_alert=True)
@@ -3121,13 +3262,14 @@ async def confirm_instant_switch(
try: try:
# Списываем баланс если это upgrade # Списываем баланс если это upgrade
# upgrade_cost includes both group + offer discounts from PricingEngine
if is_upgrade and upgrade_cost > 0: if is_upgrade and upgrade_cost > 0:
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
db_user, db_user,
upgrade_cost, upgrade_cost,
f'Переключение на тариф {new_tariff.name}', 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, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -3176,7 +3318,15 @@ async def confirm_instant_switch(
if is_new_daily: if is_new_daily:
# Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры # Для суточного тарифа - сбрасываем на 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) # Списываем первый день если ещё не списано (upgrade_cost был 0)
if upgrade_cost == 0 and daily_price > 0: if upgrade_cost == 0 and daily_price > 0:
@@ -3186,6 +3336,7 @@ async def confirm_instant_switch(
db_user, db_user,
daily_price, daily_price,
f'Переключение на суточный тариф {new_tariff.name} (первый день)', f'Переключение на суточный тариф {new_tariff.name} (первый день)',
consume_promo_offer=consume_promo_for_daily,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -3199,6 +3350,22 @@ async def confirm_instant_switch(
description=f'Переключение на суточный тариф {new_tariff.name} (первый день)', 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.end_date = datetime.now(UTC) + timedelta(days=1)
subscription.is_trial = False subscription.is_trial = False
subscription.is_daily_paused = False subscription.is_daily_paused = False
@@ -3266,7 +3433,6 @@ async def confirm_instant_switch(
# Для суточного тарифа другое сообщение об успехе # Для суточного тарифа другое сообщение об успехе
if is_new_daily: if is_new_daily:
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
await callback.message.edit_text( await callback.message.edit_text(
f'🎉 <b>Тариф успешно изменён!</b>\n\n' f'🎉 <b>Тариф успешно изменён!</b>\n\n'
f'📦 Новый тариф: <b>{new_tariff.name}</b>\n' f'📦 Новый тариф: <b>{new_tariff.name}</b>\n'
+45 -18
View File
@@ -19,18 +19,16 @@ from app.keyboards.inline import (
get_reset_traffic_confirm_keyboard, get_reset_traffic_confirm_keyboard,
) )
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.services.pricing_engine import PricingEngine
from app.services.remnawave_service import RemnaWaveService from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service from app.services.user_cart_service import user_cart_service
from app.states import SubscriptionStates from app.states import SubscriptionStates
from app.utils.pricing_utils import ( from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_prorated_price, calculate_prorated_price,
) )
from .common import ( from .common import (
_apply_addon_discount,
_get_addon_discount_percent_for_user,
_get_period_hint_from_subscription, _get_period_hint_from_subscription,
get_confirm_switch_traffic_keyboard, get_confirm_switch_traffic_keyboard,
get_traffic_switch_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() packages = tariff.get_traffic_topup_packages()
period_hint_days = _get_period_hint_from_subscription(subscription) 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, db_user,
'traffic', 'traffic',
period_hint_days, 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 current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription) 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, db_user,
'traffic', 'traffic',
period_hint_days, 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) await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True)
return 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) texts = get_texts(db_user.language)
subscription = db_user.subscription 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) await callback.answer('⚠️ Цена для этого пакета не настроена', show_alert=True)
return 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) period_hint_days = _get_period_hint_from_subscription(subscription)
discount_result = _apply_addon_discount( discounted_per_month, discount_per_month, traffic_discount_pct = PricingEngine.calculate_traffic_discount(
db_user,
'traffic',
base_price, base_price,
db_user,
period_hint_days, period_hint_days,
) )
discounted_per_month = discount_result['discounted']
discount_per_month = discount_result['discount']
charged_days = 30 charged_days = 30
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней), # На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
@@ -510,7 +514,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
'traffic_gb': traffic_gb, 'traffic_gb': traffic_gb,
'price_kopeks': price, 'price_kopeks': price,
'base_price_kopeks': discounted_per_month, 'base_price_kopeks': discounted_per_month,
'discount_percent': discount_result['percent'], 'discount_percent': traffic_discount_pct,
'source': 'bot', 'source': 'bot',
'description': f'Докупка {traffic_gb} ГБ трафика', 'description': f'Докупка {traffic_gb} ГБ трафика',
} }
@@ -619,7 +623,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
if price > 0: if price > 0:
success_text += f'\n💰 Списано: {texts.format_price(price)}' success_text += f'\n💰 Списано: {texts.format_price(price)}'
if total_discount_value > 0: 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)) 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 base_traffic = current_traffic - purchased_traffic
period_hint_days = _get_period_hint_from_subscription(subscription) 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, db_user,
'traffic', 'traffic',
period_hint_days, period_hint_days,
@@ -722,17 +726,17 @@ async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, d
now = datetime.now(UTC) now = datetime.now(UTC)
days_remaining = max(1, (subscription.end_date - now).days) days_remaining = max(1, (subscription.end_date - now).days)
period_hint_days = days_remaining if days_remaining > 0 else None 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, db_user,
'traffic', 'traffic',
period_hint_days, period_hint_days,
) )
discounted_old_per_month, _ = apply_percentage_discount( discounted_old_per_month = PricingEngine.apply_discount(
old_price_per_month, old_price_per_month,
traffic_discount_percent, traffic_discount_percent,
) )
discounted_new_per_month, _ = apply_percentage_discount( discounted_new_per_month = PricingEngine.apply_discount(
new_price_per_month, new_price_per_month,
traffic_discount_percent, 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): async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
callback_parts = callback.data.split('_') callback_parts = callback.data.split('_')
new_traffic_gb = int(callback_parts[3]) 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) texts = get_texts(db_user.language)
subscription = db_user.subscription subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb 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: try:
if price_difference > 0: if price_difference > 0:
success = await subtract_user_balance( success = await subtract_user_balance(
+16 -2
View File
@@ -123,11 +123,25 @@ class DailySubscriptionService:
logger.warning('Тариф не найден для подписки', subscription_id=subscription.id) logger.warning('Тариф не найден для подписки', subscription_id=subscription.id)
return 'error' return 'error'
daily_price = tariff.daily_price_kopeks raw_daily_price = tariff.daily_price_kopeks
if daily_price <= 0: if raw_daily_price <= 0:
logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id) logger.warning('Некорректная суточная цена для тарифа', tariff_id=tariff.id)
return 'error' 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: if user.balance_kopeks < daily_price:
# Недостаточно средств - приостанавливаем подписку # Недостаточно средств - приостанавливаем подписку
+21 -5
View File
@@ -118,7 +118,9 @@ async def validate_and_calculate(
overrides = landing.discount_overrides or {} overrides = landing.discount_overrides or {}
tariff_override = overrides.get(str(tariff_id)) tariff_override = overrides.get(str(tariff_id))
effective_discount = tariff_override if tariff_override is not None else landing.discount_percent 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 return tariff, price_kopeks
@@ -283,6 +285,13 @@ async def fulfill_purchase(
) )
return 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: if existing_subscription is not None:
# Expired/inactive subscription — replace it # Expired/inactive subscription — replace it
existing_subscription.tariff_id = tariff.id existing_subscription.tariff_id = tariff.id
@@ -292,7 +301,7 @@ async def fulfill_purchase(
duration_days=purchase.period_days, duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb, traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit, device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [], connected_squads=squads,
is_trial=False, is_trial=False,
update_server_counters=True, update_server_counters=True,
) )
@@ -304,7 +313,7 @@ async def fulfill_purchase(
duration_days=purchase.period_days, duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb, traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit, device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [], connected_squads=squads,
tariff_id=tariff.id, tariff_id=tariff.id,
update_server_counters=True, 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) existing_subscription = await get_subscription_by_user_id(db, user.id)
subscription_service = SubscriptionService() 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: if existing_subscription is not None:
subscription = await replace_subscription( subscription = await replace_subscription(
db, db,
@@ -895,7 +911,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
duration_days=purchase.period_days, duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb, traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit, device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [], connected_squads=squads,
is_trial=False, is_trial=False,
update_server_counters=True, update_server_counters=True,
commit=False, commit=False,
@@ -908,7 +924,7 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
duration_days=purchase.period_days, duration_days=purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb, traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit, device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [], connected_squads=squads,
tariff_id=tariff.id, tariff_id=tariff.id,
update_server_counters=True, update_server_counters=True,
commit=False, commit=False,
+3
View File
@@ -1053,8 +1053,11 @@ class MonitoringService:
autopay_period = 30 autopay_period = 30
try: try:
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine from app.services.pricing_engine import pricing_engine
user = await lock_user_for_pricing(db, user.id)
pricing = await pricing_engine.calculate_renewal_price( pricing = await pricing_engine.calculate_renewal_price(
db, db,
subscription, subscription,
+8
View File
@@ -260,6 +260,14 @@ class TelegramStarsMixin:
logger.error('Не удалось активировать pending подписку пользователя', user_id=user.id) logger.error('Не удалось активировать pending подписку пользователя', user_id=user.id)
return False 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: try:
from app.services.subscription_service import SubscriptionService from app.services.subscription_service import SubscriptionService
+12
View File
@@ -912,6 +912,18 @@ class YooKassaPaymentMixin:
if subscription: if subscription:
logger.info('Подписка успешно активирована для пользователя', user_id=user.id) 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, чтобы получить актуальные ссылки # Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки
try: try:
remnawave_user = await subscription_service.create_remnawave_user(db, subscription) remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
+20 -6
View File
@@ -242,12 +242,23 @@ class AutoPaymentVerificationService:
) )
for record in candidates: for record in candidates:
refreshed = await run_manual_check( try:
session, refreshed = await run_manual_check(
record.method, session,
record.local_id, record.method,
self._payment_service, 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: if not refreshed:
logger.debug( logger.debug(
@@ -972,6 +983,9 @@ async def run_manual_check(
error=error, error=error,
exc_info=True, exc_info=True,
) )
# Откатываем сессию чтобы не оставлять её в грязном состоянии
if db.in_transaction():
await db.rollback()
return None return None
+548 -41
View File
@@ -15,7 +15,7 @@ from app.utils.promo_offer import get_user_active_promo_discount_percent
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession 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__) logger = structlog.get_logger(__name__)
@@ -27,8 +27,9 @@ class TariffBreakdown:
tariff_id: int tariff_id: int
extra_devices: int extra_devices: int
group_discount_pct: int group_discount_pct: dict[str, int]
offer_discount_pct: int offer_discount_pct: int
months_in_period: int = 1
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -42,9 +43,14 @@ class ClassicBreakdown:
base_traffic_gb: int base_traffic_gb: int
purchased_traffic_gb: int purchased_traffic_gb: int
extra_devices: 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] group_discount_pct: dict[str, int]
offer_discount_pct: 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) @dataclass(frozen=True)
@@ -68,6 +74,30 @@ class RenewalPricing:
return self.final_total + self.promo_group_discount + self.promo_offer_discount 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: class PricingEngine:
"""Unified pricing engine for all subscription renewal calculations.""" """Unified pricing engine for all subscription renewal calculations."""
@@ -93,6 +123,286 @@ class PricingEngine:
offer_discount_value = after_group - after_offer offer_discount_value = after_group - after_offer
return after_offer, group_discount_value, offer_discount_value 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:
"""Рассчитывает стоимость переключения тарифа.
Автоматически определяет тип переключения:
- periodicdaily: оплата первого дня (daily_price_kopeks)
- dailyperiodic: оплата кратчайшего периода нового тарифа
- periodicperiodic: пропорциональная разница дневных ставок × 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( async def _calculate_servers_price(
self, self,
country_uuids: list[str], country_uuids: list[str],
@@ -222,37 +532,99 @@ class PricingEngine:
) -> RenewalPricing: ) -> RenewalPricing:
"""Price calculation when subscription is linked to a Tariff.""" """Price calculation when subscription is linked to a Tariff."""
tariff = subscription.tariff tariff = subscription.tariff
period_prices: dict = tariff.period_prices or {} device_limit = subscription.device_limit or 0
base_price = int(period_prices.get(str(period_days), 0) 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 = ( device_price_per_unit = (
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE 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)) tariff_device_limit = tariff.device_limit or 0
devices_price = extra_devices * device_price_per_unit 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 # --- Per-category group discounts ---
group_pct = 0 period_pct = 0
if user and getattr(user, 'promo_group', None) is not None: devices_pct = 0
group_pct = user.promo_group.get_discount_percent('period', period_days) 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 offer_pct = get_user_active_promo_discount_percent(user) if user else 0
final_total, group_discount, offer_discount = self.apply_stacked_discounts( discounted_base = self.apply_discount(base_price, period_pct)
subtotal, discounted_devices = self.apply_discount(devices_price, devices_pct)
group_pct,
offer_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( breakdown = dataclasses.asdict(
TariffBreakdown( TariffBreakdown(
tariff_id=tariff.id, tariff_id=tariff.id,
extra_devices=extra_devices, extra_devices=extra_devices,
group_discount_pct=group_pct, group_discount_pct={'period': period_pct, 'devices': devices_pct},
offer_discount_pct=offer_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', 'Negative final_total in tariff mode, clamping to 0',
final_total=final_total, final_total=final_total,
subtotal=subtotal, subtotal=subtotal,
group_pct=group_pct, period_pct=period_pct,
devices_pct=devices_pct,
offer_pct=offer_pct, offer_pct=offer_pct,
) )
return RenewalPricing( return RenewalPricing(
base_price=base_price, base_price=discounted_base,
servers_price=0, servers_price=0,
traffic_price=0, traffic_price=discounted_traffic,
devices_price=devices_price, devices_price=discounted_devices,
promo_group_discount=group_discount, promo_group_discount=total_group_discount,
promo_offer_discount=offer_discount, promo_offer_discount=offer_discount,
final_total=max(0, final_total), final_total=max(0, final_total),
period_days=period_days, period_days=period_days,
@@ -278,19 +651,45 @@ class PricingEngine:
breakdown=breakdown, 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 # Classic mode
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _calculate_classic_mode( async def _calculate_classic_core(
self, self,
db: AsyncSession, db: AsyncSession,
subscription: Subscription,
period_days: int, period_days: int,
connected_squads: list[str],
traffic_limit_gb: int,
device_limit: int,
*, *,
purchased_traffic_gb: int = 0,
user: User | None = None, user: User | None = None,
) -> RenewalPricing: ) -> 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 Uses CLASSIC_PERIOD_PRICES from settings, falling back to the
global PERIOD_PRICES dict during migration. global PERIOD_PRICES dict during migration.
@@ -298,6 +697,7 @@ class PricingEngine:
Per-category discounts (period, servers, traffic, devices) are Per-category discounts (period, servers, traffic, devices) are
applied separately to each component. Servers, traffic, and applied separately to each component. Servers, traffic, and
devices are monthly prices multiplied by months_in_period. devices are monthly prices multiplied by months_in_period.
Promo-offer discount is applied on the subtotal.
""" """
months = calculate_months_from_days(period_days) months = calculate_months_from_days(period_days)
@@ -312,14 +712,13 @@ class PricingEngine:
fallback_price_kopeks=base_price_original, 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 period_pct = 0
servers_pct = 0 servers_pct = 0
traffic_pct = 0 traffic_pct = 0
devices_pct = 0 devices_pct = 0
promo_group = None promo_group = self.resolve_promo_group(user)
if user and getattr(user, 'promo_group', None) is not None: if promo_group is not None:
promo_group = user.promo_group
period_pct = promo_group.get_discount_percent('period', period_days) period_pct = promo_group.get_discount_percent('period', period_days)
servers_pct = promo_group.get_discount_percent('servers', period_days) servers_pct = promo_group.get_discount_percent('servers', period_days)
traffic_pct = promo_group.get_discount_percent('traffic', 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) base_price = self.apply_discount(base_price_original, period_pct)
# --- Servers (monthly × months, with servers discount) --- # --- 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 promo_group_id = getattr(user, 'promo_group_id', None) if user else None
servers_price_per_month, server_details = await self._calculate_servers_price( servers_price_per_month, server_details = await self._calculate_servers_price(
connected_squads, connected_squads,
@@ -345,13 +743,6 @@ class PricingEngine:
if settings.is_traffic_fixed(): if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit() traffic_limit_gb = settings.get_fixed_traffic_limit()
purchased_traffic_gb = 0 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) 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) discounted_traffic_per_month = self.apply_discount(traffic_price_per_month, traffic_pct)
traffic_price = discounted_traffic_per_month * months traffic_price = discounted_traffic_per_month * months
@@ -359,7 +750,7 @@ class PricingEngine:
# --- Devices (monthly × months, with devices discount) --- # --- Devices (monthly × months, with devices discount) ---
default_device_limit = settings.DEFAULT_DEVICE_LIMIT default_device_limit = settings.DEFAULT_DEVICE_LIMIT
device_price_per_unit = settings.PRICE_PER_DEVICE 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 devices_price_per_month = extra_devices * device_price_per_unit
discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct) discounted_devices_per_month = self.apply_discount(devices_price_per_month, devices_pct)
devices_price = discounted_devices_per_month * months devices_price = discounted_devices_per_month * months
@@ -386,7 +777,9 @@ class PricingEngine:
ClassicBreakdown( ClassicBreakdown(
months_in_period=months, months_in_period=months,
servers=server_details, 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], server_ids=[d['id'] for d in valid_servers],
base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb), base_traffic_gb=max(0, traffic_limit_gb - purchased_traffic_gb),
purchased_traffic_gb=purchased_traffic_gb, purchased_traffic_gb=purchased_traffic_gb,
@@ -398,6 +791,10 @@ class PricingEngine:
'devices': devices_pct, 'devices': devices_pct,
}, },
offer_discount_pct=offer_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, 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() # Module-level singleton — use this instead of PricingEngine()
pricing_engine = PricingEngine() pricing_engine = PricingEngine()
@@ -224,8 +224,12 @@ async def _process_single_subscription(
autopay_period = 30 autopay_period = 30
try: try:
from app.database.crud.user import lock_user_for_pricing
from app.services.pricing_engine import pricing_engine 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( pricing = await pricing_engine.calculate_renewal_price(
db, db,
subscription, subscription,
@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database.crud.subscription import extend_subscription from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction 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.database.models import Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService from app.services.admin_notification_service import AdminNotificationService
@@ -83,13 +83,11 @@ async def _prepare_auto_purchase(
) )
return None return None
# Перезагружаем user с нужными связями (user_promo_groups), # Блокируем user с нужными связями (user_promo_groups) для защиты от TOCTOU,
# т.к. после db.refresh() в payment-сервисах связи сбрасываются # т.к. после db.refresh() в payment-сервисах связи сбрасываются
fresh_user = await get_user_by_id(db, user.id) from app.database.crud.user import lock_user_for_pricing
if not fresh_user:
logger.warning('🔁 Автопокупка: не удалось перезагрузить пользователя', format_user_id=_format_user_id(user)) user = await lock_user_for_pricing(db, user.id)
return None
user = fresh_user
miniapp_service = MiniAppSubscriptionPurchaseService() miniapp_service = MiniAppSubscriptionPurchaseService()
context = await miniapp_service.build_options(db, user) context = await miniapp_service.build_options(db, user)
@@ -141,11 +139,6 @@ def _safe_int(value: object | None, default: int = 0) -> int:
return default 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( async def _prepare_auto_extend_context(
db: AsyncSession, db: AsyncSession,
user: User, user: User,
@@ -187,7 +180,11 @@ async def _prepare_auto_extend_context(
if tariff_id: if tariff_id:
tariff_id = _safe_int(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.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: try:
pricing = await _pricing_engine.calculate_renewal_price( 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) traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0)
squad_uuid = cart_data.get('squad_uuid') 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 consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
allowed_squads = cart_data.get('allowed_squads') allowed_squads = cart_data.get('allowed_squads')
@@ -625,44 +620,26 @@ async def _auto_purchase_tariff(
) )
return False return False
# Получаем актуальную цену тарифа # Lock user BEFORE price computation to prevent TOCTOU on promo offer
prices = tariff.period_prices or {} from app.database.crud.user import lock_user_for_pricing
base_price = prices.get(str(period_days))
if base_price is None:
logger.warning(
'🔁 Автопокупка тарифа: период дней недоступен для тарифа', period_days=period_days, tariff_id=tariff_id
)
return False
final_price = int(base_price)
# Проверяем есть ли уже подписка (нужно до расчёта цены для учёта доп. устройств)
existing_subscription = await get_subscription_by_user_id(db, user.id) 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: 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)) device_limit = existing_subscription.device_limit
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
# Пересчитываем скидку из актуальных данных пользователя (не из stale корзины) result = await pricing_engine.calculate_tariff_purchase_price(
# Promo_group и promo_offer применяются последовательно (как в cabinet) tariff,
from app.utils.promo_offer import get_user_active_promo_discount_percent period_days,
device_limit=device_limit,
discount_percent = 0 user=user,
if hasattr(user, 'get_promo_discount'): )
discount_percent = user.get_promo_discount('period', period_days) final_price = result.final_total
consume_promo = result.promo_offer_discount > 0
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)
if user.balance_kopeks < final_price: if user.balance_kopeks < final_price:
logger.info( logger.info(
@@ -674,7 +651,6 @@ async def _auto_purchase_tariff(
return False return False
# Save promo offer state before deduction (for restore on failure) # 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_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_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 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 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( logger.info(
'🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)', '🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)',
format_user_id=_format_user_id(user), format_user_id=_format_user_id(user),
balance_kopeks=user.balance_kopeks, balance_kopeks=user.balance_kopeks,
daily_price=daily_price, final_price=final_price,
) )
return False return False
@@ -993,8 +982,9 @@ async def _auto_purchase_daily_tariff(
success = await subtract_user_balance( success = await subtract_user_balance(
db, db,
user, user,
daily_price, final_price,
description, description,
consume_promo_offer=consume_promo,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
) )
if not success: if not success:
@@ -1081,7 +1071,7 @@ async def _auto_purchase_daily_tariff(
await add_user_balance( await add_user_balance(
db, db,
user, user,
daily_price, final_price,
'Возврат: ошибка автопокупки суточного тарифа', 'Возврат: ошибка автопокупки суточного тарифа',
create_transaction=True, create_transaction=True,
transaction_type=TransactionType.REFUND, transaction_type=TransactionType.REFUND,
@@ -1089,13 +1079,13 @@ async def _auto_purchase_daily_tariff(
logger.info( logger.info(
'💰 Автопокупка суточного тарифа: возврат средств после ошибки создания подписки', '💰 Автопокупка суточного тарифа: возврат средств после ошибки создания подписки',
format_user_id=_format_user_id(user), format_user_id=_format_user_id(user),
refund_kopeks=daily_price, refund_kopeks=final_price,
) )
except Exception as refund_error: except Exception as refund_error:
logger.critical( logger.critical(
'CRITICAL: Автопокупка суточного тарифа: не удалось вернуть средства', 'CRITICAL: Автопокупка суточного тарифа: не удалось вернуть средства',
format_user_id=_format_user_id(user), format_user_id=_format_user_id(user),
price_kopeks=daily_price, price_kopeks=final_price,
refund_error=refund_error, refund_error=refund_error,
) )
return False return False
@@ -1106,7 +1096,7 @@ async def _auto_purchase_daily_tariff(
db=db, db=db,
user_id=user.id, user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT, type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price, amount_kopeks=final_price,
description=description, description=description,
) )
except Exception as error: except Exception as error:
@@ -1167,7 +1157,7 @@ async def _auto_purchase_daily_tariff(
message = ( message = (
f'✅ <b>Суточный тариф «{tariff.name}» активирован!</b>\n\n' f'✅ <b>Суточный тариф «{tariff.name}» активирован!</b>\n\n'
f'💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n' f'💰 Списано: {final_price / 100:.0f} ₽ за первый день\n'
f'🔄 Средства будут списываться автоматически раз в сутки.\n\n' f'🔄 Средства будут списываться автоматически раз в сутки.\n\n'
f'ℹ️ Вы можете приостановить подписку в любой момент.' f'ℹ️ Вы можете приостановить подписку в любой момент.'
) )
@@ -1215,7 +1205,7 @@ async def _auto_purchase_daily_tariff(
await notify_user_subscription_renewed( await notify_user_subscription_renewed(
user_id=user.id, user_id=user.id,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '', new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=daily_price, amount_kopeks=final_price,
) )
else: else:
# New subscription activation # New subscription activation
@@ -1244,28 +1234,19 @@ async def _auto_add_devices(
"""Auto-purchase devices from saved cart after balance topup.""" """Auto-purchase devices from saved cart after balance topup."""
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 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.database.models import PaymentMethod
from app.utils.pricing_utils import apply_percentage_discount
devices_to_add = _safe_int(cart_data.get('devices_to_add')) 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( logger.warning(
'🔁 Автопокупка устройств: некорректные данные корзины для пользователя (devices price=)', '🔁 Автопокупка устройств: некорректные данные корзины для пользователя (devices price=)',
format_user_id=_format_user_id(user), format_user_id=_format_user_id(user),
devices_to_add=devices_to_add, devices_to_add=devices_to_add,
price_kopeks=price_kopeks, cart_price_kopeks=cart_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,
) )
return False return False
@@ -1330,6 +1311,44 @@ async def _auto_add_devices(
await user_cart_service.delete_user_cart(user.id) await user_cart_service.delete_user_cart(user.id)
return False 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} доп. устройств' description = f'Покупка {devices_to_add} доп. устройств'
try: try:
@@ -1519,28 +1538,19 @@ async def _auto_add_traffic(
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.database.crud.subscription import add_subscription_traffic, get_subscription_by_user_id 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.database.models import PaymentMethod
from app.utils.pricing_utils import calculate_prorated_price
traffic_gb = _safe_int(cart_data.get('traffic_gb')) 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( logger.warning(
'🔁 Автопокупка трафика: некорректные данные корзины для пользователя (traffic_gb price=)', '🔁 Автопокупка трафика: некорректные данные корзины для пользователя (traffic_gb price=)',
format_user_id=_format_user_id(user), format_user_id=_format_user_id(user),
traffic_gb=traffic_gb, traffic_gb=traffic_gb,
price_kopeks=price_kopeks, cart_price_kopeks=cart_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,
) )
return False return False
@@ -1572,6 +1582,72 @@ async def _auto_add_traffic(
await user_cart_service.delete_user_cart(user.id) await user_cart_service.delete_user_cart(user.id)
return False 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 # Deduct balance
description = f'Докупка {traffic_gb} ГБ трафика' description = f'Докупка {traffic_gb} ГБ трафика'
try: try:
@@ -1801,6 +1877,11 @@ async def try_auto_extend_expired_after_topup(
else: else:
period_days = 30 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 # Calculate renewal price via PricingEngine
subscription_service = SubscriptionService() subscription_service = SubscriptionService()
try: try:
@@ -1870,10 +1951,8 @@ async def try_auto_extend_expired_after_topup(
check_error=check_error, check_error=check_error,
) )
# Determine if promo offer discount was applied (for consume flag) # Derive consume_promo_offer from PricingEngine result (user already locked above)
from app.utils.promo_offer import get_user_active_promo_discount_percent consume_promo_offer = pricing.promo_offer_discount > 0
consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
# Save promo offer state before deduction (for restore on failure) # 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 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: if not tariff:
return False return False
daily_price = getattr(tariff, 'daily_price_kopeks', 0) raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0: if raw_daily_price <= 0:
return False 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: if user.balance_kopeks < daily_price:
logger.info( logger.info(
'🔄 Авто-возобновление daily: недостаточно средств', '🔄 Авто-возобновление daily: недостаточно средств',
+21 -55
View File
@@ -11,7 +11,6 @@ from app.config import PERIOD_PRICES, settings
from app.database.crud.server_squad import ( from app.database.crud.server_squad import (
add_user_to_servers, add_user_to_servers,
get_available_server_squads, get_available_server_squads,
get_server_ids_by_uuids,
get_server_squad_by_uuid, get_server_squad_by_uuid,
) )
from app.database.crud.subscription import ( from app.database.crud.subscription import (
@@ -32,7 +31,6 @@ from app.utils.pricing_utils import (
format_period_description, format_period_description,
validate_pricing_calculation, validate_pricing_calculation,
) )
from app.utils.promo_offer import get_user_active_promo_discount_percent
logger = structlog.get_logger(__name__) 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( def _build_server_option(
server: ServerSquad, server: ServerSquad,
discount_percent: int, discount_percent: int,
@@ -321,11 +307,9 @@ class MiniAppSubscriptionPurchaseService:
currency = (getattr(user, 'balance_currency', None) or 'RUB').upper() currency = (getattr(user, 'balance_currency', None) or 'RUB').upper()
texts = get_texts(getattr(user, 'language', None)) texts = get_texts(getattr(user, 'language', None))
# Exclude trial-only servers from purchase options
available_servers = await get_available_server_squads( available_servers = await get_available_server_squads(
db, db,
promo_group_id=getattr(user, 'promo_group_id', None), 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} 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)) get_texts(getattr(context.user, 'language', None))
months = selection.period.months 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): if len(server_ids) != len(selection.servers):
raise PurchaseValidationError('Some selected servers are not available', code='invalid_servers') raise PurchaseValidationError('Some selected servers are not available', code='invalid_servers')
total_without_promo, details = await self._calculate_base_total( details = PricingEngine.classic_pricing_to_purchase_details(pricing)
db,
context.user,
selection,
server_ids,
)
base_original_total = ( base_original_total = pricing.original_total
details['base_price_original'] discounted_total = pricing.final_total + pricing.promo_offer_discount # subtotal before offer
+ details['traffic_price_per_month'] * months promo_discount_value = pricing.promo_offer_discount
+ details['servers_price_per_month'] * months promo_percent = pricing.breakdown.get('offer_discount_pct', 0)
+ 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
is_valid = validate_pricing_calculation( is_valid = validate_pricing_calculation(
details.get('base_price', 0), details.get('base_price', 0),
@@ -755,30 +740,11 @@ class MiniAppSubscriptionPurchaseService:
discounted_total=discounted_total, discounted_total=discounted_total,
promo_discount_value=promo_discount_value, promo_discount_value=promo_discount_value,
promo_discount_percent=promo_percent, promo_discount_percent=promo_percent,
final_total=final_total, final_total=pricing.final_total,
months=months, months=months,
details=details, 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( def build_preview_payload(
self, self,
context: PurchaseOptionsContext, context: PurchaseOptionsContext,
+1 -119
View File
@@ -10,12 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database.crud.server_squad import get_all_server_squads from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.user import get_user_by_id 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.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 ( from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload, resolve_hwid_device_limit_for_payload,
) )
@@ -773,120 +769,6 @@ class SubscriptionService:
default_prices = [0] * len(country_uuids) default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices 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: def _gb_to_bytes(self, gb: int | None) -> int:
if not gb: # None or 0 if not gb: # None or 0
return 0 return 0
+16 -10
View File
@@ -133,9 +133,10 @@ class YooKassaService:
) )
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
response = await loop.run_in_executor( async with asyncio.timeout(30):
None, lambda: YooKassaPayment.create(payment_request, idempotence_key) response = await loop.run_in_executor(
) None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info( logger.info(
'Ответ YooKassa Payment.create: ID=, Status=, Paid', 'Ответ YooKassa Payment.create: ID=, Status=, Paid',
@@ -241,9 +242,10 @@ class YooKassaService:
) )
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
response = await loop.run_in_executor( async with asyncio.timeout(30):
None, lambda: YooKassaPayment.create(payment_request, idempotence_key) response = await loop.run_in_executor(
) None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info( logger.info(
'Ответ YooKassa Payment.create (СБП, redirect): ID=, Status=, Paid', 'Ответ 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) logger.info('Получение информации о платеже YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
loop = asyncio.get_running_loop() 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: if payment_info_yk:
logger.info( logger.info(
@@ -415,9 +420,10 @@ class YooKassaService:
) )
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
response = await loop.run_in_executor( async with asyncio.timeout(30):
None, lambda: YooKassaPayment.create(payment_request, idempotence_key) response = await loop.run_in_executor(
) None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info( logger.info(
'Ответ YooKassa автоплатёж', 'Ответ YooKassa автоплатёж',
+4 -6
View File
@@ -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) promo_offer_discount = get_user_active_promo_discount_percent(user)
# Apply both discounts sequentially (same as cabinet) # Apply both discounts sequentially via PricingEngine
final_price = base_price from app.services.pricing_engine import PricingEngine
if group_discount > 0:
final_price = final_price - (final_price * group_discount) // 100 final_price, _, _ = PricingEngine.apply_stacked_discounts(base_price, group_discount, promo_offer_discount)
if promo_offer_discount > 0:
final_price = final_price - (final_price * promo_offer_discount) // 100
# Effective combined discount percent # Effective combined discount percent
if final_price < base_price: if final_price < base_price:
+104 -120
View File
@@ -10,6 +10,7 @@ from app.config import settings
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from app.database.models import PromoGroup, User from app.database.models import PromoGroup, User
from app.services.pricing_engine import RenewalPricing
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -84,32 +85,45 @@ async def compute_simple_subscription_price(
user: Optional['User'] = None, user: Optional['User'] = None,
resolved_squad_uuids: Sequence[str] | None = None, resolved_squad_uuids: Sequence[str] | None = None,
) -> tuple[int, dict[str, Any]]: ) -> 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) 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') traffic_limit_raw = params.get('traffic_limit_gb')
try: 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 except (TypeError, ValueError): # pragma: no cover - defensive conversion
traffic_limit = None traffic_limit_gb = 0
# Treat None / non-positive as unlimited (0 GB → price = 0 in PricingEngine)
if traffic_limit is None or traffic_limit <= 0: traffic_limit_gb = max(traffic_limit_gb, 0)
# Default simple subscriptions already include unlimited traffic.
traffic_price_original = 0
else:
traffic_price_original = settings.get_traffic_price(traffic_limit)
device_limit_raw = params.get('device_limit', settings.DEFAULT_DEVICE_LIMIT) device_limit_raw = params.get('device_limit', settings.DEFAULT_DEVICE_LIMIT)
try: try:
device_limit = int(device_limit_raw) device_limit = int(device_limit_raw)
except (TypeError, ValueError): # pragma: no cover - defensive conversion except (TypeError, ValueError): # pragma: no cover - defensive conversion
device_limit = settings.DEFAULT_DEVICE_LIMIT 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') promo_group: PromoGroup | None = params.get('promo_group')
if promo_group is None: 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: if promo_group is None and user is not None:
promo_group = user.get_primary_promo_group() promo_group = user.get_primary_promo_group()
period_discount_percent = resolve_discount_percent( # --- Delegate to PricingEngine ---
user, engine = PricingEngine()
promo_group, pricing = await engine.calculate_classic_new_subscription_price(
'period', db,
period_days=period_days, period_days,
) resolved_uuids,
base_discount = base_price_original * period_discount_percent // 100 traffic_limit_gb,
device_limit,
traffic_discount_percent = resolve_discount_percent( user=user,
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,
) )
resolved_uuids: list[str] = [] # --- Build legacy breakdown dict from RenewalPricing + ClassicBreakdown ---
if resolved_squad_uuids: breakdown = _build_simple_subscription_breakdown(pricing, resolved_uuids, promo_group)
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))
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: def _build_simple_subscription_breakdown(
servers = await get_server_squads_by_uuids(db, resolved_uuids) pricing: 'RenewalPricing',
server_map = {s.squad_uuid: s for s in servers} resolved_uuids: list[str],
else: promo_group: Optional['PromoGroup'],
server_map = {} ) -> dict[str, Any]:
"""Convert PricingEngine's RenewalPricing to the legacy breakdown dict.
for squad_uuid in resolved_uuids: Preserves all keys that callers depend on:
server = server_map.get(squad_uuid) base_price, base_discount, traffic_price, traffic_discount,
if not server: devices_price, devices_discount, servers_price, servers_discount,
logger.warning('SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad', squad_uuid=squad_uuid) servers_final, server_details, total_before_discount, total_discount,
server_breakdown.append( resolved_squad_uuids, applied_promo_group_id, *_discount_percent.
{ """
'uuid': squad_uuid, from app.services.pricing_engine import PricingEngine
'name': None,
'available': False,
'original_price': 0,
'discount': 0,
'final_price': 0,
}
)
continue
if not server.is_available or server.is_full: bd = pricing.breakdown
logger.warning( months = bd.get('months_in_period', 1) or 1
'SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad= | available= | full', group_pct: dict[str, int] = bd.get('group_discount_pct', {})
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
original_price = server.price_kopeks # Original (pre-discount) prices from ClassicBreakdown
discount_value = original_price * servers_discount_percent // 100 base_price_original: int = bd.get('base_price_original', 0)
final_price = original_price - discount_value 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 # Per-category discount percents
servers_discount_total += discount_value 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, 'uuid': srv.get('uuid', ''),
'name': server.display_name, 'name': None, # PricingEngine._calculate_servers_price doesn't return display_name
'available': True, 'available': is_available,
'original_price': original_price, 'original_price': original_price if is_available else 0,
'discount': discount_value, 'discount': discount_value,
'final_price': final_price, 'final_price': final_price,
} }
) )
total_before_discount = ( return {
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 = {
'base_price': base_price_original, 'base_price': base_price_original,
'base_discount': base_discount, 'base_discount': base_discount,
'traffic_price': traffic_price_original, 'traffic_price': traffic_price_total,
'traffic_discount': traffic_discount, 'traffic_discount': traffic_discount,
'devices_price': devices_price_original, 'devices_price': devices_price_total,
'devices_discount': devices_discount, 'devices_discount': devices_discount,
'servers_price': servers_price_original, 'servers_price': servers_price_total,
'servers_discount': servers_discount_total, 'servers_discount': servers_discount,
'servers_final': sum(item['final_price'] for item in server_breakdown), 'servers_final': servers_final,
'server_details': server_breakdown, 'server_details': server_details,
'total_before_discount': total_before_discount, 'total_before_discount': total_before_discount,
'total_discount': total_discount, 'total_discount': total_discount,
'resolved_squad_uuids': resolved_uuids, 'resolved_squad_uuids': resolved_uuids,
@@ -257,8 +243,6 @@ async def compute_simple_subscription_price(
'servers_discount_percent': servers_discount_percent, 'servers_discount_percent': servers_discount_percent,
} }
return total_price, breakdown
def _pluralize_days_ru(n: int) -> str: def _pluralize_days_ru(n: int) -> str:
"""Склонение слова 'день' по числу: 1 день, 2 дня, 5 дней.""" """Склонение слова 'день' по числу: 1 день, 2 дня, 5 дней."""
+38
View File
@@ -35,6 +35,44 @@ def get_user_active_promo_discount_percent(user: User | None) -> int:
return max(0, min(100, percent)) 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: def _format_time_left(seconds_left: int, language: str) -> str:
total_minutes = max(1, math.ceil(seconds_left / 60)) total_minutes = max(1, math.ceil(seconds_left / 60))
days, remainder_minutes = divmod(total_minutes, 60 * 24) days, remainder_minutes = divmod(total_minutes, 60 * 24)
+195 -328
View File
@@ -58,6 +58,7 @@ from app.database.models import (
from app.services.faq_service import FaqService from app.services.faq_service import FaqService
from app.services.maintenance_service import maintenance_service from app.services.maintenance_service import maintenance_service
from app.services.payment_service import PaymentService, get_wata_payment_by_link_id 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.privacy_policy_service import PrivacyPolicyService
from app.services.promo_offer_service import promo_offer_service from app.services.promo_offer_service import promo_offer_service
from app.services.promocode_service import PromoCodeService from app.services.promocode_service import PromoCodeService
@@ -210,12 +211,11 @@ _CRYPTOBOT_FALLBACK_RATE = 95.0
def _get_tariff_monthly_price(tariff) -> int: def _get_tariff_monthly_price(tariff) -> int:
"""Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" """Получает месячную цену тарифа (30 дней) для отображения в UI."""
price = tariff.get_price_for_period(30) price = tariff.get_price_for_period(30)
if price is not None: if price is not None:
return price return price
# Fallback: пропорционально пересчитываем из первого доступного периода
periods = tariff.get_available_periods() periods = tariff.get_available_periods()
if periods: if periods:
first_period = periods[0] first_period = periods[0]
@@ -3341,6 +3341,15 @@ async def get_subscription_details(
is_daily_paused = getattr(subscription, 'is_daily_paused', False) is_daily_paused = getattr(subscription, 'is_daily_paused', False)
daily_tariff_name = tariff.name daily_tariff_name = tariff.name
daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) 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 daily_price_label = settings.format_price(daily_price_kopeks) + '/день' if daily_price_kopeks > 0 else None
# Оставшееся время подписки (показываем даже при паузе) # Оставшееся время подписки (показываем даже при паузе)
if subscription.end_date: 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 servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
# Получаем скидку на трафик из промогруппы # Скидка на трафик через PricingEngine
traffic_discount_percent = 0 from app.services.pricing_engine import PricingEngine, pricing_engine
promo_group = (
( promo_group = PricingEngine.resolve_promo_group(user) if user else None
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)))
# Лимит докупки трафика # Лимит докупки трафика
max_topup_traffic_gb = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 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 continue
base_price = packages[gb] base_price = packages[gb]
# Применяем скидку # Применяем скидку через PricingEngine
if traffic_discount_percent > 0: discounted_price, _discount_val, traffic_discount_pct = pricing_engine.calculate_traffic_discount(
discounted_price = int(base_price * (100 - traffic_discount_percent) / 100) base_price,
user,
)
if traffic_discount_pct > 0:
traffic_topup_packages.append( traffic_topup_packages.append(
MiniAppTrafficTopupPackage( MiniAppTrafficTopupPackage(
gb=gb, gb=gb,
@@ -3557,7 +3558,7 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -
price_label=settings.format_price(discounted_price), price_label=settings.format_price(discounted_price),
original_price_kopeks=base_price, original_price_kopeks=base_price,
original_price_label=settings.format_price(base_price), original_price_label=settings.format_price(base_price),
discount_percent=traffic_discount_percent, discount_percent=traffic_discount_pct,
) )
) )
else: else:
@@ -3577,15 +3578,9 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -
# Применяем скидку промогруппы для 30-дневного периода # Применяем скидку промогруппы для 30-дневного периода
if promo_group: if promo_group:
raw_discounts = getattr(promo_group, 'period_discounts', None) or {} discount = promo_group.get_discount_percent('period', 30)
for k, v in raw_discounts.items(): if discount > 0:
try: monthly_price = PricingEngine.apply_discount(monthly_price, discount)
if int(k) == 30:
discount = max(0, min(100, int(v)))
monthly_price = int(monthly_price * (100 - discount) / 100)
break
except (TypeError, ValueError):
pass
return MiniAppCurrentTariff( return MiniAppCurrentTariff(
id=tariff.id, id=tariff.id,
@@ -4613,32 +4608,6 @@ async def _prepare_subscription_renewal_options(
return periods, pricing_map, recommended_option[0].id 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( def _get_period_hint_from_subscription(
subscription: Subscription | None, subscription: Subscription | None,
) -> int | None: ) -> int | None:
@@ -4916,21 +4885,9 @@ async def _build_subscription_settings(
) -> MiniAppSubscriptionSettings: ) -> MiniAppSubscriptionSettings:
period_hint_days = _get_period_hint_from_subscription(subscription) period_hint_days = _get_period_hint_from_subscription(subscription)
months_remaining = max(1, math.ceil((period_hint_days or 0) / 30)) months_remaining = max(1, math.ceil((period_hint_days or 0) / 30))
servers_discount = _get_addon_discount_percent_for_user( servers_discount = PricingEngine.get_addon_discount_percent(user, 'servers', period_hint_days)
user, traffic_discount = PricingEngine.get_addon_discount_percent(user, 'traffic', period_hint_days)
'servers', devices_discount = PricingEngine.get_addon_discount_percent(user, 'devices', period_hint_days)
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,
)
current_servers, server_options, _ = await _prepare_server_catalog( current_servers, server_options, _ = await _prepare_server_catalog(
db, db,
@@ -5193,6 +5150,10 @@ async def submit_subscription_renewal_endpoint(
detail={'code': 'period_unavailable', 'message': 'Selected renewal period is not available'}, 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: try:
pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period_days, user=user) pricing_result = await pricing_engine.calculate_renewal_price(db, subscription, period_days, user=user)
except HTTPException: except HTTPException:
@@ -5450,6 +5411,10 @@ async def subscription_purchase_endpoint(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionPurchaseResponse: ) -> MiniAppSubscriptionPurchaseResponse:
user = await _authorize_miniapp_user(payload.init_data, db) 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) context = await purchase_service.build_options(db, user)
selection_payload = _merge_purchase_selection_from_request(payload) selection_payload = _merge_purchase_selection_from_request(payload)
@@ -5593,12 +5558,13 @@ async def update_subscription_servers_endpoint(
message='No changes', 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) period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount = _get_addon_discount_percent_for_user( servers_discount = PricingEngine.get_addon_discount_percent(user, 'servers', period_hint_days)
user,
'servers',
period_hint_days,
)
_, _, catalog = await _prepare_server_catalog( _, _, catalog = await _prepare_server_catalog(
db, db,
@@ -5816,23 +5782,18 @@ async def update_subscription_traffic_endpoint(
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
period_hint_days = days_remaining period_hint_days = days_remaining
traffic_discount = _get_addon_discount_percent_for_user(
user, # Lock user BEFORE discount computation to prevent TOCTOU on promo group
'traffic', from app.database.crud.user import lock_user_for_pricing
period_hint_days,
) 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) old_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
new_price_per_month = settings.get_traffic_price(new_traffic) new_price_per_month = settings.get_traffic_price(new_traffic)
discounted_old_per_month, _ = apply_percentage_discount( discounted_old_per_month = PricingEngine.apply_discount(old_price_per_month, traffic_discount)
old_price_per_month, discounted_new_per_month = PricingEngine.apply_discount(new_price_per_month, traffic_discount)
traffic_discount,
)
discounted_new_per_month, _ = apply_percentage_discount(
new_price_per_month,
traffic_discount,
)
price_difference_per_month = discounted_new_per_month - discounted_old_per_month price_difference_per_month = discounted_new_per_month - discounted_old_per_month
total_price_difference = 0 total_price_difference = 0
@@ -5998,16 +5959,15 @@ async def update_subscription_devices_endpoint(
price_per_month = chargeable_diff * tariff_device_price price_per_month = chargeable_diff * tariff_device_price
days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days) days_remaining = max(1, (subscription.end_date - datetime.now(UTC)).days)
period_hint_days = days_remaining period_hint_days = days_remaining
devices_discount = _get_addon_discount_percent_for_user(
user,
'devices',
period_hint_days,
)
discounted_per_month, _ = apply_percentage_discount( # Lock user BEFORE price computation to prevent TOCTOU on promo discount
price_per_month, from app.database.crud.user import lock_user_for_pricing
devices_discount,
) 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( price_to_charge, charged_days = calculate_prorated_price(
discounted_per_month, discounted_per_month,
subscription.end_date, 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] = [] periods: list[MiniAppTariffPeriod] = []
if tariff.period_prices: if tariff.period_prices:
for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
period_days = int(period_str) period_days = int(period_str)
# Применяем скидку промогруппы # Применяем скидку промогруппы + promo-offer (stacked)
discount_percent = period_discounts.get(period_days, 0) group_pct = promo_group.get_discount_percent('period', period_days) if promo_group else 0
if discount_percent > 0: offer_pct = get_user_active_promo_discount_percent(user) if user else 0
price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) 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: else:
price_kopeks = original_price_kopeks price_kopeks = original_price_kopeks
discount_percent = 0
months = max(1, period_days // 30) months = max(1, period_days // 30)
per_month = price_kopeks // months if months > 0 else price_kopeks per_month = price_kopeks // months if months > 0 else price_kopeks
@@ -6197,31 +6152,31 @@ async def _build_tariff_model(
is_switch_free = None is_switch_free = None
if current_tariff and current_tariff.id != tariff.id: if current_tariff and current_tariff.id != tariff.id:
current_is_daily = getattr(current_tariff, 'is_daily', False) # PricingEngine обрабатывает все случаи: periodic↔periodic, daily→periodic, periodic→daily
new_is_daily = getattr(tariff, 'is_daily', False) result = _calculate_tariff_switch(current_tariff, tariff, remaining_days, user=user)
switch_cost_kopeks = result.upgrade_cost
if current_is_daily and not new_is_daily: 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
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
# Суточный тариф # Суточный тариф
is_daily = getattr(tariff, 'is_daily', False) 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 = ( daily_price_label = (
settings.format_price(daily_price_kopeks) + '/день' if is_daily and daily_price_kopeks > 0 else None 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 servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
monthly_price = _get_tariff_monthly_price(tariff) monthly_price = _get_tariff_monthly_price(tariff)
# Применяем скидку промогруппы для 30-дневного периода # Применяем скидку промогруппы + promo-offer для 30-дневного периода
if promo_group: group_pct = promo_group.get_discount_percent('period', 30) if promo_group else 0
raw_discounts = getattr(promo_group, 'period_discounts', None) or {} offer_pct = get_user_active_promo_discount_percent(user) if user else 0
for k, v in raw_discounts.items(): if group_pct > 0 or offer_pct > 0:
try: monthly_price, _, _ = PricingEngine.apply_stacked_discounts(monthly_price, group_pct, offer_pct)
if int(k) == 30:
discount = max(0, min(100, int(v)))
monthly_price = int(monthly_price * (100 - discount) / 100)
break
except (TypeError, ValueError):
pass
# Суточный тариф # Суточный тариф
is_daily = getattr(tariff, 'is_daily', False) 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 = ( daily_price_label = (
settings.format_price(daily_price_kopeks) + '/день' if is_daily and daily_price_kopeks > 0 else None 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 = ( from app.services.pricing_engine import PricingEngine
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group') promo_group = PricingEngine.resolve_promo_group(user)
else getattr(user, 'promo_group', None)
)
promo_group_id = promo_group.id if promo_group else None promo_group_id = promo_group.id if promo_group else None
# Получаем тарифы, доступные пользователю # Получаем тарифы, доступные пользователю
@@ -6335,7 +6293,7 @@ async def get_tariffs_endpoint(
if current_tariff_id: if current_tariff_id:
current_tariff = await get_tariff_by_id(db, current_tariff_id) current_tariff = await get_tariff_by_id(db, current_tariff_id)
if current_tariff: 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] = [] 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 = ( promo_group = PricingEngine.resolve_promo_group(user)
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group')
else getattr(user, 'promo_group', None)
)
promo_group_id = promo_group.id if promo_group else None promo_group_id = promo_group.id if promo_group else None
if not tariff.is_available_for_promo_group(promo_group_id): if not tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException( 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) is_daily_tariff = getattr(tariff, 'is_daily', False)
if is_daily_tariff: if is_daily_tariff:
# Для суточного тарифа принудительно 1 день (защита от манипуляций с period_days)
payload.period_days = 1 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) subscription = getattr(user, 'subscription', None)
if not is_daily_tariff and subscription and subscription.tariff_id == tariff.id: device_limit = None
device_price_per_unit = ( if subscription and subscription.tariff_id == tariff.id:
tariff.device_price_kopeks if tariff.device_price_kopeks is not None else settings.PRICE_PER_DEVICE device_limit = subscription.device_limit
)
extra_devices = max(0, (subscription.device_limit or 0) - (tariff.device_limit or 0))
base_price_kopeks += extra_devices * device_price_per_unit
# Применяем скидку промогруппы (только для обычных тарифов, не для суточных) result = await pricing_engine.calculate_tariff_purchase_price(
price_kopeks = base_price_kopeks tariff,
discount_percent = 0 payload.period_days,
if not is_daily_tariff and promo_group: device_limit=device_limit,
raw_discounts = getattr(promo_group, 'period_discounts', None) or {} user=user,
for k, v in raw_discounts.items(): )
try: price_kopeks = result.final_total
if int(k) == payload.period_days: consume_promo_offer = result.promo_offer_discount > 0
discount_percent = max(0, min(100, int(v))) bd = result.breakdown
break group_pcts = bd.get('group_discount_pct', {})
except (TypeError, ValueError): discount_percent = group_pcts.get('period', 0)
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
# Проверяем баланс # Проверяем баланс
if user.balance_kopeks < price_kopeks: 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: def _calculate_tariff_switch(
"""Получает скидку пользователя на период (унифицировано с ботом)."""
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(
current_tariff, current_tariff,
new_tariff, new_tariff,
remaining_days: int, remaining_days: int,
promo_group=None,
user=None, user=None,
) -> tuple[int, bool]: ):
""" """Рассчитывает стоимость переключения тарифа.
Рассчитывает стоимость переключения тарифа.
Логика унифицирована с ботом (tariff_purchase.py).
Формула: (new_monthly - current_monthly) * remaining_days / 30
Скидка применяется к обоим тарифам одинаково.
Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost().
PricingEngine автоматически определяет тип переключения
(periodicperiodic, dailyperiodic, periodicdaily).
Returns: Returns:
(cost_kopeks, is_upgrade) - стоимость доплаты и флаг апгрейда TariffSwitchResult
""" """
current_monthly = _get_tariff_monthly_price(current_tariff) from app.services.pricing_engine import pricing_engine
new_monthly = _get_tariff_monthly_price(new_tariff)
discount_percent = _get_user_period_discount(user, 30) if user else 0 return pricing_engine.calculate_tariff_switch_cost(
current_tariff,
# Fallback на promo_group.period_discounts если user не передан new_tariff,
if discount_percent == 0 and promo_group: remaining_days,
raw_discounts = getattr(promo_group, 'period_discounts', None) or {} user=user,
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
@router.post('/subscription/tariff/switch/preview') @router.post('/subscription/tariff/switch/preview')
@@ -6725,11 +6604,9 @@ async def preview_tariff_switch_endpoint(
) )
# Проверяем доступность тарифа для пользователя # Проверяем доступность тарифа для пользователя
promo_group = ( from app.services.pricing_engine import PricingEngine
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group') promo_group = PricingEngine.resolve_promo_group(user)
else getattr(user, 'promo_group', None)
)
promo_group_id = promo_group.id if promo_group else None promo_group_id = promo_group.id if promo_group else None
if not new_tariff.is_available_for_promo_group(promo_group_id): if not new_tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException( raise HTTPException(
@@ -6743,22 +6620,10 @@ async def preview_tariff_switch_endpoint(
delta = subscription.end_date - datetime.now(UTC) delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days) remaining_days = max(0, delta.days)
# Рассчитываем стоимость переключения # Рассчитываем стоимость переключения (PricingEngine обрабатывает все случаи: periodic↔periodic, daily↔periodic)
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False switch_result = _calculate_tariff_switch(current_tariff, new_tariff, remaining_days, user=user)
new_is_daily = getattr(new_tariff, 'is_daily', False) upgrade_cost = switch_result.upgrade_cost
is_upgrade = switch_result.is_upgrade
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
)
balance = user.balance_kopeks or 0 balance = user.balance_kopeks or 0
has_enough = balance >= upgrade_cost has_enough = balance >= upgrade_cost
@@ -6836,11 +6701,9 @@ async def switch_tariff_endpoint(
) )
# Проверяем доступность тарифа # Проверяем доступность тарифа
promo_group = ( from app.services.pricing_engine import PricingEngine
user.get_primary_promo_group()
if hasattr(user, 'get_primary_promo_group') promo_group = PricingEngine.resolve_promo_group(user)
else getattr(user, 'promo_group', None)
)
promo_group_id = promo_group.id if promo_group else None promo_group_id = promo_group.id if promo_group else None
if not new_tariff.is_available_for_promo_group(promo_group_id): if not new_tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException( raise HTTPException(
@@ -6848,35 +6711,26 @@ async def switch_tariff_endpoint(
detail={'code': 'tariff_not_available', 'message': 'Tariff not available'}, 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 remaining_days = 0
if subscription.end_date and subscription.end_date > datetime.now(UTC): if subscription.end_date and subscription.end_date > datetime.now(UTC):
delta = subscription.end_date - datetime.now(UTC) delta = subscription.end_date - datetime.now(UTC)
remaining_days = max(0, delta.days) 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 current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
new_is_daily = getattr(new_tariff, 'is_daily', False) new_is_daily = getattr(new_tariff, 'is_daily', False)
switching_from_daily = current_is_daily and not new_is_daily 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 upgrade_cost > 0:
if user.balance_kopeks < upgrade_cost: if user.balance_kopeks < upgrade_cost:
@@ -6899,6 +6753,7 @@ async def switch_tariff_endpoint(
user, user,
upgrade_cost, upgrade_cost,
description, description,
consume_promo_offer=switch_result.offer_discount_pct > 0,
mark_as_paid_subscription=True, mark_as_paid_subscription=True,
commit=False, commit=False,
) )
@@ -7143,20 +6998,18 @@ async def purchase_traffic_topup_endpoint(
base_price_kopeks = packages[payload.gb] base_price_kopeks = packages[payload.gb]
# Применяем скидку промогруппы на трафик # Lock user BEFORE price computation to prevent TOCTOU on promo discount
traffic_discount_percent = 0 from app.database.crud.user import lock_user_for_pricing
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)))
if traffic_discount_percent > 0: user = await lock_user_for_pricing(db, user.id)
base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100)
# Применяем скидку промогруппы на трафик через 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( 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 new_paused_state = not is_currently_paused
subscription.is_daily_paused = new_paused_state 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: if not new_paused_state:
+1 -1
View File
@@ -127,7 +127,7 @@
Функции: нет Функции: нет
- `app/database/crud/subscription.py` — Python-модуль - `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-модуль - `app/database/crud/subscription_conversion.py` — Python-модуль
Классы: нет Классы: нет
Функции: нет Функции: нет
+23 -1
View File
@@ -265,6 +265,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.purchased_traffic_gb = 0 subscription.purchased_traffic_gb = 0
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_offer_discount_percent = 0 user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None user.promo_offer_expires_at = None
with ( with (
@@ -293,6 +294,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.purchased_traffic_gb = 0 subscription.purchased_traffic_gb = 0
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_offer_discount_percent = 0 user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None user.promo_offer_expires_at = None
with ( with (
@@ -319,6 +321,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.device_limit = 4 # 2 extra devices subscription.device_limit = 4 # 2 extra devices
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms, patch('app.services.pricing_engine.settings') as ms,
@@ -344,13 +347,14 @@ class TestCalculateRenewalPriceTariffMode:
promo_group.get_discount_percent.return_value = 10 promo_group.get_discount_percent.return_value = 10
user = MagicMock() user = MagicMock()
user.promo_group = promo_group user.promo_group = promo_group
user.get_primary_promo_group.return_value = promo_group
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=5),
patch('app.services.pricing_engine.settings') as ms, patch('app.services.pricing_engine.settings') as ms,
): ):
ms.PRICE_PER_DEVICE = 5000 ms.PRICE_PER_DEVICE = 5000
result = await engine.calculate_renewal_price(db, subscription, 30, user=user) 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 assert result.promo_group_discount == 2000
# After group: 18000, then 5% off 18000 = 900 # After group: 18000, then 5% off 18000 = 900
assert result.promo_offer_discount == 900 assert result.promo_offer_discount == 900
@@ -367,9 +371,12 @@ class TestCalculateRenewalPriceTariffMode:
subscription.tariff.device_limit = 1 subscription.tariff.device_limit = 1
subscription.tariff.device_price_kopeks = None subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 1 subscription.tariff.id = 1
subscription.tariff.is_daily = False
subscription.tariff.can_purchase_custom_days.return_value = False
subscription.device_limit = 1 subscription.device_limit = 1
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms, patch('app.services.pricing_engine.settings') as ms,
@@ -395,6 +402,7 @@ class TestCalculateRenewalPriceTariffMode:
sub.device_limit = 2 # less than tariff's 5 sub.device_limit = 2 # less than tariff's 5
user = MagicMock() user = MagicMock()
user.promo_group = None 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): 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) result = await engine.calculate_renewal_price(db, sub, 30, user=user)
@@ -441,6 +449,7 @@ class TestCalculateRenewalPriceClassicMode:
subscription.device_limit = 2 subscription.device_limit = 2
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
user.promo_offer_discount_percent = 0 user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None user.promo_offer_expires_at = None
@@ -479,6 +488,7 @@ class TestCalculateRenewalPriceClassicMode:
promo_group.get_discount_percent.return_value = 20 promo_group.get_discount_percent.return_value = 20
user = MagicMock() user = MagicMock()
user.promo_group = promo_group user.promo_group = promo_group
user.get_primary_promo_group.return_value = promo_group
user.promo_group_id = 1 user.promo_group_id = 1
user.promo_offer_discount_percent = 10 user.promo_offer_discount_percent = 10
user.promo_offer_expires_at = None user.promo_offer_expires_at = None
@@ -511,6 +521,7 @@ class TestCalculateRenewalPriceClassicMode:
subscription.device_limit = 1 subscription.device_limit = 1
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
@@ -539,6 +550,7 @@ class TestCalculateRenewalPriceClassicMode:
subscription.device_limit = 5 subscription.device_limit = 5
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
@@ -569,6 +581,7 @@ class TestCalculateRenewalPriceClassicMode:
subscription.device_limit = 1 subscription.device_limit = 1
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-found') 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') s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-found2')
@@ -610,6 +623,7 @@ class TestCalculateRenewalPriceClassicMode:
subscription.device_limit = 1 subscription.device_limit = 1
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
@@ -640,6 +654,7 @@ class TestCalculateRenewalPriceClassicMode:
subscription.device_limit = 1 subscription.device_limit = 1
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
with ( with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
@@ -669,6 +684,7 @@ class TestCalculateRenewalPriceClassicMode:
sub.device_limit = 1 sub.device_limit = 1
user = MagicMock() user = MagicMock()
user.promo_group = None user.promo_group = None
user.get_primary_promo_group.return_value = None
user.promo_group_id = None user.promo_group_id = None
server = _make_server(price_kopeks=3000, squad_uuid='uuid-s1') 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) promo_group.get_discount_percent = MagicMock(side_effect=discount_by_category)
user.promo_group = promo_group user.promo_group = promo_group
user.get_primary_promo_group.return_value = promo_group
user.promo_group_id = 1 user.promo_group_id = 1
server = _make_server(price_kopeks=6000, squad_uuid='uuid-s1') server = _make_server(price_kopeks=6000, squad_uuid='uuid-s1')
@@ -898,6 +915,7 @@ class TestOriginalPriceIdentity:
promo_group = MagicMock() promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=25) promo_group.get_discount_percent = MagicMock(return_value=25)
user.promo_group = promo_group 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): 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) result = await engine.calculate_renewal_price(db, sub, 30, user=user)
@@ -922,6 +940,7 @@ class TestOriginalPriceIdentity:
promo_group = MagicMock() promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=20) promo_group.get_discount_percent = MagicMock(return_value=20)
user.promo_group = promo_group user.promo_group = promo_group
user.get_primary_promo_group.return_value = promo_group
user.promo_group_id = 1 user.promo_group_id = 1
server = _make_server(price_kopeks=4000, squad_uuid='uuid-s1') server = _make_server(price_kopeks=4000, squad_uuid='uuid-s1')
@@ -965,6 +984,9 @@ class TestOriginalPriceIdentity:
promo_group = MagicMock() promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=10) promo_group.get_discount_percent = MagicMock(return_value=10)
user.promo_group = promo_group 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): 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) result = await engine.calculate_renewal_price(db, sub, 30, user=user)
assert result.original_total == 20000 # undiscounted subtotal assert result.original_total == 20000 # undiscounted subtotal