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:
+22
-36
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
# Недостаточно средств - приостанавливаем подписку
|
# Недостаточно средств - приостанавливаем подписку
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
"""Рассчитывает стоимость переключения тарифа.
|
||||||
|
|
||||||
|
Автоматически определяет тип переключения:
|
||||||
|
- periodic→daily: оплата первого дня (daily_price_kopeks)
|
||||||
|
- daily→periodic: оплата кратчайшего периода нового тарифа
|
||||||
|
- periodic→periodic: пропорциональная разница дневных ставок × remaining_days
|
||||||
|
|
||||||
|
Для всех типов переключений скидки (group + offer) применяются stacked.
|
||||||
|
"""
|
||||||
|
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||||
|
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||||
|
|
||||||
|
# Daily tariff edge cases
|
||||||
|
if not current_is_daily and new_is_daily:
|
||||||
|
return self._calculate_switch_to_daily(new_tariff, remaining_days, user=user)
|
||||||
|
if current_is_daily and not new_is_daily:
|
||||||
|
return self._calculate_switch_from_daily(new_tariff, remaining_days, user=user)
|
||||||
|
if current_is_daily and new_is_daily:
|
||||||
|
# Daily → Daily: бесплатное переключение, cron начислит новую цену завтра
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=0,
|
||||||
|
is_upgrade=False,
|
||||||
|
raw_cost=0,
|
||||||
|
group_discount_pct=0,
|
||||||
|
offer_discount_pct=0,
|
||||||
|
new_period_days=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Periodic → Periodic ---
|
||||||
|
|
||||||
|
# Early return: нечего считать при нулевом остатке
|
||||||
|
if remaining_days <= 0:
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=0,
|
||||||
|
is_upgrade=False,
|
||||||
|
raw_cost=0,
|
||||||
|
group_discount_pct=0,
|
||||||
|
offer_discount_pct=0,
|
||||||
|
new_period_days=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Целочисленная арифметика (без float round-trip):
|
||||||
|
# raw_cost = (new_p/new_d - cur_p/cur_d) * remaining
|
||||||
|
# = (new_p * cur_d - cur_p * new_d) * remaining / (new_d * cur_d)
|
||||||
|
# Floor division (//) округляет дробные копейки вниз — в пользу пользователя.
|
||||||
|
cur_price, cur_period = self.get_tariff_daily_rate_fraction(current_tariff, remaining_days)
|
||||||
|
new_price, new_period = self.get_tariff_daily_rate_fraction(new_tariff, remaining_days)
|
||||||
|
|
||||||
|
numerator = (new_price * cur_period - cur_price * new_period) * remaining_days
|
||||||
|
denominator = new_period * cur_period
|
||||||
|
raw_cost = max(0, numerator // denominator)
|
||||||
|
|
||||||
|
if numerator <= 0:
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=0,
|
||||||
|
is_upgrade=False,
|
||||||
|
raw_cost=0,
|
||||||
|
group_discount_pct=0,
|
||||||
|
offer_discount_pct=0,
|
||||||
|
new_period_days=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve discounts via resolve_promo_group (get_primary_promo_group first)
|
||||||
|
group_pct = 0
|
||||||
|
offer_pct = 0
|
||||||
|
if user:
|
||||||
|
promo_group = self.resolve_promo_group(user)
|
||||||
|
if promo_group is not None:
|
||||||
|
best_period = min(
|
||||||
|
current_tariff.get_available_periods() or [30],
|
||||||
|
key=lambda p: abs(p - remaining_days),
|
||||||
|
)
|
||||||
|
group_pct = promo_group.get_discount_percent('period', best_period)
|
||||||
|
offer_pct = get_user_active_promo_discount_percent(user)
|
||||||
|
|
||||||
|
# Применяем stacked скидки к итоговой сумме напрямую (без float round-trip)
|
||||||
|
if group_pct > 0 or offer_pct > 0:
|
||||||
|
upgrade_cost, _, _ = self.apply_stacked_discounts(raw_cost, group_pct, offer_pct)
|
||||||
|
else:
|
||||||
|
upgrade_cost = raw_cost
|
||||||
|
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=upgrade_cost,
|
||||||
|
is_upgrade=True,
|
||||||
|
raw_cost=raw_cost,
|
||||||
|
group_discount_pct=group_pct,
|
||||||
|
offer_discount_pct=offer_pct,
|
||||||
|
new_period_days=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_switch_to_daily(
|
||||||
|
self,
|
||||||
|
new_tariff: Tariff,
|
||||||
|
remaining_days: int,
|
||||||
|
*,
|
||||||
|
user: User | None = None,
|
||||||
|
) -> TariffSwitchResult:
|
||||||
|
"""Periodic → Daily: оплата первого дня с group + offer discount."""
|
||||||
|
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) or 0
|
||||||
|
if daily_price <= 0:
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=0,
|
||||||
|
is_upgrade=False,
|
||||||
|
raw_cost=0,
|
||||||
|
group_discount_pct=0,
|
||||||
|
offer_discount_pct=0,
|
||||||
|
new_period_days=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
group_pct = 0
|
||||||
|
offer_pct = 0
|
||||||
|
if user:
|
||||||
|
promo_group = self.resolve_promo_group(user)
|
||||||
|
if promo_group:
|
||||||
|
period_hint = remaining_days if remaining_days > 0 else 30
|
||||||
|
group_pct = promo_group.get_discount_percent('period', period_hint)
|
||||||
|
offer_pct = get_user_active_promo_discount_percent(user)
|
||||||
|
|
||||||
|
if group_pct > 0 or offer_pct > 0:
|
||||||
|
upgrade_cost, _, _ = self.apply_stacked_discounts(daily_price, group_pct, offer_pct)
|
||||||
|
else:
|
||||||
|
upgrade_cost = daily_price
|
||||||
|
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=upgrade_cost,
|
||||||
|
is_upgrade=upgrade_cost > 0,
|
||||||
|
raw_cost=daily_price,
|
||||||
|
group_discount_pct=group_pct,
|
||||||
|
offer_discount_pct=offer_pct,
|
||||||
|
new_period_days=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_switch_from_daily(
|
||||||
|
self,
|
||||||
|
new_tariff: Tariff,
|
||||||
|
remaining_days: int,
|
||||||
|
*,
|
||||||
|
user: User | None = None,
|
||||||
|
) -> TariffSwitchResult:
|
||||||
|
"""Daily → Periodic: оплата кратчайшего периода нового тарифа с group + offer discount."""
|
||||||
|
min_period_days = 30
|
||||||
|
min_period_price = 0
|
||||||
|
if new_tariff.period_prices:
|
||||||
|
min_period_days = min(int(k) for k in new_tariff.period_prices.keys())
|
||||||
|
min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) or 0
|
||||||
|
|
||||||
|
if min_period_price <= 0:
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=0,
|
||||||
|
is_upgrade=False,
|
||||||
|
raw_cost=0,
|
||||||
|
group_discount_pct=0,
|
||||||
|
offer_discount_pct=0,
|
||||||
|
new_period_days=min_period_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
group_pct = 0
|
||||||
|
offer_pct = 0
|
||||||
|
if user:
|
||||||
|
promo_group = self.resolve_promo_group(user)
|
||||||
|
if promo_group:
|
||||||
|
group_pct = promo_group.get_discount_percent('period', min_period_days)
|
||||||
|
offer_pct = get_user_active_promo_discount_percent(user)
|
||||||
|
|
||||||
|
if group_pct > 0 or offer_pct > 0:
|
||||||
|
upgrade_cost, _, _ = self.apply_stacked_discounts(min_period_price, group_pct, offer_pct)
|
||||||
|
else:
|
||||||
|
upgrade_cost = min_period_price
|
||||||
|
|
||||||
|
return TariffSwitchResult(
|
||||||
|
upgrade_cost=upgrade_cost,
|
||||||
|
is_upgrade=upgrade_cost > 0,
|
||||||
|
raw_cost=min_period_price,
|
||||||
|
group_discount_pct=group_pct,
|
||||||
|
offer_discount_pct=offer_pct,
|
||||||
|
new_period_days=min_period_days,
|
||||||
|
)
|
||||||
|
|
||||||
async def _calculate_servers_price(
|
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: недостаточно средств',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 автоплатёж',
|
||||||
|
|||||||
@@ -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
@@ -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 дней."""
|
||||||
|
|||||||
@@ -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
@@ -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 автоматически определяет тип переключения
|
||||||
|
(periodic↔periodic, daily→periodic, periodic→daily).
|
||||||
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:
|
||||||
|
|||||||
@@ -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-модуль
|
||||||
Классы: нет
|
Классы: нет
|
||||||
Функции: нет
|
Функции: нет
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user