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): """Тест проверки наличия корзины пользователя"""