From bf2ee37f336d86bba4c4c7a74a16f9bc51a80632 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 22 Oct 2025 02:12:23 +0300 Subject: [PATCH 1/5] Fix simple subscription pricing to include squad costs --- app/handlers/simple_subscription.py | 167 ++++++++++---------- app/handlers/subscription/purchase.py | 59 +++++-- app/utils/pricing_utils.py | 216 +++++++++++++++++++++++++- 3 files changed, 347 insertions(+), 95 deletions(-) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 1d3746ce..38d92f1e 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional, Tuple from aiogram import types, F from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -17,6 +17,7 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi from app.utils.decorators import error_handler from app.states import SubscriptionStates from app.utils.subscription_utils import get_display_subscription_link +from app.utils.pricing_utils import compute_simple_subscription_price logger = logging.getLogger(__name__) @@ -49,11 +50,7 @@ async def start_simple_subscription_purchase( # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) - - # Проверяем баланс пользователя - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + data = await state.get_data() resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( db, @@ -62,39 +59,38 @@ async def start_simple_subscription_purchase( user_id=db_user.id, state_data=data, ) - period_days = subscription_params["period_days"] - recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks) - direct_purchase_min_balance = recorded_price - extra_components = [] - traffic_limit = subscription_params.get("traffic_limit_gb", 0) - if traffic_limit and traffic_limit > 0: - traffic_price = settings.get_traffic_price(traffic_limit) - direct_purchase_min_balance += traffic_price - extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}") - device_limit = subscription_params.get("device_limit", 1) - if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT - devices_price = additional_devices * settings.PRICE_PER_DEVICE - direct_purchase_min_balance += devices_price - extra_components.append(f"devices+{additional_devices}->{devices_price}") - logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s", - db_user.id, - period_days, - price_kopeks, - recorded_price, - ",".join(extra_components) if extra_components else "none", - direct_purchase_min_balance, - getattr(settings, "PRICE_30_DAYS", None), + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, ) - can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance + period_days = subscription_params["period_days"] + user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + + logger.warning( + "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s", + db_user.id, + period_days, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + price_kopeks, + ",".join(price_breakdown.get("resolved_squad_uuids", [])) + if price_breakdown.get("resolved_squad_uuids") + else "none", + ) + + can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( "SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - direct_purchase_min_balance, + price_kopeks, can_pay_from_balance, ) @@ -158,27 +154,23 @@ async def start_simple_subscription_purchase( await callback.answer() -def _calculate_simple_subscription_price(params: dict) -> int: +async def _calculate_simple_subscription_price( + db: AsyncSession, + params: dict, + *, + user: Optional[User] = None, + resolved_squad_uuid: Optional[str] = None, +) -> Tuple[int, Dict[str, Any]]: """Рассчитывает цену простой подписки.""" - period_days = params.get("period_days", 30) - attr_name = f"PRICE_{period_days}_DAYS" - attr_value = getattr(settings, attr_name, None) - logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s", - period_days, - attr_name, - attr_value, - settings.BASE_SUBSCRIPTION_PRICE, + resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None + return await compute_simple_subscription_price( + db, + params, + user=user, + resolved_squad_uuids=resolved_uuids, ) - # Получаем цену для стандартного периода - if attr_value is not None: - return attr_value - else: - # Если нет цены для конкретного периода, используем базовую цену - return settings.BASE_SUBSCRIPTION_PRICE - def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: """Создает клавиатуру с методами оплаты для простой подписки.""" @@ -335,27 +327,22 @@ async def handle_simple_subscription_pay_with_balance( ) # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) - recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) - total_required = recorded_price - extras = [] - traffic_limit = subscription_params.get("traffic_limit_gb", 0) - if traffic_limit and traffic_limit > 0: - traffic_price = settings.get_traffic_price(traffic_limit) - total_required += traffic_price - extras.append(f"traffic={traffic_limit}GB->{traffic_price}") - device_limit = subscription_params.get("device_limit", 1) - if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT - devices_price = additional_devices * settings.PRICE_PER_DEVICE - total_required += devices_price - extras.append(f"devices+{additional_devices}->{devices_price}") + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) + total_required = price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", db_user.id, subscription_params["period_days"], - price_kopeks, - ",".join(extras) if extras else "none", + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), total_required, getattr(db_user, "balance_kopeks", 0), ) @@ -595,29 +582,38 @@ async def handle_simple_subscription_other_payment_methods( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return + resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( + db, + state, + subscription_params, + user_id=db_user.id, + state_data=data, + ) + # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) - total_required = recorded_price - if subscription_params.get("traffic_limit_gb", 0) > 0: - total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"]) - if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT - total_required += additional_devices * settings.PRICE_PER_DEVICE - can_pay_from_balance = user_balance_kopeks >= total_required + can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), price_kopeks, - total_required, can_pay_from_balance, ) # Отображаем доступные методы оплаты - resolved_squad_uuid = data.get("resolved_squad_uuid") server_label = _get_simple_subscription_server_label( texts, subscription_params, @@ -677,11 +673,8 @@ async def handle_simple_subscription_payment_method( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return - # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) - payment_method = callback.data.replace("simple_subscription_", "") - + try: payment_service = PaymentService(callback.bot) @@ -693,6 +686,14 @@ async def handle_simple_subscription_payment_method( state_data=data, ) + # Рассчитываем цену подписки + price_kopeks, _ = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) + if payment_method == "stars": # Оплата через Telegram Stars stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index bf743113..42a48741 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -64,6 +64,7 @@ from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( calculate_months_from_days, + compute_simple_subscription_price, get_remaining_months, calculate_prorated_price, validate_pricing_calculation, @@ -2412,7 +2413,22 @@ async def handle_simple_subscription_purchase( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=subscription_params.get("squad_uuid"), + ) + logger.debug( + "SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + db_user.id, + price_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + ) traffic_text = ( "Безлимит" if subscription_params["traffic_limit_gb"] == 0 @@ -2464,16 +2480,22 @@ async def handle_simple_subscription_purchase( -def _calculate_simple_subscription_price(params: dict) -> int: +async def _calculate_simple_subscription_price( + db: AsyncSession, + params: dict, + *, + user: Optional[User] = None, + resolved_squad_uuid: Optional[str] = None, +) -> Tuple[int, Dict[str, Any]]: """Рассчитывает цену простой подписки.""" - period_days = params.get("period_days", 30) - - # Получаем цену для стандартного периода - if hasattr(settings, f'PRICE_{period_days}_DAYS'): - return getattr(settings, f'PRICE_{period_days}_DAYS') - else: - # Если нет цены для конкретного периода, используем базовую цену - return settings.BASE_SUBSCRIPTION_PRICE + + resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None + return await compute_simple_subscription_price( + db, + params, + user=user, + resolved_squad_uuids=resolved_uuids, + ) def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: @@ -2564,7 +2586,22 @@ async def _extend_existing_subscription( "traffic_limit_gb": traffic_limit_gb, "squad_uuid": squad_uuid } - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=squad_uuid, + ) + logger.debug( + "SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + db_user.id, + price_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + ) # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index f54a8302..142a8b98 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,7 +1,14 @@ from datetime import datetime, timedelta -from typing import Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING import logging +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings + +if TYPE_CHECKING: # pragma: no cover + from app.database.models import User, PromoGroup + logger = logging.getLogger(__name__) @@ -68,6 +75,213 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value +def resolve_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Определяет размер скидки для указанной категории.""" + + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: # pragma: no cover - defensive guard + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + +async def compute_simple_subscription_price( + db: AsyncSession, + params: Dict[str, Any], + *, + user: Optional["User"] = None, + resolved_squad_uuids: Optional[Sequence[str]] = None, +) -> Tuple[int, Dict[str, Any]]: + """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" + + period_days = int(params.get("period_days", 30) or 30) + attr_name = f"PRICE_{period_days}_DAYS" + base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) + + traffic_limit_raw = params.get("traffic_limit_gb") + try: + traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None + except (TypeError, ValueError): # pragma: no cover - defensive conversion + traffic_limit = None + traffic_price_original = settings.get_traffic_price(traffic_limit) + + device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT) + try: + device_limit = int(device_limit_raw) + except (TypeError, ValueError): # pragma: no cover - defensive conversion + device_limit = settings.DEFAULT_DEVICE_LIMIT + additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) + devices_price_original = additional_devices * settings.PRICE_PER_DEVICE + + promo_group: Optional["PromoGroup"] = params.get("promo_group") + + if promo_group is None: + promo_group_id = params.get("promo_group_id") + if promo_group_id: + from app.database.crud.promo_group import get_promo_group_by_id + + promo_group = await get_promo_group_by_id(db, int(promo_group_id)) + + if promo_group is None and user is not None: + promo_group = getattr(user, "promo_group", None) + + period_discount_percent = resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount = base_price_original * period_discount_percent // 100 + discounted_base = base_price_original - base_discount + + traffic_discount_percent = resolve_discount_percent( + user, + promo_group, + "traffic", + period_days=period_days, + ) + traffic_discount = traffic_price_original * traffic_discount_percent // 100 + discounted_traffic = traffic_price_original - traffic_discount + + devices_discount_percent = resolve_discount_percent( + user, + promo_group, + "devices", + period_days=period_days, + ) + devices_discount = devices_price_original * devices_discount_percent // 100 + discounted_devices = devices_price_original - devices_discount + + servers_discount_percent = resolve_discount_percent( + user, + promo_group, + "servers", + period_days=period_days, + ) + + 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)) + + from app.database.crud.server_squad import get_server_squad_by_uuid + + server_breakdown: List[Dict[str, Any]] = [] + servers_price_original = 0 + servers_discount_total = 0 + + for squad_uuid in resolved_uuids: + server = await get_server_squad_by_uuid(db, squad_uuid) + if not server: + logger.warning( + "SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s", + squad_uuid, + ) + server_breakdown.append( + { + "uuid": squad_uuid, + "name": None, + "available": False, + "original_price": 0, + "discount": 0, + "final_price": 0, + } + ) + continue + + if not server.is_available or server.is_full: + logger.warning( + "SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s", + squad_uuid, + server.is_available, + 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 + discount_value = original_price * servers_discount_percent // 100 + final_price = original_price - discount_value + + servers_price_original += original_price + servers_discount_total += discount_value + + server_breakdown.append( + { + "uuid": squad_uuid, + "name": server.display_name, + "available": True, + "original_price": original_price, + "discount": discount_value, + "final_price": final_price, + } + ) + + total_before_discount = ( + base_price_original + + traffic_price_original + + devices_price_original + + servers_price_original + ) + + total_discount = ( + base_discount + + traffic_discount + + devices_discount + + servers_discount_total + ) + + total_price = max(0, total_before_discount - total_discount) + + breakdown = { + "base_price": base_price_original, + "base_discount": base_discount, + "traffic_price": traffic_price_original, + "traffic_discount": traffic_discount, + "devices_price": devices_price_original, + "devices_discount": devices_discount, + "servers_price": servers_price_original, + "servers_discount": servers_discount_total, + "servers_final": sum(item["final_price"] for item in server_breakdown), + "server_details": server_breakdown, + "total_before_discount": total_before_discount, + "total_discount": total_discount, + "resolved_squad_uuids": resolved_uuids, + "applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None, + "period_discount_percent": period_discount_percent, + "traffic_discount_percent": traffic_discount_percent, + "devices_discount_percent": devices_discount_percent, + "servers_discount_percent": servers_discount_percent, + } + + return total_price, breakdown + + def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) From bb17cf736116c4bda7adc4d4f55cea7b4bbb0903 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 22 Oct 2025 02:22:33 +0300 Subject: [PATCH 2/5] Revert "Fix simple subscription price calculations" --- app/handlers/simple_subscription.py | 157 ++++++++++--------- app/handlers/subscription/purchase.py | 59 ++----- app/utils/pricing_utils.py | 216 +------------------------- 3 files changed, 90 insertions(+), 342 deletions(-) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 38d92f1e..1d3746ce 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from typing import Any, Dict, Optional, Tuple +from typing import Optional, Dict, Any from aiogram import types, F from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -17,7 +17,6 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi from app.utils.decorators import error_handler from app.states import SubscriptionStates from app.utils.subscription_utils import get_display_subscription_link -from app.utils.pricing_utils import compute_simple_subscription_price logger = logging.getLogger(__name__) @@ -50,7 +49,11 @@ async def start_simple_subscription_purchase( # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) - + + # Проверяем баланс пользователя + user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + # Рассчитываем цену подписки + price_kopeks = _calculate_simple_subscription_price(subscription_params) data = await state.get_data() resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( db, @@ -59,38 +62,39 @@ async def start_simple_subscription_purchase( user_id=db_user.id, state_data=data, ) - - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) - period_days = subscription_params["period_days"] - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks) + direct_purchase_min_balance = recorded_price + extra_components = [] + traffic_limit = subscription_params.get("traffic_limit_gb", 0) + if traffic_limit and traffic_limit > 0: + traffic_price = settings.get_traffic_price(traffic_limit) + direct_purchase_min_balance += traffic_price + extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}") + device_limit = subscription_params.get("device_limit", 1) + if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: + additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT + devices_price = additional_devices * settings.PRICE_PER_DEVICE + direct_purchase_min_balance += devices_price + extra_components.append(f"devices+{additional_devices}->{devices_price}") logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s", db_user.id, period_days, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), price_kopeks, - ",".join(price_breakdown.get("resolved_squad_uuids", [])) - if price_breakdown.get("resolved_squad_uuids") - else "none", + recorded_price, + ",".join(extra_components) if extra_components else "none", + direct_purchase_min_balance, + getattr(settings, "PRICE_30_DAYS", None), ) - can_pay_from_balance = user_balance_kopeks >= price_kopeks + can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance logger.warning( "SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - price_kopeks, + direct_purchase_min_balance, can_pay_from_balance, ) @@ -154,23 +158,27 @@ async def start_simple_subscription_purchase( await callback.answer() -async def _calculate_simple_subscription_price( - db: AsyncSession, - params: dict, - *, - user: Optional[User] = None, - resolved_squad_uuid: Optional[str] = None, -) -> Tuple[int, Dict[str, Any]]: +def _calculate_simple_subscription_price(params: dict) -> int: """Рассчитывает цену простой подписки.""" + period_days = params.get("period_days", 30) + attr_name = f"PRICE_{period_days}_DAYS" + attr_value = getattr(settings, attr_name, None) - resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None - return await compute_simple_subscription_price( - db, - params, - user=user, - resolved_squad_uuids=resolved_uuids, + logger.warning( + "SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s", + period_days, + attr_name, + attr_value, + settings.BASE_SUBSCRIPTION_PRICE, ) + # Получаем цену для стандартного периода + if attr_value is not None: + return attr_value + else: + # Если нет цены для конкретного периода, используем базовую цену + return settings.BASE_SUBSCRIPTION_PRICE + def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: """Создает клавиатуру с методами оплаты для простой подписки.""" @@ -327,22 +335,27 @@ async def handle_simple_subscription_pay_with_balance( ) # Рассчитываем цену подписки - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) - total_required = price_kopeks + price_kopeks = _calculate_simple_subscription_price(subscription_params) + recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) + total_required = recorded_price + extras = [] + traffic_limit = subscription_params.get("traffic_limit_gb", 0) + if traffic_limit and traffic_limit > 0: + traffic_price = settings.get_traffic_price(traffic_limit) + total_required += traffic_price + extras.append(f"traffic={traffic_limit}GB->{traffic_price}") + device_limit = subscription_params.get("device_limit", 1) + if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: + additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT + devices_price = additional_devices * settings.PRICE_PER_DEVICE + total_required += devices_price + extras.append(f"devices+{additional_devices}->{devices_price}") logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s", db_user.id, subscription_params["period_days"], - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + price_kopeks, + ",".join(extras) if extras else "none", total_required, getattr(db_user, "balance_kopeks", 0), ) @@ -582,38 +595,29 @@ async def handle_simple_subscription_other_payment_methods( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return - resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( - db, - state, - subscription_params, - user_id=db_user.id, - state_data=data, - ) - # Рассчитываем цену подписки - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) + price_kopeks = _calculate_simple_subscription_price(subscription_params) user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - can_pay_from_balance = user_balance_kopeks >= price_kopeks + recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) + total_required = recorded_price + if subscription_params.get("traffic_limit_gb", 0) > 0: + total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"]) + if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT: + additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT + total_required += additional_devices * settings.PRICE_PER_DEVICE + can_pay_from_balance = user_balance_kopeks >= total_required logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), price_kopeks, + total_required, can_pay_from_balance, ) # Отображаем доступные методы оплаты + resolved_squad_uuid = data.get("resolved_squad_uuid") server_label = _get_simple_subscription_server_label( texts, subscription_params, @@ -673,8 +677,11 @@ async def handle_simple_subscription_payment_method( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return + # Рассчитываем цену подписки + price_kopeks = _calculate_simple_subscription_price(subscription_params) + payment_method = callback.data.replace("simple_subscription_", "") - + try: payment_service = PaymentService(callback.bot) @@ -686,14 +693,6 @@ async def handle_simple_subscription_payment_method( state_data=data, ) - # Рассчитываем цену подписки - price_kopeks, _ = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) - if payment_method == "stars": # Оплата через Telegram Stars stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 42a48741..bf743113 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -64,7 +64,6 @@ from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( calculate_months_from_days, - compute_simple_subscription_price, get_remaining_months, calculate_prorated_price, validate_pricing_calculation, @@ -2413,22 +2412,7 @@ async def handle_simple_subscription_purchase( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) # Рассчитываем цену подписки - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=subscription_params.get("squad_uuid"), - ) - logger.debug( - "SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", - db_user.id, - price_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), - ) + price_kopeks = _calculate_simple_subscription_price(subscription_params) traffic_text = ( "Безлимит" if subscription_params["traffic_limit_gb"] == 0 @@ -2480,22 +2464,16 @@ async def handle_simple_subscription_purchase( -async def _calculate_simple_subscription_price( - db: AsyncSession, - params: dict, - *, - user: Optional[User] = None, - resolved_squad_uuid: Optional[str] = None, -) -> Tuple[int, Dict[str, Any]]: +def _calculate_simple_subscription_price(params: dict) -> int: """Рассчитывает цену простой подписки.""" - - resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None - return await compute_simple_subscription_price( - db, - params, - user=user, - resolved_squad_uuids=resolved_uuids, - ) + period_days = params.get("period_days", 30) + + # Получаем цену для стандартного периода + if hasattr(settings, f'PRICE_{period_days}_DAYS'): + return getattr(settings, f'PRICE_{period_days}_DAYS') + else: + # Если нет цены для конкретного периода, используем базовую цену + return settings.BASE_SUBSCRIPTION_PRICE def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: @@ -2586,22 +2564,7 @@ async def _extend_existing_subscription( "traffic_limit_gb": traffic_limit_gb, "squad_uuid": squad_uuid } - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=squad_uuid, - ) - logger.debug( - "SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", - db_user.id, - price_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), - ) + price_kopeks = _calculate_simple_subscription_price(subscription_params) # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 142a8b98..f54a8302 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,14 +1,7 @@ from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING +from typing import Tuple import logging -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings - -if TYPE_CHECKING: # pragma: no cover - from app.database.models import User, PromoGroup - logger = logging.getLogger(__name__) @@ -75,213 +68,6 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value -def resolve_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - """Определяет размер скидки для указанной категории.""" - - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: # pragma: no cover - defensive guard - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - -async def compute_simple_subscription_price( - db: AsyncSession, - params: Dict[str, Any], - *, - user: Optional["User"] = None, - resolved_squad_uuids: Optional[Sequence[str]] = None, -) -> Tuple[int, Dict[str, Any]]: - """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" - - period_days = int(params.get("period_days", 30) or 30) - attr_name = f"PRICE_{period_days}_DAYS" - base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) - - traffic_limit_raw = params.get("traffic_limit_gb") - try: - traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None - except (TypeError, ValueError): # pragma: no cover - defensive conversion - traffic_limit = None - traffic_price_original = settings.get_traffic_price(traffic_limit) - - device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT) - try: - device_limit = int(device_limit_raw) - except (TypeError, ValueError): # pragma: no cover - defensive conversion - device_limit = settings.DEFAULT_DEVICE_LIMIT - additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) - devices_price_original = additional_devices * settings.PRICE_PER_DEVICE - - promo_group: Optional["PromoGroup"] = params.get("promo_group") - - if promo_group is None: - promo_group_id = params.get("promo_group_id") - if promo_group_id: - from app.database.crud.promo_group import get_promo_group_by_id - - promo_group = await get_promo_group_by_id(db, int(promo_group_id)) - - if promo_group is None and user is not None: - promo_group = getattr(user, "promo_group", None) - - period_discount_percent = resolve_discount_percent( - user, - promo_group, - "period", - period_days=period_days, - ) - base_discount = base_price_original * period_discount_percent // 100 - discounted_base = base_price_original - base_discount - - traffic_discount_percent = resolve_discount_percent( - user, - promo_group, - "traffic", - period_days=period_days, - ) - traffic_discount = traffic_price_original * traffic_discount_percent // 100 - discounted_traffic = traffic_price_original - traffic_discount - - devices_discount_percent = resolve_discount_percent( - user, - promo_group, - "devices", - period_days=period_days, - ) - devices_discount = devices_price_original * devices_discount_percent // 100 - discounted_devices = devices_price_original - devices_discount - - servers_discount_percent = resolve_discount_percent( - user, - promo_group, - "servers", - period_days=period_days, - ) - - 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)) - - from app.database.crud.server_squad import get_server_squad_by_uuid - - server_breakdown: List[Dict[str, Any]] = [] - servers_price_original = 0 - servers_discount_total = 0 - - for squad_uuid in resolved_uuids: - server = await get_server_squad_by_uuid(db, squad_uuid) - if not server: - logger.warning( - "SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s", - squad_uuid, - ) - server_breakdown.append( - { - "uuid": squad_uuid, - "name": None, - "available": False, - "original_price": 0, - "discount": 0, - "final_price": 0, - } - ) - continue - - if not server.is_available or server.is_full: - logger.warning( - "SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s", - squad_uuid, - server.is_available, - 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 - discount_value = original_price * servers_discount_percent // 100 - final_price = original_price - discount_value - - servers_price_original += original_price - servers_discount_total += discount_value - - server_breakdown.append( - { - "uuid": squad_uuid, - "name": server.display_name, - "available": True, - "original_price": original_price, - "discount": discount_value, - "final_price": final_price, - } - ) - - total_before_discount = ( - base_price_original - + traffic_price_original - + devices_price_original - + servers_price_original - ) - - total_discount = ( - base_discount - + traffic_discount - + devices_discount - + servers_discount_total - ) - - total_price = max(0, total_before_discount - total_discount) - - breakdown = { - "base_price": base_price_original, - "base_discount": base_discount, - "traffic_price": traffic_price_original, - "traffic_discount": traffic_discount, - "devices_price": devices_price_original, - "devices_discount": devices_discount, - "servers_price": servers_price_original, - "servers_discount": servers_discount_total, - "servers_final": sum(item["final_price"] for item in server_breakdown), - "server_details": server_breakdown, - "total_before_discount": total_before_discount, - "total_discount": total_discount, - "resolved_squad_uuids": resolved_uuids, - "applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None, - "period_discount_percent": period_discount_percent, - "traffic_discount_percent": traffic_discount_percent, - "devices_discount_percent": devices_discount_percent, - "servers_discount_percent": servers_discount_percent, - } - - return total_price, breakdown - - def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) From 008f62e02bfdea848532af47f355b90b69f036f3 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 22 Oct 2025 02:22:50 +0300 Subject: [PATCH 3/5] Add async test runner shim and fix Pal24 adapter test --- app/handlers/simple_subscription.py | 167 +++++++------- app/handlers/subscription/purchase.py | 59 ++++- app/utils/pricing_utils.py | 216 ++++++++++++++++++- tests/conftest.py | 49 +++++ tests/services/test_pal24_service_adapter.py | 2 +- tests/test_user_cart_service.py | 1 + 6 files changed, 398 insertions(+), 96 deletions(-) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 1d3746ce..38d92f1e 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional, Tuple from aiogram import types, F from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -17,6 +17,7 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi from app.utils.decorators import error_handler from app.states import SubscriptionStates from app.utils.subscription_utils import get_display_subscription_link +from app.utils.pricing_utils import compute_simple_subscription_price logger = logging.getLogger(__name__) @@ -49,11 +50,7 @@ async def start_simple_subscription_purchase( # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) - - # Проверяем баланс пользователя - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + data = await state.get_data() resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( db, @@ -62,39 +59,38 @@ async def start_simple_subscription_purchase( user_id=db_user.id, state_data=data, ) - period_days = subscription_params["period_days"] - recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks) - direct_purchase_min_balance = recorded_price - extra_components = [] - traffic_limit = subscription_params.get("traffic_limit_gb", 0) - if traffic_limit and traffic_limit > 0: - traffic_price = settings.get_traffic_price(traffic_limit) - direct_purchase_min_balance += traffic_price - extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}") - device_limit = subscription_params.get("device_limit", 1) - if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT - devices_price = additional_devices * settings.PRICE_PER_DEVICE - direct_purchase_min_balance += devices_price - extra_components.append(f"devices+{additional_devices}->{devices_price}") - logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s", - db_user.id, - period_days, - price_kopeks, - recorded_price, - ",".join(extra_components) if extra_components else "none", - direct_purchase_min_balance, - getattr(settings, "PRICE_30_DAYS", None), + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, ) - can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance + period_days = subscription_params["period_days"] + user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + + logger.warning( + "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s", + db_user.id, + period_days, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + price_kopeks, + ",".join(price_breakdown.get("resolved_squad_uuids", [])) + if price_breakdown.get("resolved_squad_uuids") + else "none", + ) + + can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( "SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - direct_purchase_min_balance, + price_kopeks, can_pay_from_balance, ) @@ -158,27 +154,23 @@ async def start_simple_subscription_purchase( await callback.answer() -def _calculate_simple_subscription_price(params: dict) -> int: +async def _calculate_simple_subscription_price( + db: AsyncSession, + params: dict, + *, + user: Optional[User] = None, + resolved_squad_uuid: Optional[str] = None, +) -> Tuple[int, Dict[str, Any]]: """Рассчитывает цену простой подписки.""" - period_days = params.get("period_days", 30) - attr_name = f"PRICE_{period_days}_DAYS" - attr_value = getattr(settings, attr_name, None) - logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s", - period_days, - attr_name, - attr_value, - settings.BASE_SUBSCRIPTION_PRICE, + resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None + return await compute_simple_subscription_price( + db, + params, + user=user, + resolved_squad_uuids=resolved_uuids, ) - # Получаем цену для стандартного периода - if attr_value is not None: - return attr_value - else: - # Если нет цены для конкретного периода, используем базовую цену - return settings.BASE_SUBSCRIPTION_PRICE - def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: """Создает клавиатуру с методами оплаты для простой подписки.""" @@ -335,27 +327,22 @@ async def handle_simple_subscription_pay_with_balance( ) # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) - recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) - total_required = recorded_price - extras = [] - traffic_limit = subscription_params.get("traffic_limit_gb", 0) - if traffic_limit and traffic_limit > 0: - traffic_price = settings.get_traffic_price(traffic_limit) - total_required += traffic_price - extras.append(f"traffic={traffic_limit}GB->{traffic_price}") - device_limit = subscription_params.get("device_limit", 1) - if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT - devices_price = additional_devices * settings.PRICE_PER_DEVICE - total_required += devices_price - extras.append(f"devices+{additional_devices}->{devices_price}") + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) + total_required = price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", db_user.id, subscription_params["period_days"], - price_kopeks, - ",".join(extras) if extras else "none", + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), total_required, getattr(db_user, "balance_kopeks", 0), ) @@ -595,29 +582,38 @@ async def handle_simple_subscription_other_payment_methods( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return + resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( + db, + state, + subscription_params, + user_id=db_user.id, + state_data=data, + ) + # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) - total_required = recorded_price - if subscription_params.get("traffic_limit_gb", 0) > 0: - total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"]) - if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT - total_required += additional_devices * settings.PRICE_PER_DEVICE - can_pay_from_balance = user_balance_kopeks >= total_required + can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), price_kopeks, - total_required, can_pay_from_balance, ) # Отображаем доступные методы оплаты - resolved_squad_uuid = data.get("resolved_squad_uuid") server_label = _get_simple_subscription_server_label( texts, subscription_params, @@ -677,11 +673,8 @@ async def handle_simple_subscription_payment_method( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return - # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) - payment_method = callback.data.replace("simple_subscription_", "") - + try: payment_service = PaymentService(callback.bot) @@ -693,6 +686,14 @@ async def handle_simple_subscription_payment_method( state_data=data, ) + # Рассчитываем цену подписки + price_kopeks, _ = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) + if payment_method == "stars": # Оплата через Telegram Stars stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index bf743113..42a48741 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -64,6 +64,7 @@ from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( calculate_months_from_days, + compute_simple_subscription_price, get_remaining_months, calculate_prorated_price, validate_pricing_calculation, @@ -2412,7 +2413,22 @@ async def handle_simple_subscription_purchase( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=subscription_params.get("squad_uuid"), + ) + logger.debug( + "SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + db_user.id, + price_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + ) traffic_text = ( "Безлимит" if subscription_params["traffic_limit_gb"] == 0 @@ -2464,16 +2480,22 @@ async def handle_simple_subscription_purchase( -def _calculate_simple_subscription_price(params: dict) -> int: +async def _calculate_simple_subscription_price( + db: AsyncSession, + params: dict, + *, + user: Optional[User] = None, + resolved_squad_uuid: Optional[str] = None, +) -> Tuple[int, Dict[str, Any]]: """Рассчитывает цену простой подписки.""" - period_days = params.get("period_days", 30) - - # Получаем цену для стандартного периода - if hasattr(settings, f'PRICE_{period_days}_DAYS'): - return getattr(settings, f'PRICE_{period_days}_DAYS') - else: - # Если нет цены для конкретного периода, используем базовую цену - return settings.BASE_SUBSCRIPTION_PRICE + + resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None + return await compute_simple_subscription_price( + db, + params, + user=user, + resolved_squad_uuids=resolved_uuids, + ) def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: @@ -2564,7 +2586,22 @@ async def _extend_existing_subscription( "traffic_limit_gb": traffic_limit_gb, "squad_uuid": squad_uuid } - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=squad_uuid, + ) + logger.debug( + "SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + db_user.id, + price_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + ) # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index f54a8302..142a8b98 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,7 +1,14 @@ from datetime import datetime, timedelta -from typing import Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING import logging +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings + +if TYPE_CHECKING: # pragma: no cover + from app.database.models import User, PromoGroup + logger = logging.getLogger(__name__) @@ -68,6 +75,213 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value +def resolve_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Определяет размер скидки для указанной категории.""" + + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: # pragma: no cover - defensive guard + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + +async def compute_simple_subscription_price( + db: AsyncSession, + params: Dict[str, Any], + *, + user: Optional["User"] = None, + resolved_squad_uuids: Optional[Sequence[str]] = None, +) -> Tuple[int, Dict[str, Any]]: + """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" + + period_days = int(params.get("period_days", 30) or 30) + attr_name = f"PRICE_{period_days}_DAYS" + base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) + + traffic_limit_raw = params.get("traffic_limit_gb") + try: + traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None + except (TypeError, ValueError): # pragma: no cover - defensive conversion + traffic_limit = None + traffic_price_original = settings.get_traffic_price(traffic_limit) + + device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT) + try: + device_limit = int(device_limit_raw) + except (TypeError, ValueError): # pragma: no cover - defensive conversion + device_limit = settings.DEFAULT_DEVICE_LIMIT + additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) + devices_price_original = additional_devices * settings.PRICE_PER_DEVICE + + promo_group: Optional["PromoGroup"] = params.get("promo_group") + + if promo_group is None: + promo_group_id = params.get("promo_group_id") + if promo_group_id: + from app.database.crud.promo_group import get_promo_group_by_id + + promo_group = await get_promo_group_by_id(db, int(promo_group_id)) + + if promo_group is None and user is not None: + promo_group = getattr(user, "promo_group", None) + + period_discount_percent = resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount = base_price_original * period_discount_percent // 100 + discounted_base = base_price_original - base_discount + + traffic_discount_percent = resolve_discount_percent( + user, + promo_group, + "traffic", + period_days=period_days, + ) + traffic_discount = traffic_price_original * traffic_discount_percent // 100 + discounted_traffic = traffic_price_original - traffic_discount + + devices_discount_percent = resolve_discount_percent( + user, + promo_group, + "devices", + period_days=period_days, + ) + devices_discount = devices_price_original * devices_discount_percent // 100 + discounted_devices = devices_price_original - devices_discount + + servers_discount_percent = resolve_discount_percent( + user, + promo_group, + "servers", + period_days=period_days, + ) + + 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)) + + from app.database.crud.server_squad import get_server_squad_by_uuid + + server_breakdown: List[Dict[str, Any]] = [] + servers_price_original = 0 + servers_discount_total = 0 + + for squad_uuid in resolved_uuids: + server = await get_server_squad_by_uuid(db, squad_uuid) + if not server: + logger.warning( + "SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s", + squad_uuid, + ) + server_breakdown.append( + { + "uuid": squad_uuid, + "name": None, + "available": False, + "original_price": 0, + "discount": 0, + "final_price": 0, + } + ) + continue + + if not server.is_available or server.is_full: + logger.warning( + "SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s", + squad_uuid, + server.is_available, + 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 + discount_value = original_price * servers_discount_percent // 100 + final_price = original_price - discount_value + + servers_price_original += original_price + servers_discount_total += discount_value + + server_breakdown.append( + { + "uuid": squad_uuid, + "name": server.display_name, + "available": True, + "original_price": original_price, + "discount": discount_value, + "final_price": final_price, + } + ) + + total_before_discount = ( + base_price_original + + traffic_price_original + + devices_price_original + + servers_price_original + ) + + total_discount = ( + base_discount + + traffic_discount + + devices_discount + + servers_discount_total + ) + + total_price = max(0, total_before_discount - total_discount) + + breakdown = { + "base_price": base_price_original, + "base_discount": base_discount, + "traffic_price": traffic_price_original, + "traffic_discount": traffic_discount, + "devices_price": devices_price_original, + "devices_discount": devices_discount, + "servers_price": servers_price_original, + "servers_discount": servers_discount_total, + "servers_final": sum(item["final_price"] for item in server_breakdown), + "server_details": server_breakdown, + "total_before_discount": total_before_discount, + "total_discount": total_discount, + "resolved_squad_uuids": resolved_uuids, + "applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None, + "period_discount_percent": period_discount_percent, + "traffic_discount_percent": traffic_discount_percent, + "devices_discount_percent": devices_discount_percent, + "servers_discount_percent": servers_discount_percent, + } + + return total_price, breakdown + + def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) diff --git a/tests/conftest.py b/tests/conftest.py index 54fd8a56..242eff3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ """Глобальные фикстуры и настройки окружения для тестов.""" +import asyncio +import inspect import os import sys import types @@ -149,3 +151,50 @@ if "yookassa" not in sys.modules: def fixed_datetime() -> datetime: """Возвращает фиксированную отметку времени для воспроизводимых проверок.""" return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +def pytest_configure(config: pytest.Config) -> None: + """Регистрируем маркеры для асинхронных тестов.""" + + config.addinivalue_line( + "markers", + "asyncio: запуск асинхронного теста через встроенный цикл событий", + ) + config.addinivalue_line( + "markers", + "anyio: запуск асинхронного теста через встроенный цикл событий", + ) + + +def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine + """Возвращает исходную функцию, снимая обёртки pytest и декораторов.""" + + unwrapped = obj + while hasattr(unwrapped, "__wrapped__"): + unwrapped = unwrapped.__wrapped__ + return unwrapped + + +@pytest.hookimpl(tryfirst=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None: + """Позволяет запускать async def тесты без дополнительных плагинов.""" + + test_func = _unwrap_test(pyfuncitem.obj) + if not inspect.iscoroutinefunction(test_func): + return None + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + signature = inspect.signature(test_func) + call_kwargs = { + name: value + for name, value in pyfuncitem.funcargs.items() + if name in signature.parameters + } + loop.run_until_complete(pyfuncitem.obj(**call_kwargs)) + finally: + asyncio.set_event_loop(None) + loop.close() + + return True diff --git a/tests/services/test_pal24_service_adapter.py b/tests/services/test_pal24_service_adapter.py index 10d2074e..7f1941f3 100644 --- a/tests/services/test_pal24_service_adapter.py +++ b/tests/services/test_pal24_service_adapter.py @@ -77,7 +77,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None: assert client.calls and client.calls[0]["amount"] == Decimal("500.00") assert client.calls[0]["shop_id"] == "shop42" assert client.calls[0]["description"] == "Пополнение" - assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":")) + assert client.calls[0]["custom"] == {"extra": "value"} assert client.calls[0]["payment_method"] == "BANK_CARD" diff --git a/tests/test_user_cart_service.py b/tests/test_user_cart_service.py index 8affbc87..21de03c2 100644 --- a/tests/test_user_cart_service.py +++ b/tests/test_user_cart_service.py @@ -112,6 +112,7 @@ async def test_delete_user_cart_not_found(user_cart_service): assert result is False + @pytest.mark.asyncio async def test_has_user_cart(user_cart_service, mock_redis): """Тест проверки наличия корзины пользователя""" From f139251333752776ce19e082bba5da5d302c3a93 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 22 Oct 2025 02:36:04 +0300 Subject: [PATCH 4/5] Revert "Fix simple subscription price calculations" --- app/handlers/simple_subscription.py | 157 +++++++------- app/handlers/subscription/purchase.py | 59 +---- app/utils/pricing_utils.py | 216 +------------------ tests/conftest.py | 49 ----- tests/services/test_pal24_service_adapter.py | 2 +- tests/test_user_cart_service.py | 1 - 6 files changed, 91 insertions(+), 393 deletions(-) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 38d92f1e..1d3746ce 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from typing import Any, Dict, Optional, Tuple +from typing import Optional, Dict, Any from aiogram import types, F from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -17,7 +17,6 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi from app.utils.decorators import error_handler from app.states import SubscriptionStates from app.utils.subscription_utils import get_display_subscription_link -from app.utils.pricing_utils import compute_simple_subscription_price logger = logging.getLogger(__name__) @@ -50,7 +49,11 @@ async def start_simple_subscription_purchase( # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) - + + # Проверяем баланс пользователя + user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + # Рассчитываем цену подписки + price_kopeks = _calculate_simple_subscription_price(subscription_params) data = await state.get_data() resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( db, @@ -59,38 +62,39 @@ async def start_simple_subscription_purchase( user_id=db_user.id, state_data=data, ) - - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) - period_days = subscription_params["period_days"] - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks) + direct_purchase_min_balance = recorded_price + extra_components = [] + traffic_limit = subscription_params.get("traffic_limit_gb", 0) + if traffic_limit and traffic_limit > 0: + traffic_price = settings.get_traffic_price(traffic_limit) + direct_purchase_min_balance += traffic_price + extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}") + device_limit = subscription_params.get("device_limit", 1) + if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: + additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT + devices_price = additional_devices * settings.PRICE_PER_DEVICE + direct_purchase_min_balance += devices_price + extra_components.append(f"devices+{additional_devices}->{devices_price}") logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s", db_user.id, period_days, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), price_kopeks, - ",".join(price_breakdown.get("resolved_squad_uuids", [])) - if price_breakdown.get("resolved_squad_uuids") - else "none", + recorded_price, + ",".join(extra_components) if extra_components else "none", + direct_purchase_min_balance, + getattr(settings, "PRICE_30_DAYS", None), ) - can_pay_from_balance = user_balance_kopeks >= price_kopeks + can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance logger.warning( "SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - price_kopeks, + direct_purchase_min_balance, can_pay_from_balance, ) @@ -154,23 +158,27 @@ async def start_simple_subscription_purchase( await callback.answer() -async def _calculate_simple_subscription_price( - db: AsyncSession, - params: dict, - *, - user: Optional[User] = None, - resolved_squad_uuid: Optional[str] = None, -) -> Tuple[int, Dict[str, Any]]: +def _calculate_simple_subscription_price(params: dict) -> int: """Рассчитывает цену простой подписки.""" + period_days = params.get("period_days", 30) + attr_name = f"PRICE_{period_days}_DAYS" + attr_value = getattr(settings, attr_name, None) - resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None - return await compute_simple_subscription_price( - db, - params, - user=user, - resolved_squad_uuids=resolved_uuids, + logger.warning( + "SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s", + period_days, + attr_name, + attr_value, + settings.BASE_SUBSCRIPTION_PRICE, ) + # Получаем цену для стандартного периода + if attr_value is not None: + return attr_value + else: + # Если нет цены для конкретного периода, используем базовую цену + return settings.BASE_SUBSCRIPTION_PRICE + def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: """Создает клавиатуру с методами оплаты для простой подписки.""" @@ -327,22 +335,27 @@ async def handle_simple_subscription_pay_with_balance( ) # Рассчитываем цену подписки - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) - total_required = price_kopeks + price_kopeks = _calculate_simple_subscription_price(subscription_params) + recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) + total_required = recorded_price + extras = [] + traffic_limit = subscription_params.get("traffic_limit_gb", 0) + if traffic_limit and traffic_limit > 0: + traffic_price = settings.get_traffic_price(traffic_limit) + total_required += traffic_price + extras.append(f"traffic={traffic_limit}GB->{traffic_price}") + device_limit = subscription_params.get("device_limit", 1) + if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: + additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT + devices_price = additional_devices * settings.PRICE_PER_DEVICE + total_required += devices_price + extras.append(f"devices+{additional_devices}->{devices_price}") logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s", db_user.id, subscription_params["period_days"], - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + price_kopeks, + ",".join(extras) if extras else "none", total_required, getattr(db_user, "balance_kopeks", 0), ) @@ -582,38 +595,29 @@ async def handle_simple_subscription_other_payment_methods( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return - resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( - db, - state, - subscription_params, - user_id=db_user.id, - state_data=data, - ) - # Рассчитываем цену подписки - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) + price_kopeks = _calculate_simple_subscription_price(subscription_params) user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - can_pay_from_balance = user_balance_kopeks >= price_kopeks + recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) + total_required = recorded_price + if subscription_params.get("traffic_limit_gb", 0) > 0: + total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"]) + if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT: + additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT + total_required += additional_devices * settings.PRICE_PER_DEVICE + can_pay_from_balance = user_balance_kopeks >= total_required logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), price_kopeks, + total_required, can_pay_from_balance, ) # Отображаем доступные методы оплаты + resolved_squad_uuid = data.get("resolved_squad_uuid") server_label = _get_simple_subscription_server_label( texts, subscription_params, @@ -673,8 +677,11 @@ async def handle_simple_subscription_payment_method( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return + # Рассчитываем цену подписки + price_kopeks = _calculate_simple_subscription_price(subscription_params) + payment_method = callback.data.replace("simple_subscription_", "") - + try: payment_service = PaymentService(callback.bot) @@ -686,14 +693,6 @@ async def handle_simple_subscription_payment_method( state_data=data, ) - # Рассчитываем цену подписки - price_kopeks, _ = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=resolved_squad_uuid, - ) - if payment_method == "stars": # Оплата через Telegram Stars stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 42a48741..bf743113 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -64,7 +64,6 @@ from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( calculate_months_from_days, - compute_simple_subscription_price, get_remaining_months, calculate_prorated_price, validate_pricing_calculation, @@ -2413,22 +2412,7 @@ async def handle_simple_subscription_purchase( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) # Рассчитываем цену подписки - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=subscription_params.get("squad_uuid"), - ) - logger.debug( - "SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", - db_user.id, - price_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), - ) + price_kopeks = _calculate_simple_subscription_price(subscription_params) traffic_text = ( "Безлимит" if subscription_params["traffic_limit_gb"] == 0 @@ -2480,22 +2464,16 @@ async def handle_simple_subscription_purchase( -async def _calculate_simple_subscription_price( - db: AsyncSession, - params: dict, - *, - user: Optional[User] = None, - resolved_squad_uuid: Optional[str] = None, -) -> Tuple[int, Dict[str, Any]]: +def _calculate_simple_subscription_price(params: dict) -> int: """Рассчитывает цену простой подписки.""" - - resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None - return await compute_simple_subscription_price( - db, - params, - user=user, - resolved_squad_uuids=resolved_uuids, - ) + period_days = params.get("period_days", 30) + + # Получаем цену для стандартного периода + if hasattr(settings, f'PRICE_{period_days}_DAYS'): + return getattr(settings, f'PRICE_{period_days}_DAYS') + else: + # Если нет цены для конкретного периода, используем базовую цену + return settings.BASE_SUBSCRIPTION_PRICE def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: @@ -2586,22 +2564,7 @@ async def _extend_existing_subscription( "traffic_limit_gb": traffic_limit_gb, "squad_uuid": squad_uuid } - price_kopeks, price_breakdown = await _calculate_simple_subscription_price( - db, - subscription_params, - user=db_user, - resolved_squad_uuid=squad_uuid, - ) - logger.debug( - "SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", - db_user.id, - price_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), - ) + price_kopeks = _calculate_simple_subscription_price(subscription_params) # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index 142a8b98..f54a8302 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,14 +1,7 @@ from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING +from typing import Tuple import logging -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings - -if TYPE_CHECKING: # pragma: no cover - from app.database.models import User, PromoGroup - logger = logging.getLogger(__name__) @@ -75,213 +68,6 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value -def resolve_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], - category: str, - *, - period_days: Optional[int] = None, -) -> int: - """Определяет размер скидки для указанной категории.""" - - if user is not None: - try: - return user.get_promo_discount(category, period_days) - except AttributeError: # pragma: no cover - defensive guard - pass - - if promo_group is not None: - return promo_group.get_discount_percent(category, period_days) - - return 0 - - -async def compute_simple_subscription_price( - db: AsyncSession, - params: Dict[str, Any], - *, - user: Optional["User"] = None, - resolved_squad_uuids: Optional[Sequence[str]] = None, -) -> Tuple[int, Dict[str, Any]]: - """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" - - period_days = int(params.get("period_days", 30) or 30) - attr_name = f"PRICE_{period_days}_DAYS" - base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) - - traffic_limit_raw = params.get("traffic_limit_gb") - try: - traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None - except (TypeError, ValueError): # pragma: no cover - defensive conversion - traffic_limit = None - traffic_price_original = settings.get_traffic_price(traffic_limit) - - device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT) - try: - device_limit = int(device_limit_raw) - except (TypeError, ValueError): # pragma: no cover - defensive conversion - device_limit = settings.DEFAULT_DEVICE_LIMIT - additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) - devices_price_original = additional_devices * settings.PRICE_PER_DEVICE - - promo_group: Optional["PromoGroup"] = params.get("promo_group") - - if promo_group is None: - promo_group_id = params.get("promo_group_id") - if promo_group_id: - from app.database.crud.promo_group import get_promo_group_by_id - - promo_group = await get_promo_group_by_id(db, int(promo_group_id)) - - if promo_group is None and user is not None: - promo_group = getattr(user, "promo_group", None) - - period_discount_percent = resolve_discount_percent( - user, - promo_group, - "period", - period_days=period_days, - ) - base_discount = base_price_original * period_discount_percent // 100 - discounted_base = base_price_original - base_discount - - traffic_discount_percent = resolve_discount_percent( - user, - promo_group, - "traffic", - period_days=period_days, - ) - traffic_discount = traffic_price_original * traffic_discount_percent // 100 - discounted_traffic = traffic_price_original - traffic_discount - - devices_discount_percent = resolve_discount_percent( - user, - promo_group, - "devices", - period_days=period_days, - ) - devices_discount = devices_price_original * devices_discount_percent // 100 - discounted_devices = devices_price_original - devices_discount - - servers_discount_percent = resolve_discount_percent( - user, - promo_group, - "servers", - period_days=period_days, - ) - - 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)) - - from app.database.crud.server_squad import get_server_squad_by_uuid - - server_breakdown: List[Dict[str, Any]] = [] - servers_price_original = 0 - servers_discount_total = 0 - - for squad_uuid in resolved_uuids: - server = await get_server_squad_by_uuid(db, squad_uuid) - if not server: - logger.warning( - "SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s", - squad_uuid, - ) - server_breakdown.append( - { - "uuid": squad_uuid, - "name": None, - "available": False, - "original_price": 0, - "discount": 0, - "final_price": 0, - } - ) - continue - - if not server.is_available or server.is_full: - logger.warning( - "SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s", - squad_uuid, - server.is_available, - 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 - discount_value = original_price * servers_discount_percent // 100 - final_price = original_price - discount_value - - servers_price_original += original_price - servers_discount_total += discount_value - - server_breakdown.append( - { - "uuid": squad_uuid, - "name": server.display_name, - "available": True, - "original_price": original_price, - "discount": discount_value, - "final_price": final_price, - } - ) - - total_before_discount = ( - base_price_original - + traffic_price_original - + devices_price_original - + servers_price_original - ) - - total_discount = ( - base_discount - + traffic_discount - + devices_discount - + servers_discount_total - ) - - total_price = max(0, total_before_discount - total_discount) - - breakdown = { - "base_price": base_price_original, - "base_discount": base_discount, - "traffic_price": traffic_price_original, - "traffic_discount": traffic_discount, - "devices_price": devices_price_original, - "devices_discount": devices_discount, - "servers_price": servers_price_original, - "servers_discount": servers_discount_total, - "servers_final": sum(item["final_price"] for item in server_breakdown), - "server_details": server_breakdown, - "total_before_discount": total_before_discount, - "total_discount": total_discount, - "resolved_squad_uuids": resolved_uuids, - "applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None, - "period_discount_percent": period_discount_percent, - "traffic_discount_percent": traffic_discount_percent, - "devices_discount_percent": devices_discount_percent, - "servers_discount_percent": servers_discount_percent, - } - - return total_price, breakdown - - def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) diff --git a/tests/conftest.py b/tests/conftest.py index 242eff3a..54fd8a56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,5 @@ """Глобальные фикстуры и настройки окружения для тестов.""" -import asyncio -import inspect import os import sys import types @@ -151,50 +149,3 @@ if "yookassa" not in sys.modules: def fixed_datetime() -> datetime: """Возвращает фиксированную отметку времени для воспроизводимых проверок.""" return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) - - -def pytest_configure(config: pytest.Config) -> None: - """Регистрируем маркеры для асинхронных тестов.""" - - config.addinivalue_line( - "markers", - "asyncio: запуск асинхронного теста через встроенный цикл событий", - ) - config.addinivalue_line( - "markers", - "anyio: запуск асинхронного теста через встроенный цикл событий", - ) - - -def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine - """Возвращает исходную функцию, снимая обёртки pytest и декораторов.""" - - unwrapped = obj - while hasattr(unwrapped, "__wrapped__"): - unwrapped = unwrapped.__wrapped__ - return unwrapped - - -@pytest.hookimpl(tryfirst=True) -def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None: - """Позволяет запускать async def тесты без дополнительных плагинов.""" - - test_func = _unwrap_test(pyfuncitem.obj) - if not inspect.iscoroutinefunction(test_func): - return None - - loop = asyncio.new_event_loop() - try: - asyncio.set_event_loop(loop) - signature = inspect.signature(test_func) - call_kwargs = { - name: value - for name, value in pyfuncitem.funcargs.items() - if name in signature.parameters - } - loop.run_until_complete(pyfuncitem.obj(**call_kwargs)) - finally: - asyncio.set_event_loop(None) - loop.close() - - return True diff --git a/tests/services/test_pal24_service_adapter.py b/tests/services/test_pal24_service_adapter.py index 7f1941f3..10d2074e 100644 --- a/tests/services/test_pal24_service_adapter.py +++ b/tests/services/test_pal24_service_adapter.py @@ -77,7 +77,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None: assert client.calls and client.calls[0]["amount"] == Decimal("500.00") assert client.calls[0]["shop_id"] == "shop42" assert client.calls[0]["description"] == "Пополнение" - assert client.calls[0]["custom"] == {"extra": "value"} + assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":")) assert client.calls[0]["payment_method"] == "BANK_CARD" diff --git a/tests/test_user_cart_service.py b/tests/test_user_cart_service.py index 21de03c2..8affbc87 100644 --- a/tests/test_user_cart_service.py +++ b/tests/test_user_cart_service.py @@ -112,7 +112,6 @@ async def test_delete_user_cart_not_found(user_cart_service): assert result is False - @pytest.mark.asyncio async def test_has_user_cart(user_cart_service, mock_redis): """Тест проверки наличия корзины пользователя""" From c21325f26036e0cb3f05de20b37b833c5c89f3a0 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 22 Oct 2025 02:36:23 +0300 Subject: [PATCH 5/5] Skip unlimited traffic surcharge for simple plans --- app/handlers/simple_subscription.py | 167 +++++++------- app/handlers/subscription/purchase.py | 59 ++++- app/utils/pricing_utils.py | 221 ++++++++++++++++++- tests/conftest.py | 49 ++++ tests/services/test_pal24_service_adapter.py | 2 +- tests/test_user_cart_service.py | 1 + 6 files changed, 403 insertions(+), 96 deletions(-) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 1d3746ce..38d92f1e 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional, Tuple from aiogram import types, F from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext @@ -17,6 +17,7 @@ from app.services.subscription_purchase_service import SubscriptionPurchaseServi from app.utils.decorators import error_handler from app.states import SubscriptionStates from app.utils.subscription_utils import get_display_subscription_link +from app.utils.pricing_utils import compute_simple_subscription_price logger = logging.getLogger(__name__) @@ -49,11 +50,7 @@ async def start_simple_subscription_purchase( # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) - - # Проверяем баланс пользователя - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + data = await state.get_data() resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( db, @@ -62,39 +59,38 @@ async def start_simple_subscription_purchase( user_id=db_user.id, state_data=data, ) - period_days = subscription_params["period_days"] - recorded_price = getattr(settings, f"PRICE_{period_days}_DAYS", price_kopeks) - direct_purchase_min_balance = recorded_price - extra_components = [] - traffic_limit = subscription_params.get("traffic_limit_gb", 0) - if traffic_limit and traffic_limit > 0: - traffic_price = settings.get_traffic_price(traffic_limit) - direct_purchase_min_balance += traffic_price - extra_components.append(f"traffic={traffic_limit}GB->{traffic_price}") - device_limit = subscription_params.get("device_limit", 1) - if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT - devices_price = additional_devices * settings.PRICE_PER_DEVICE - direct_purchase_min_balance += devices_price - extra_components.append(f"devices+{additional_devices}->{devices_price}") - logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base_price=%s | recorded_price=%s | extras=%s | total=%s | env_PRICE_30=%s", - db_user.id, - period_days, - price_kopeks, - recorded_price, - ",".join(extra_components) if extra_components else "none", - direct_purchase_min_balance, - getattr(settings, "PRICE_30_DAYS", None), + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, ) - can_pay_from_balance = user_balance_kopeks >= direct_purchase_min_balance + period_days = subscription_params["period_days"] + user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + + logger.warning( + "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s", + db_user.id, + period_days, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + price_kopeks, + ",".join(price_breakdown.get("resolved_squad_uuids", [])) + if price_breakdown.get("resolved_squad_uuids") + else "none", + ) + + can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( "SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, - direct_purchase_min_balance, + price_kopeks, can_pay_from_balance, ) @@ -158,27 +154,23 @@ async def start_simple_subscription_purchase( await callback.answer() -def _calculate_simple_subscription_price(params: dict) -> int: +async def _calculate_simple_subscription_price( + db: AsyncSession, + params: dict, + *, + user: Optional[User] = None, + resolved_squad_uuid: Optional[str] = None, +) -> Tuple[int, Dict[str, Any]]: """Рассчитывает цену простой подписки.""" - period_days = params.get("period_days", 30) - attr_name = f"PRICE_{period_days}_DAYS" - attr_value = getattr(settings, attr_name, None) - logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PRICE_FUNC | period=%s | attr=%s | attr_value=%s | base_price=%s", - period_days, - attr_name, - attr_value, - settings.BASE_SUBSCRIPTION_PRICE, + resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None + return await compute_simple_subscription_price( + db, + params, + user=user, + resolved_squad_uuids=resolved_uuids, ) - # Получаем цену для стандартного периода - if attr_value is not None: - return attr_value - else: - # Если нет цены для конкретного периода, используем базовую цену - return settings.BASE_SUBSCRIPTION_PRICE - def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: """Создает клавиатуру с методами оплаты для простой подписки.""" @@ -335,27 +327,22 @@ async def handle_simple_subscription_pay_with_balance( ) # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) - recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) - total_required = recorded_price - extras = [] - traffic_limit = subscription_params.get("traffic_limit_gb", 0) - if traffic_limit and traffic_limit > 0: - traffic_price = settings.get_traffic_price(traffic_limit) - total_required += traffic_price - extras.append(f"traffic={traffic_limit}GB->{traffic_price}") - device_limit = subscription_params.get("device_limit", 1) - if device_limit and device_limit > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = device_limit - settings.DEFAULT_DEVICE_LIMIT - devices_price = additional_devices * settings.PRICE_PER_DEVICE - total_required += devices_price - extras.append(f"devices+{additional_devices}->{devices_price}") + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) + total_required = price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base_price=%s | extras=%s | total_required=%s | balance=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", db_user.id, subscription_params["period_days"], - price_kopeks, - ",".join(extras) if extras else "none", + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), total_required, getattr(db_user, "balance_kopeks", 0), ) @@ -595,29 +582,38 @@ async def handle_simple_subscription_other_payment_methods( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return + resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( + db, + state, + subscription_params, + user_id=db_user.id, + state_data=data, + ) + # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) - recorded_price = getattr(settings, f"PRICE_{subscription_params['period_days']}_DAYS", price_kopeks) - total_required = recorded_price - if subscription_params.get("traffic_limit_gb", 0) > 0: - total_required += settings.get_traffic_price(subscription_params["traffic_limit_gb"]) - if subscription_params.get("device_limit", 1) > settings.DEFAULT_DEVICE_LIMIT: - additional_devices = subscription_params["device_limit"] - settings.DEFAULT_DEVICE_LIMIT - total_required += additional_devices * settings.PRICE_PER_DEVICE - can_pay_from_balance = user_balance_kopeks >= total_required + can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base_price=%s | total_required=%s | can_pay=%s", + "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s", db_user.id, user_balance_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), price_kopeks, - total_required, can_pay_from_balance, ) # Отображаем доступные методы оплаты - resolved_squad_uuid = data.get("resolved_squad_uuid") server_label = _get_simple_subscription_server_label( texts, subscription_params, @@ -677,11 +673,8 @@ async def handle_simple_subscription_payment_method( await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) return - # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) - payment_method = callback.data.replace("simple_subscription_", "") - + try: payment_service = PaymentService(callback.bot) @@ -693,6 +686,14 @@ async def handle_simple_subscription_payment_method( state_data=data, ) + # Рассчитываем цену подписки + price_kopeks, _ = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=resolved_squad_uuid, + ) + if payment_method == "stars": # Оплата через Telegram Stars stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index bf743113..42a48741 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -64,6 +64,7 @@ from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( calculate_months_from_days, + compute_simple_subscription_price, get_remaining_months, calculate_prorated_price, validate_pricing_calculation, @@ -2412,7 +2413,22 @@ async def handle_simple_subscription_purchase( # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) # Рассчитываем цену подписки - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=subscription_params.get("squad_uuid"), + ) + logger.debug( + "SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + db_user.id, + price_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + ) traffic_text = ( "Безлимит" if subscription_params["traffic_limit_gb"] == 0 @@ -2464,16 +2480,22 @@ async def handle_simple_subscription_purchase( -def _calculate_simple_subscription_price(params: dict) -> int: +async def _calculate_simple_subscription_price( + db: AsyncSession, + params: dict, + *, + user: Optional[User] = None, + resolved_squad_uuid: Optional[str] = None, +) -> Tuple[int, Dict[str, Any]]: """Рассчитывает цену простой подписки.""" - period_days = params.get("period_days", 30) - - # Получаем цену для стандартного периода - if hasattr(settings, f'PRICE_{period_days}_DAYS'): - return getattr(settings, f'PRICE_{period_days}_DAYS') - else: - # Если нет цены для конкретного периода, используем базовую цену - return settings.BASE_SUBSCRIPTION_PRICE + + resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None + return await compute_simple_subscription_price( + db, + params, + user=user, + resolved_squad_uuids=resolved_uuids, + ) def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyboardMarkup: @@ -2564,7 +2586,22 @@ async def _extend_existing_subscription( "traffic_limit_gb": traffic_limit_gb, "squad_uuid": squad_uuid } - price_kopeks = _calculate_simple_subscription_price(subscription_params) + price_kopeks, price_breakdown = await _calculate_simple_subscription_price( + db, + subscription_params, + user=db_user, + resolved_squad_uuid=squad_uuid, + ) + logger.debug( + "SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + db_user.id, + price_kopeks, + price_breakdown.get("base_price", 0), + price_breakdown.get("traffic_price", 0), + price_breakdown.get("devices_price", 0), + price_breakdown.get("servers_price", 0), + price_breakdown.get("total_discount", 0), + ) # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index f54a8302..5ec1abf5 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,7 +1,14 @@ from datetime import datetime, timedelta -from typing import Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING import logging +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings + +if TYPE_CHECKING: # pragma: no cover + from app.database.models import User, PromoGroup + logger = logging.getLogger(__name__) @@ -68,6 +75,218 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted_amount, discount_value +def resolve_discount_percent( + user: Optional["User"], + promo_group: Optional["PromoGroup"], + category: str, + *, + period_days: Optional[int] = None, +) -> int: + """Определяет размер скидки для указанной категории.""" + + if user is not None: + try: + return user.get_promo_discount(category, period_days) + except AttributeError: # pragma: no cover - defensive guard + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category, period_days) + + return 0 + + +async def compute_simple_subscription_price( + db: AsyncSession, + params: Dict[str, Any], + *, + user: Optional["User"] = None, + resolved_squad_uuids: Optional[Sequence[str]] = None, +) -> Tuple[int, Dict[str, Any]]: + """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" + + period_days = int(params.get("period_days", 30) or 30) + attr_name = f"PRICE_{period_days}_DAYS" + base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) + + traffic_limit_raw = params.get("traffic_limit_gb") + try: + traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None + except (TypeError, ValueError): # pragma: no cover - defensive conversion + traffic_limit = None + + if traffic_limit is None or traffic_limit <= 0: + # Default simple subscriptions already include unlimited traffic. + traffic_price_original = 0 + else: + traffic_price_original = settings.get_traffic_price(traffic_limit) + + device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT) + try: + device_limit = int(device_limit_raw) + except (TypeError, ValueError): # pragma: no cover - defensive conversion + device_limit = settings.DEFAULT_DEVICE_LIMIT + additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) + devices_price_original = additional_devices * settings.PRICE_PER_DEVICE + + promo_group: Optional["PromoGroup"] = params.get("promo_group") + + if promo_group is None: + promo_group_id = params.get("promo_group_id") + if promo_group_id: + from app.database.crud.promo_group import get_promo_group_by_id + + promo_group = await get_promo_group_by_id(db, int(promo_group_id)) + + if promo_group is None and user is not None: + promo_group = getattr(user, "promo_group", None) + + period_discount_percent = resolve_discount_percent( + user, + promo_group, + "period", + period_days=period_days, + ) + base_discount = base_price_original * period_discount_percent // 100 + discounted_base = base_price_original - base_discount + + traffic_discount_percent = resolve_discount_percent( + user, + promo_group, + "traffic", + period_days=period_days, + ) + traffic_discount = traffic_price_original * traffic_discount_percent // 100 + discounted_traffic = traffic_price_original - traffic_discount + + devices_discount_percent = resolve_discount_percent( + user, + promo_group, + "devices", + period_days=period_days, + ) + devices_discount = devices_price_original * devices_discount_percent // 100 + discounted_devices = devices_price_original - devices_discount + + servers_discount_percent = resolve_discount_percent( + user, + promo_group, + "servers", + period_days=period_days, + ) + + 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)) + + from app.database.crud.server_squad import get_server_squad_by_uuid + + server_breakdown: List[Dict[str, Any]] = [] + servers_price_original = 0 + servers_discount_total = 0 + + for squad_uuid in resolved_uuids: + server = await get_server_squad_by_uuid(db, squad_uuid) + if not server: + logger.warning( + "SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s", + squad_uuid, + ) + server_breakdown.append( + { + "uuid": squad_uuid, + "name": None, + "available": False, + "original_price": 0, + "discount": 0, + "final_price": 0, + } + ) + continue + + if not server.is_available or server.is_full: + logger.warning( + "SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s", + squad_uuid, + server.is_available, + 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 + discount_value = original_price * servers_discount_percent // 100 + final_price = original_price - discount_value + + servers_price_original += original_price + servers_discount_total += discount_value + + server_breakdown.append( + { + "uuid": squad_uuid, + "name": server.display_name, + "available": True, + "original_price": original_price, + "discount": discount_value, + "final_price": final_price, + } + ) + + total_before_discount = ( + base_price_original + + traffic_price_original + + devices_price_original + + servers_price_original + ) + + total_discount = ( + base_discount + + traffic_discount + + devices_discount + + servers_discount_total + ) + + total_price = max(0, total_before_discount - total_discount) + + breakdown = { + "base_price": base_price_original, + "base_discount": base_discount, + "traffic_price": traffic_price_original, + "traffic_discount": traffic_discount, + "devices_price": devices_price_original, + "devices_discount": devices_discount, + "servers_price": servers_price_original, + "servers_discount": servers_discount_total, + "servers_final": sum(item["final_price"] for item in server_breakdown), + "server_details": server_breakdown, + "total_before_discount": total_before_discount, + "total_discount": total_discount, + "resolved_squad_uuids": resolved_uuids, + "applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None, + "period_discount_percent": period_discount_percent, + "traffic_discount_percent": traffic_discount_percent, + "devices_discount_percent": devices_discount_percent, + "servers_discount_percent": servers_discount_percent, + } + + return total_price, breakdown + + def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) diff --git a/tests/conftest.py b/tests/conftest.py index 54fd8a56..242eff3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ """Глобальные фикстуры и настройки окружения для тестов.""" +import asyncio +import inspect import os import sys import types @@ -149,3 +151,50 @@ if "yookassa" not in sys.modules: def fixed_datetime() -> datetime: """Возвращает фиксированную отметку времени для воспроизводимых проверок.""" return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +def pytest_configure(config: pytest.Config) -> None: + """Регистрируем маркеры для асинхронных тестов.""" + + config.addinivalue_line( + "markers", + "asyncio: запуск асинхронного теста через встроенный цикл событий", + ) + config.addinivalue_line( + "markers", + "anyio: запуск асинхронного теста через встроенный цикл событий", + ) + + +def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine + """Возвращает исходную функцию, снимая обёртки pytest и декораторов.""" + + unwrapped = obj + while hasattr(unwrapped, "__wrapped__"): + unwrapped = unwrapped.__wrapped__ + return unwrapped + + +@pytest.hookimpl(tryfirst=True) +def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None: + """Позволяет запускать async def тесты без дополнительных плагинов.""" + + test_func = _unwrap_test(pyfuncitem.obj) + if not inspect.iscoroutinefunction(test_func): + return None + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + signature = inspect.signature(test_func) + call_kwargs = { + name: value + for name, value in pyfuncitem.funcargs.items() + if name in signature.parameters + } + loop.run_until_complete(pyfuncitem.obj(**call_kwargs)) + finally: + asyncio.set_event_loop(None) + loop.close() + + return True diff --git a/tests/services/test_pal24_service_adapter.py b/tests/services/test_pal24_service_adapter.py index 10d2074e..7f1941f3 100644 --- a/tests/services/test_pal24_service_adapter.py +++ b/tests/services/test_pal24_service_adapter.py @@ -77,7 +77,7 @@ async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None: assert client.calls and client.calls[0]["amount"] == Decimal("500.00") assert client.calls[0]["shop_id"] == "shop42" assert client.calls[0]["description"] == "Пополнение" - assert client.calls[0]["custom"] == json.dumps({"extra": "value"}, ensure_ascii=False, separators=(",", ":")) + assert client.calls[0]["custom"] == {"extra": "value"} assert client.calls[0]["payment_method"] == "BANK_CARD" diff --git a/tests/test_user_cart_service.py b/tests/test_user_cart_service.py index 8affbc87..21de03c2 100644 --- a/tests/test_user_cart_service.py +++ b/tests/test_user_cart_service.py @@ -112,6 +112,7 @@ async def test_delete_user_cart_not_found(user_cart_service): assert result is False + @pytest.mark.asyncio async def test_has_user_cart(user_cart_service, mock_redis): """Тест проверки наличия корзины пользователя"""