diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 655da597..2fd18b30 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging import re import math -from dataclasses import dataclass from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR from datetime import datetime, timedelta, timezone from uuid import uuid4 @@ -28,16 +27,10 @@ from app.database.crud.promo_offer_template import get_promo_offer_template_by_i from app.database.crud.server_squad import ( get_available_server_squads, get_server_squad_by_uuid, - get_server_ids_by_uuids, add_user_to_servers, remove_user_from_servers, ) -from app.database.crud.subscription import ( - add_subscription_servers, - remove_subscription_servers, - create_paid_subscription, -) -from app.database.crud.subscription_conversion import create_subscription_conversion +from app.database.crud.subscription import add_subscription_servers, remove_subscription_servers from app.database.crud.transaction import ( create_transaction, get_user_total_spent_kopeks, @@ -48,14 +41,11 @@ from app.database.models import ( PromoOfferTemplate, Subscription, SubscriptionTemporaryAccess, - SubscriptionStatus, Transaction, TransactionType, PaymentMethod, User, ) -from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService from app.services.faq_service import FaqService from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService @@ -77,15 +67,12 @@ from app.utils.telegram_webapp import ( from app.utils.user_utils import ( get_detailed_referral_list, get_user_referral_summary, - mark_user_as_had_paid_subscription, ) from app.utils.pricing_utils import ( apply_percentage_discount, calculate_prorated_price, get_remaining_months, - format_period_description, ) -from app.utils.promo_offer import get_user_active_promo_discount_percent from ..dependencies import get_db_session from ..schemas.miniapp import ( @@ -139,19 +126,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionTrafficUpdateRequest, MiniAppSubscriptionDevicesUpdateRequest, MiniAppSubscriptionUpdateResponse, - MiniAppSubscriptionPurchaseOptionsRequest, - MiniAppSubscriptionPurchaseOptionsResponse, - MiniAppSubscriptionPurchaseOptions, - MiniAppSubscriptionPurchasePeriod, - MiniAppSubscriptionPurchaseTrafficConfig, - MiniAppSubscriptionPurchaseServersConfig, - MiniAppSubscriptionPurchaseDevicesConfig, - MiniAppSubscriptionPurchasePreviewRequest, - MiniAppSubscriptionPurchasePreviewResponse, - MiniAppSubscriptionPurchasePreview, - MiniAppSubscriptionPurchaseSubmitRequest, - MiniAppSubscriptionPurchaseSubmitResponse, - MiniAppPurchaseBreakdownItem, ) @@ -1854,734 +1828,6 @@ def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: ) -@dataclass -class SubscriptionPurchaseCalculation: - period_days: int - months: int - base_price_original: int - base_price_discounted: int - base_discount_percent: int - base_discount_value: int - traffic_gb: int - traffic_price_original: int - traffic_price_discounted: int - traffic_discount_percent: int - traffic_discount_value: int - servers_selection: List[str] - servers_price_original: List[int] - servers_price_discounted: List[int] - servers_discount_percent: int - servers_discount_value: int - devices: int - devices_price_original: int - devices_price_discounted: int - devices_discount_percent: int - devices_discount_value: int - promo_discount_percent: int - promo_discount_value: int - final_price: int - original_total: int - - @property - def total_discount_value(self) -> int: - return max(0, self.original_total - self.final_price) - - -def _format_price(amount_kopeks: int) -> str: - return settings.format_price(amount_kopeks) - - -def _get_language_code(user: Optional[User]) -> str: - raw = getattr(user, "language", "ru") or "ru" - return str(raw).split("-")[0].lower() - - -def _ensure_purchase_allowed(user: User) -> Optional[Subscription]: - subscription = getattr(user, "subscription", None) - if subscription and not getattr(subscription, "is_trial", False): - if subscription.is_active: - language = _get_language_code(user) - message = ( - "У вас уже есть активная подписка" - if language == "ru" - else "You already have an active subscription" - ) - raise HTTPException( - status.HTTP_409_CONFLICT, - detail={"code": "subscription_active", "message": message}, - ) - return subscription - - -def _get_available_purchase_periods() -> List[int]: - periods = [ - days - for days in settings.get_available_subscription_periods() - if days in PERIOD_PRICES and PERIOD_PRICES[days] > 0 - ] - if periods: - return periods - return sorted(PERIOD_PRICES.keys()) - - -async def _build_purchase_server_catalog( - db: AsyncSession, - user: User, - subscription: Optional[Subscription], -) -> Tuple[List[str], Dict[str, Dict[str, Any]]]: - available_servers = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - catalog: Dict[str, Dict[str, Any]] = {} - ordered: List[str] = [] - - for server in available_servers: - uuid = getattr(server, "squad_uuid", None) - if not uuid: - continue - price_per_month = int(getattr(server, "price_kopeks", 0) or 0) - available = bool(getattr(server, "is_available", True) and not getattr(server, "is_full", False)) - catalog[uuid] = { - "uuid": uuid, - "name": getattr(server, "display_name", uuid), - "price_per_month": price_per_month, - "available": available, - } - ordered.append(uuid) - - current = list(getattr(subscription, "connected_squads", []) or []) - for uuid in current: - if not uuid: - continue - if uuid not in catalog: - catalog[uuid] = { - "uuid": uuid, - "name": uuid, - "price_per_month": 0, - "available": False, - } - ordered.append(uuid) - - return ordered, catalog - - -def _resolve_default_servers( - ordered_servers: List[str], - catalog: Dict[str, Dict[str, Any]], - subscription: Optional[Subscription], -) -> List[str]: - defaults: List[str] = [] - if subscription and getattr(subscription, "connected_squads", None): - for uuid in subscription.connected_squads: - if not uuid: - continue - entry = catalog.get(uuid) - if entry and entry.get("available", False): - defaults.append(uuid) - - if defaults: - return defaults - - for uuid in ordered_servers: - entry = catalog.get(uuid) - if entry and entry.get("available", False): - return [uuid] - - return [] - - -def _resolve_default_traffic(subscription: Optional[Subscription]) -> int: - if settings.is_traffic_fixed(): - return settings.get_fixed_traffic_limit() - - if subscription and not subscription.is_trial: - try: - value = int(subscription.traffic_limit_gb) - if value >= 0: - return value - except (TypeError, ValueError): - pass - - if subscription and subscription.is_trial: - try: - value = int(subscription.traffic_limit_gb) - if value >= 0: - return value - except (TypeError, ValueError): - pass - - return settings.DEFAULT_TRAFFIC_LIMIT_GB - - -def _resolve_default_devices(subscription: Optional[Subscription]) -> int: - base = max(1, settings.DEFAULT_DEVICE_LIMIT) - if subscription and getattr(subscription, "device_limit", None): - try: - value = int(subscription.device_limit) - if value >= base: - return min(value, settings.MAX_DEVICES_LIMIT) - except (TypeError, ValueError): - pass - return base - - -def _parse_period_days_from_id(period_id: Optional[str]) -> Optional[int]: - if not period_id: - return None - if isinstance(period_id, (int, float)): - try: - return int(period_id) - except (TypeError, ValueError): - return None - period_str = str(period_id) - if period_str.isdigit(): - try: - return int(period_str) - except ValueError: - return None - match = re.search(r"(\d+)", period_str) - if match: - try: - return int(match.group(1)) - except ValueError: - return None - return None - - -def _load_traffic_packages() -> List[Dict[str, int]]: - packages: List[Dict[str, int]] = [] - for package in settings.get_traffic_packages(): - if not package or not package.get("enabled", True): - continue - try: - gb = int(package.get("gb", 0) or 0) - except (TypeError, ValueError): - gb = 0 - try: - price = int(package.get("price", 0) or 0) - except (TypeError, ValueError): - price = 0 - packages.append({"gb": gb, "price": price}) - if not packages: - packages.append({"gb": 0, "price": 0}) - return packages - - -def _normalize_purchase_selection( - user: User, - selection: Optional[MiniAppSubscriptionPurchaseSelection], - *, - subscription: Optional[Subscription], - available_periods: List[int], - default_period_days: int, - servers_catalog: Dict[str, Dict[str, Any]], - default_servers: List[str], - traffic_packages: List[Dict[str, int]], -) -> Dict[str, Any]: - if selection is None: - selection = MiniAppSubscriptionPurchaseSelection() - - requested_period = selection.period_days or _parse_period_days_from_id(selection.period_id) - period_days = requested_period or default_period_days - if period_days not in available_periods: - language = _get_language_code(user) - message = ( - "Выбранный период недоступен" - if language == "ru" - else "Selected period is not available" - ) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_period", "message": message}, - ) - - if settings.is_traffic_fixed(): - traffic_gb = settings.get_fixed_traffic_limit() - else: - if selection.traffic_value is None: - traffic_gb = _resolve_default_traffic(subscription) - else: - try: - traffic_gb = int(selection.traffic_value) - except (TypeError, ValueError): - traffic_gb = _resolve_default_traffic(subscription) - allowed_values = {pkg["gb"] for pkg in traffic_packages} - if traffic_gb not in allowed_values: - language = _get_language_code(user) - message = ( - "Выбранный объём трафика недоступен" - if language == "ru" - else "Selected traffic option is not available" - ) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_traffic", "message": message}, - ) - - seen_servers: set[str] = set() - selected_servers: List[str] = [] - if selection.servers: - for raw_uuid in selection.servers: - if not raw_uuid: - continue - uuid = str(raw_uuid).strip() - if not uuid or uuid in seen_servers: - continue - entry = servers_catalog.get(uuid) - if not entry or not entry.get("available", False): - continue - seen_servers.add(uuid) - selected_servers.append(uuid) - - if not selected_servers: - selected_servers = [uuid for uuid in default_servers if uuid in servers_catalog and servers_catalog[uuid].get("available", False)] - - if not selected_servers: - language = _get_language_code(user) - message = ( - "Нет доступных серверов для подключения" - if language == "ru" - else "No servers are currently available" - ) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "servers_unavailable", "message": message}, - ) - - min_devices = max(1, settings.DEFAULT_DEVICE_LIMIT) - max_devices = max(min_devices, settings.MAX_DEVICES_LIMIT) - if selection.devices is None: - devices = _resolve_default_devices(subscription) - else: - try: - devices = int(selection.devices) - except (TypeError, ValueError): - devices = _resolve_default_devices(subscription) - - if devices < min_devices or devices > max_devices: - language = _get_language_code(user) - message = ( - "Недопустимое количество устройств" - if language == "ru" - else "Selected device count is out of range" - ) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_devices", "message": message}, - ) - - return { - "period_days": period_days, - "traffic_gb": traffic_gb, - "servers": selected_servers, - "devices": devices, - } - - -def _calculate_purchase_pricing( - user: User, - selection: Dict[str, Any], - servers_catalog: Dict[str, Dict[str, Any]], -) -> SubscriptionPurchaseCalculation: - period_days = int(selection["period_days"]) - months = calculate_months_from_days(period_days) - - base_price_original = PERIOD_PRICES.get(period_days, 0) - try: - base_discount_percent = int(user.get_promo_discount("period", period_days)) - except AttributeError: - base_discount_percent = 0 - base_price_discounted, base_discount_value = apply_percentage_discount( - base_price_original, - base_discount_percent, - ) - - traffic_gb = int(selection.get("traffic_gb", 0)) - traffic_price_per_month = settings.get_traffic_price(traffic_gb) - traffic_price_original = traffic_price_per_month * months - traffic_discount_percent = _get_addon_discount_percent_for_user(user, "traffic", period_days) - traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount( - traffic_price_per_month, - traffic_discount_percent, - ) - traffic_price_discounted = traffic_discounted_per_month * months - traffic_discount_value = traffic_discount_per_month * months - - servers_selection = list(selection.get("servers", [])) - servers_price_original: List[int] = [] - servers_price_discounted: List[int] = [] - servers_discount_percent = _get_addon_discount_percent_for_user(user, "servers", period_days) - servers_discount_value = 0 - - for uuid in servers_selection: - entry = servers_catalog.get(uuid) or {} - price_per_month = int(entry.get("price_per_month", 0) or 0) - original_total = price_per_month * months - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - servers_discount_percent, - ) - discounted_total = discounted_per_month * months - discount_total = discount_per_month * months - servers_price_original.append(original_total) - servers_price_discounted.append(discounted_total) - servers_discount_value += discount_total - - devices = int(selection.get("devices", settings.DEFAULT_DEVICE_LIMIT)) - base_devices = max(1, settings.DEFAULT_DEVICE_LIMIT) - additional_devices = max(0, devices - base_devices) - per_device_price_per_month = settings.PRICE_PER_DEVICE - devices_price_per_month = additional_devices * per_device_price_per_month - devices_price_original = devices_price_per_month * months - devices_discount_percent = _get_addon_discount_percent_for_user(user, "devices", period_days) - devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - devices_price_discounted = devices_discounted_per_month * months - devices_discount_value = devices_discount_per_month * months - - subtotal = ( - base_price_discounted - + traffic_price_discounted - + sum(servers_price_discounted) - + devices_price_discounted - ) - promo_discount_percent = get_user_active_promo_discount_percent(user) - final_price, promo_discount_value = apply_percentage_discount( - subtotal, - promo_discount_percent, - ) - - original_total = ( - base_price_original - + traffic_price_original - + sum(servers_price_original) - + devices_price_original - ) - - return SubscriptionPurchaseCalculation( - period_days=period_days, - months=months, - base_price_original=base_price_original, - base_price_discounted=base_price_discounted, - base_discount_percent=base_discount_percent, - base_discount_value=base_discount_value, - traffic_gb=traffic_gb, - traffic_price_original=traffic_price_original, - traffic_price_discounted=traffic_price_discounted, - traffic_discount_percent=traffic_discount_percent, - traffic_discount_value=traffic_discount_value, - servers_selection=servers_selection, - servers_price_original=servers_price_original, - servers_price_discounted=servers_price_discounted, - servers_discount_percent=servers_discount_percent, - servers_discount_value=servers_discount_value, - devices=devices, - devices_price_original=devices_price_original, - devices_price_discounted=devices_price_discounted, - devices_discount_percent=devices_discount_percent, - devices_discount_value=devices_discount_value, - promo_discount_percent=promo_discount_percent, - promo_discount_value=promo_discount_value, - final_price=final_price, - original_total=original_total, - ) - - -def _build_discount_lines( - user: User, - calculation: SubscriptionPurchaseCalculation, -) -> List[str]: - language = _get_language_code(user) - - def _line(label_ru: str, label_en: str, amount: int, percent: int) -> Optional[str]: - if amount <= 0: - return None - label = label_ru if language == "ru" else label_en - if percent > 0: - return f"{label}: -{_format_price(amount)} ({percent}%)" - return f"{label}: -{_format_price(amount)}" - - lines: List[str] = [] - if calculation.base_discount_value > 0: - line = _line("Скидка на период", "Period discount", calculation.base_discount_value, calculation.base_discount_percent) - if line: - lines.append(line) - if calculation.traffic_discount_value > 0: - line = _line("Скидка на трафик", "Traffic discount", calculation.traffic_discount_value, calculation.traffic_discount_percent) - if line: - lines.append(line) - if calculation.servers_discount_value > 0: - line = _line("Скидка на серверы", "Server discount", calculation.servers_discount_value, calculation.servers_discount_percent) - if line: - lines.append(line) - if calculation.devices_discount_value > 0: - line = _line("Скидка на устройства", "Device discount", calculation.devices_discount_value, calculation.devices_discount_percent) - if line: - lines.append(line) - if calculation.promo_discount_value > 0: - line = _line("Промо-предложение", "Promo offer", calculation.promo_discount_value, calculation.promo_discount_percent) - if line: - lines.append(line) - return lines - - -def _build_breakdown_items( - user: User, - calculation: SubscriptionPurchaseCalculation, -) -> List[MiniAppPurchaseBreakdownItem]: - language = _get_language_code(user) - breakdown: List[MiniAppPurchaseBreakdownItem] = [] - - period_label = ( - f"Период ({calculation.period_days} дн.)" - if language == "ru" - else f"Period ({calculation.period_days} days)" - ) - breakdown.append( - MiniAppPurchaseBreakdownItem( - label=period_label, - value=_format_price(calculation.base_price_discounted), - ) - ) - - if calculation.traffic_price_discounted > 0: - traffic_label = "Трафик" if language == "ru" else "Traffic" - breakdown.append( - MiniAppPurchaseBreakdownItem( - label=traffic_label, - value=_format_price(calculation.traffic_price_discounted), - ) - ) - - if calculation.servers_price_discounted: - servers_label = ( - f"Серверы ({len(calculation.servers_price_discounted)})" - if language == "ru" - else f"Servers ({len(calculation.servers_price_discounted)})" - ) - breakdown.append( - MiniAppPurchaseBreakdownItem( - label=servers_label, - value=_format_price(sum(calculation.servers_price_discounted)), - ) - ) - - if calculation.devices_price_discounted > 0: - devices_label = "Доп. устройства" if language == "ru" else "Extra devices" - breakdown.append( - MiniAppPurchaseBreakdownItem( - label=devices_label, - value=_format_price(calculation.devices_price_discounted), - ) - ) - - if calculation.promo_discount_value > 0: - promo_label = "Промо-скидка" if language == "ru" else "Promo discount" - breakdown.append( - MiniAppPurchaseBreakdownItem( - label=promo_label, - value=f"- {_format_price(calculation.promo_discount_value)}", - highlight=True, - ) - ) - - return breakdown - - -def _build_purchase_preview( - user: User, - calculation: SubscriptionPurchaseCalculation, -) -> MiniAppSubscriptionPurchasePreview: - discount_total = calculation.total_discount_value - overall_discount_percent: Optional[int] - if calculation.original_total > 0 and discount_total > 0: - overall_discount_percent = round(discount_total * 100 / calculation.original_total) - else: - overall_discount_percent = None - - per_month_price = math.ceil(calculation.final_price / calculation.months) if calculation.months else calculation.final_price - missing_amount = max(0, calculation.final_price - user.balance_kopeks) - discount_lines = _build_discount_lines(user, calculation) - breakdown = _build_breakdown_items(user, calculation) - language = _get_language_code(user) - status_message = None - if missing_amount > 0: - status_message = ( - "Недостаточно средств на балансе" - if language == "ru" - else "Insufficient balance" - ) - - return MiniAppSubscriptionPurchasePreview( - period_days=calculation.period_days, - months=calculation.months, - total_price_kopeks=calculation.final_price, - total_price_label=_format_price(calculation.final_price), - original_price_kopeks=calculation.original_total, - original_price_label=_format_price(calculation.original_total), - per_month_price_kopeks=per_month_price, - per_month_price_label=_format_price(per_month_price), - discount_percent=overall_discount_percent, - discount_label=_format_price(discount_total) if discount_total else None, - discount_lines=discount_lines, - breakdown=breakdown, - balance_kopeks=user.balance_kopeks, - balance_label=_format_price(user.balance_kopeks), - missing_amount_kopeks=missing_amount, - missing_amount_label=_format_price(missing_amount) if missing_amount else None, - can_purchase=missing_amount <= 0, - promo_discount_percent=( - calculation.promo_discount_percent if calculation.promo_discount_value > 0 else None - ), - promo_discount_value=calculation.promo_discount_value or None, - promo_discount_label= - _format_price(calculation.promo_discount_value) - if calculation.promo_discount_value - else None, - status_message=status_message, - ) - - -def _build_purchase_period_entry( - user: User, - period_days: int, - *, - is_default: bool, - servers_catalog: Dict[str, Dict[str, Any]], - ordered_servers: List[str], - traffic_packages: List[Dict[str, int]], - default_servers: List[str], - default_traffic: int, - default_devices: int, -) -> MiniAppSubscriptionPurchasePeriod: - months = calculate_months_from_days(period_days) - base_price_original = PERIOD_PRICES.get(period_days, 0) - try: - base_discount_percent = int(user.get_promo_discount("period", period_days)) - except AttributeError: - base_discount_percent = 0 - base_price_discounted, base_discount_value = apply_percentage_discount( - base_price_original, - base_discount_percent, - ) - - language = _get_language_code(user) - period_label = format_period_description(period_days, "ru" if language == "ru" else "en") - - period_entry = MiniAppSubscriptionPurchasePeriod( - id=f"days:{period_days}", - period_days=period_days, - months=months, - label=period_label, - price_kopeks=base_price_discounted, - price_label=_format_price(base_price_discounted), - original_price_kopeks=base_price_original, - original_price_label=_format_price(base_price_original) if base_discount_value else None, - discount_percent=base_discount_percent, - discount_value_kopeks=base_discount_value or None, - discount_value_label=_format_price(base_discount_value) if base_discount_value else None, - is_default=is_default, - ) - - if settings.is_traffic_fixed(): - traffic_config = MiniAppSubscriptionPurchaseTrafficConfig( - mode="fixed", - selectable=False, - current=default_traffic, - default=default_traffic, - options=[], - ) - else: - traffic_discount = _get_addon_discount_percent_for_user(user, "traffic", period_days) - traffic_options: List[MiniAppSubscriptionTrafficOption] = [] - for package in traffic_packages: - gb = package.get("gb", 0) - price_per_month = int(package.get("price", 0) or 0) - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - traffic_discount, - ) - total_price = discounted_per_month * months - original_total = price_per_month * months - option = MiniAppSubscriptionTrafficOption( - value=gb, - price_kopeks=total_price, - original_price_kopeks=original_total if discount_per_month else None, - discount_percent=traffic_discount if discount_per_month else None, - is_available=True, - ) - traffic_options.append(option) - - traffic_config = MiniAppSubscriptionPurchaseTrafficConfig( - mode="selectable", - selectable=True, - options=traffic_options, - current=default_traffic, - default=default_traffic, - ) - - servers_discount = _get_addon_discount_percent_for_user(user, "servers", period_days) - server_options: List[MiniAppSubscriptionServerOption] = [] - for uuid in ordered_servers: - entry = servers_catalog.get(uuid) - if not entry: - continue - price_per_month = int(entry.get("price_per_month", 0) or 0) - discounted_per_month, discount_per_month = apply_percentage_discount( - price_per_month, - servers_discount, - ) - total_price = discounted_per_month * months - original_total = price_per_month * months - option = MiniAppSubscriptionServerOption( - uuid=uuid, - name=entry.get("name", uuid), - price_kopeks=total_price, - original_price_kopeks=original_total if discount_per_month else None, - discount_percent=servers_discount if discount_per_month else None, - is_available=bool(entry.get("available", False)), - ) - server_options.append(option) - - selectable_servers = [opt for opt in server_options if opt.is_available] - servers_config = MiniAppSubscriptionPurchaseServersConfig( - selectable=len(selectable_servers) > 1, - min=1 if selectable_servers else 0, - options=server_options, - selected=[uuid for uuid in default_servers if uuid in servers_catalog], - default=[uuid for uuid in default_servers if uuid in servers_catalog], - ) - - base_devices = max(1, settings.DEFAULT_DEVICE_LIMIT) - devices_discount_percent = _get_addon_discount_percent_for_user(user, "devices", period_days) - discounted_per_month, discount_per_month = apply_percentage_discount( - settings.PRICE_PER_DEVICE, - devices_discount_percent, - ) - price_per_device_total = discounted_per_month * months - - devices_config = MiniAppSubscriptionPurchaseDevicesConfig( - selectable=True, - min=base_devices, - max=settings.MAX_DEVICES_LIMIT or None, - step=1, - current=default_devices, - default=default_devices, - base=base_devices, - price_kopeks=price_per_device_total, - price_label=_format_price(price_per_device_total) if price_per_device_total else None, - discount_percent=devices_discount_percent if discount_per_month else None, - ) - - period_entry.traffic = traffic_config - period_entry.servers = servers_config - period_entry.devices = devices_config - return period_entry - - async def _load_subscription_links( subscription: Subscription, ) -> Dict[str, Any]: @@ -3075,434 +2321,6 @@ async def get_subscription_details( ) -@router.post( - "/subscription/purchase/options", - response_model=MiniAppSubscriptionPurchaseOptionsResponse, -) -async def get_subscription_purchase_options_endpoint( - payload: MiniAppSubscriptionPurchaseOptionsRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionPurchaseOptionsResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_purchase_allowed(user) - - periods = _get_available_purchase_periods() - if not periods: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "periods_unavailable", "message": "Subscription periods are not configured"}, - ) - - ordered_servers, servers_catalog = await _build_purchase_server_catalog(db, user, subscription) - available_servers = [uuid for uuid, entry in servers_catalog.items() if entry.get("available", False)] - if not available_servers: - language = _get_language_code(user) - message = ( - "В данный момент нет доступных серверов" - if language == "ru" - else "No subscription servers are currently available" - ) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "servers_unavailable", "message": message}, - ) - - default_servers = _resolve_default_servers(ordered_servers, servers_catalog, subscription) - if not default_servers: - default_servers = [available_servers[0]] - - traffic_packages = _load_traffic_packages() - default_period_days = 30 if 30 in periods else periods[0] - default_traffic = _resolve_default_traffic(subscription) - if not settings.is_traffic_fixed(): - allowed_traffic = {pkg["gb"] for pkg in traffic_packages} - if default_traffic not in allowed_traffic and allowed_traffic: - default_traffic = next(iter(allowed_traffic)) - default_devices = _resolve_default_devices(subscription) - - periods_payload: List[MiniAppSubscriptionPurchasePeriod] = [] - for period_days in periods: - periods_payload.append( - _build_purchase_period_entry( - user, - period_days, - is_default=period_days == default_period_days, - servers_catalog=servers_catalog, - ordered_servers=ordered_servers, - traffic_packages=traffic_packages, - default_servers=default_servers, - default_traffic=default_traffic, - default_devices=default_devices, - ) - ) - - default_period_entry = next( - (entry for entry in periods_payload if entry.period_days == default_period_days), - periods_payload[0], - ) - - traffic_config = ( - default_period_entry.traffic.model_copy(deep=True) - if default_period_entry.traffic - else MiniAppSubscriptionPurchaseTrafficConfig( - mode="fixed" if settings.is_traffic_fixed() else "selectable", - selectable=not settings.is_traffic_fixed(), - current=default_traffic, - default=default_traffic, - options=[], - ) - ) - servers_config = ( - default_period_entry.servers.model_copy(deep=True) - if default_period_entry.servers - else MiniAppSubscriptionPurchaseServersConfig( - selectable=len(available_servers) > 1, - min=1, - options=[], - selected=default_servers, - default=default_servers, - ) - ) - devices_config = ( - default_period_entry.devices.model_copy(deep=True) - if default_period_entry.devices - else MiniAppSubscriptionPurchaseDevicesConfig( - selectable=True, - min=max(1, settings.DEFAULT_DEVICE_LIMIT), - max=settings.MAX_DEVICES_LIMIT or None, - step=1, - current=default_devices, - default=default_devices, - base=max(1, settings.DEFAULT_DEVICE_LIMIT), - ) - ) - - selection_payload = { - "period_id": f"days:{default_period_days}", - "period_days": default_period_days, - "traffic_value": default_traffic, - "servers": default_servers, - "devices": default_devices, - } - - options_payload = MiniAppSubscriptionPurchaseOptions( - currency="RUB", - balance_kopeks=user.balance_kopeks, - balance_label=_format_price(user.balance_kopeks), - periods=periods_payload, - traffic=traffic_config, - servers=servers_config, - devices=devices_config, - selection=selection_payload, - subscriptionId=getattr(subscription, "id", None), - ) - - return MiniAppSubscriptionPurchaseOptionsResponse(data=options_payload) - - -@router.post( - "/subscription/purchase/preview", - response_model=MiniAppSubscriptionPurchasePreviewResponse, -) -async def get_subscription_purchase_preview_endpoint( - payload: MiniAppSubscriptionPurchasePreviewRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionPurchasePreviewResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_purchase_allowed(user) - - periods = _get_available_purchase_periods() - if not periods: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "periods_unavailable", "message": "Subscription periods are not configured"}, - ) - - ordered_servers, servers_catalog = await _build_purchase_server_catalog(db, user, subscription) - available_servers = [uuid for uuid, entry in servers_catalog.items() if entry.get("available", False)] - if not available_servers: - language = _get_language_code(user) - message = ( - "В данный момент нет доступных серверов" - if language == "ru" - else "No subscription servers are currently available" - ) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "servers_unavailable", "message": message}, - ) - - default_servers = _resolve_default_servers(ordered_servers, servers_catalog, subscription) - if not default_servers: - default_servers = [available_servers[0]] - - traffic_packages = _load_traffic_packages() - default_period_days = 30 if 30 in periods else periods[0] - default_traffic = _resolve_default_traffic(subscription) - if not settings.is_traffic_fixed(): - allowed_traffic = {pkg["gb"] for pkg in traffic_packages} - if default_traffic not in allowed_traffic and allowed_traffic: - default_traffic = next(iter(allowed_traffic)) - default_devices = _resolve_default_devices(subscription) - - normalized_selection = _normalize_purchase_selection( - user, - payload.selection, - subscription=subscription, - available_periods=periods, - default_period_days=default_period_days, - servers_catalog=servers_catalog, - default_servers=default_servers, - traffic_packages=traffic_packages, - ) - - calculation = _calculate_purchase_pricing(user, normalized_selection, servers_catalog) - preview_payload = _build_purchase_preview(user, calculation) - - return MiniAppSubscriptionPurchasePreviewResponse(preview=preview_payload) - - -@router.post( - "/subscription/purchase", - response_model=MiniAppSubscriptionPurchaseSubmitResponse, -) -async def submit_subscription_purchase_endpoint( - payload: MiniAppSubscriptionPurchaseSubmitRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionPurchaseSubmitResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_purchase_allowed(user) - - periods = _get_available_purchase_periods() - if not periods: - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "periods_unavailable", "message": "Subscription periods are not configured"}, - ) - - ordered_servers, servers_catalog = await _build_purchase_server_catalog(db, user, subscription) - available_servers = [uuid for uuid, entry in servers_catalog.items() if entry.get("available", False)] - if not available_servers: - language = _get_language_code(user) - message = ( - "В данный момент нет доступных серверов" - if language == "ru" - else "No subscription servers are currently available" - ) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "servers_unavailable", "message": message}, - ) - - default_servers = _resolve_default_servers(ordered_servers, servers_catalog, subscription) - if not default_servers: - default_servers = [available_servers[0]] - - traffic_packages = _load_traffic_packages() - default_period_days = 30 if 30 in periods else periods[0] - default_traffic = _resolve_default_traffic(subscription) - if not settings.is_traffic_fixed(): - allowed_traffic = {pkg["gb"] for pkg in traffic_packages} - if default_traffic not in allowed_traffic and allowed_traffic: - default_traffic = next(iter(allowed_traffic)) - default_devices = _resolve_default_devices(subscription) - - normalized_selection = _normalize_purchase_selection( - user, - payload.selection, - subscription=subscription, - available_periods=periods, - default_period_days=default_period_days, - servers_catalog=servers_catalog, - default_servers=default_servers, - traffic_packages=traffic_packages, - ) - - calculation = _calculate_purchase_pricing(user, normalized_selection, servers_catalog) - - if calculation.final_price > user.balance_kopeks: - missing_amount = calculation.final_price - user.balance_kopeks - language = _get_language_code(user) - message = ( - f"Недостаточно средств: нужно {_format_price(calculation.final_price)}, на балансе {_format_price(user.balance_kopeks)}" - if language == "ru" - else "Insufficient balance for subscription purchase" - ) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": message, - "missingAmountKopeks": missing_amount, - }, - ) - - description = f"Покупка подписки на {normalized_selection['period_days']} дней" - success = await subtract_user_balance( - db, - user, - calculation.final_price, - description, - consume_promo_offer=calculation.promo_discount_value > 0, - ) - - if not success: - missing_amount = calculation.final_price - user.balance_kopeks - language = _get_language_code(user) - message = ( - "Недостаточно средств на балансе" - if language == "ru" - else "Insufficient balance for subscription purchase" - ) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": message, - "missingAmountKopeks": max(0, missing_amount), - }, - ) - - await db.refresh(user) - - final_traffic_gb = ( - settings.get_fixed_traffic_limit() if settings.is_traffic_fixed() else normalized_selection["traffic_gb"] - ) - selected_servers = normalized_selection["servers"] - devices = normalized_selection["devices"] - period_days = normalized_selection["period_days"] - - existing_subscription = getattr(user, "subscription", None) - current_time = datetime.utcnow() - was_trial_conversion = bool(existing_subscription and existing_subscription.is_trial) - - if existing_subscription: - bonus_period = timedelta() - if existing_subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: - remaining = existing_subscription.end_date - current_time - if remaining.total_seconds() > 0: - bonus_period = remaining - - if existing_subscription.is_trial: - try: - trial_duration = max(0, (current_time - existing_subscription.start_date).days) - await create_subscription_conversion( - db=db, - user_id=user.id, - trial_duration_days=trial_duration, - payment_method="balance", - first_payment_amount_kopeks=calculation.final_price, - first_paid_period_days=period_days, - ) - except Exception as conversion_error: # pragma: no cover - logger.error("Failed to record subscription conversion: %s", conversion_error) - - existing_subscription.is_trial = False - existing_subscription.status = SubscriptionStatus.ACTIVE.value - existing_subscription.traffic_limit_gb = final_traffic_gb - existing_subscription.device_limit = devices - existing_subscription.connected_squads = selected_servers - existing_subscription.start_date = current_time - existing_subscription.end_date = current_time + timedelta(days=period_days) + bonus_period - existing_subscription.updated_at = current_time - existing_subscription.traffic_used_gb = 0.0 - - await db.commit() - await db.refresh(existing_subscription) - subscription_obj = existing_subscription - else: - subscription_obj = await create_paid_subscription( - db=db, - user_id=user.id, - duration_days=period_days, - traffic_limit_gb=final_traffic_gb, - device_limit=devices, - connected_squads=selected_servers, - update_server_counters=False, - ) - - await mark_user_as_had_paid_subscription(db, user) - - server_ids = await get_server_ids_by_uuids(db, selected_servers) - if server_ids: - try: - await add_subscription_servers( - db, - subscription_obj, - server_ids, - calculation.servers_price_discounted, - ) - await add_user_to_servers(db, server_ids) - except Exception as error: # pragma: no cover - logger.error("Failed to link subscription servers: %s", error) - - subscription_service = SubscriptionService() - try: - if getattr(user, "remnawave_uuid", None): - await subscription_service.update_remnawave_user( - db, - subscription_obj, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="subscription_purchase", - ) - else: - await subscription_service.create_remnawave_user( - db, - subscription_obj, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="subscription_purchase", - ) - except Exception as sync_error: # pragma: no cover - logger.error("Failed to sync subscription with RemnaWave: %s", sync_error) - - transaction = await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=calculation.final_price, - description=f"Подписка на {period_days} дней ({calculation.months} мес)", - ) - - try: - bot = Bot(token=settings.BOT_TOKEN) - notification_service = AdminNotificationService(bot) - await notification_service.send_subscription_purchase_notification( - db, - user, - subscription_obj, - transaction, - period_days, - was_trial_conversion, - ) - except Exception as notify_error: # pragma: no cover - logger.error("Failed to send admin notification: %s", notify_error) - finally: - try: - await bot.session.close() - except Exception: # pragma: no cover - pass - - await db.refresh(user) - await db.refresh(subscription_obj) - - texts = get_texts(user.language) - success_message = texts.SUBSCRIPTION_PURCHASED - if calculation.promo_discount_value > 0: - try: - discount_note = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_NOTE", - "⚡ Доп. скидка {percent}%: -{amount}", - ).format( - percent=calculation.promo_discount_percent, - amount=_format_price(calculation.promo_discount_value), - ) - success_message = f"{success_message}\n\n{discount_note}" - except Exception: - pass - - return MiniAppSubscriptionPurchaseSubmitResponse(success=True, message=success_message) - - @router.post( "/promo-codes/activate", response_model=MiniAppPromoCodeActivationResponse, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index cbe521e4..f461f372 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -360,13 +360,10 @@ class MiniAppSubscriptionServerOption(BaseModel): name: Optional[str] = None price_kopeks: Optional[int] = None price_label: Optional[str] = None - original_price_kopeks: Optional[int] = None - original_price_label: Optional[str] = None discount_percent: Optional[int] = None is_connected: bool = False is_available: bool = True disabled_reason: Optional[str] = None - description: Optional[str] = None class MiniAppSubscriptionTrafficOption(BaseModel): @@ -374,9 +371,6 @@ class MiniAppSubscriptionTrafficOption(BaseModel): label: Optional[str] = None price_kopeks: Optional[int] = None price_label: Optional[str] = None - original_price_kopeks: Optional[int] = None - original_price_label: Optional[str] = None - discount_percent: Optional[int] = None is_current: bool = False is_available: bool = True description: Optional[str] = None @@ -530,199 +524,3 @@ class MiniAppSubscriptionUpdateResponse(BaseModel): success: bool = True message: Optional[str] = None - -class MiniAppPurchaseBreakdownItem(BaseModel): - label: str - value: str - highlight: bool = False - - -class MiniAppSubscriptionPurchaseTrafficConfig(BaseModel): - mode: Optional[str] = None - selectable: bool = True - options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list) - current: Optional[int] = None - default: Optional[int] = None - hint: Optional[str] = None - - -class MiniAppSubscriptionPurchaseServersConfig(BaseModel): - selectable: bool = True - min: int = 0 - max: Optional[int] = None - options: List[MiniAppSubscriptionServerOption] = Field(default_factory=list) - selected: List[str] = Field(default_factory=list) - default: List[str] = Field(default_factory=list) - hint: Optional[str] = None - - -class MiniAppSubscriptionPurchaseDevicesConfig(BaseModel): - selectable: bool = True - min: int = 1 - max: Optional[int] = None - step: int = 1 - current: Optional[int] = None - default: Optional[int] = None - base: Optional[int] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None - discount_percent: Optional[int] = None - - -class MiniAppSubscriptionPurchasePeriod(BaseModel): - id: str - period_days: int - months: int - label: Optional[str] = None - description: Optional[str] = None - price_kopeks: int - price_label: Optional[str] = None - original_price_kopeks: Optional[int] = None - original_price_label: Optional[str] = None - discount_percent: int = 0 - discount_value_kopeks: Optional[int] = None - discount_value_label: Optional[str] = None - traffic: Optional[MiniAppSubscriptionPurchaseTrafficConfig] = None - servers: Optional[MiniAppSubscriptionPurchaseServersConfig] = None - devices: Optional[MiniAppSubscriptionPurchaseDevicesConfig] = None - is_default: bool = False - - -class MiniAppSubscriptionPurchaseSelection(BaseModel): - period_id: Optional[str] = Field(default=None, alias="periodId") - period_days: Optional[int] = Field(default=None, alias="periodDays") - period_months: Optional[int] = Field(default=None, alias="periodMonths") - traffic_value: Optional[int] = Field(default=None, alias="trafficValue") - servers: List[str] = Field(default_factory=list) - devices: Optional[int] = None - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - @model_validator(mode="before") - @classmethod - def _collect_selection_fields(cls, values: Any) -> Any: - if isinstance(values, dict): - aliases = { - "period": "period_id", - "periodKey": "period_id", - "code": "period_id", - "period_key": "period_id", - "duration_days": "period_days", - "durationDays": "period_days", - "months": "period_months", - "traffic": "traffic_value", - "traffic_gb": "traffic_value", - "trafficGb": "traffic_value", - "limit": "traffic_value", - "countries": "servers", - "server_uuids": "servers", - "serverUuids": "servers", - "device_limit": "devices", - "deviceLimit": "devices", - } - for source, target in aliases.items(): - if source in values and target not in values: - values[target] = values[source] - return values - - -class MiniAppSubscriptionPurchaseOptions(BaseModel): - currency: str = "RUB" - balance_kopeks: int = 0 - balance_label: Optional[str] = None - periods: List[MiniAppSubscriptionPurchasePeriod] = Field(default_factory=list) - traffic: MiniAppSubscriptionPurchaseTrafficConfig - servers: MiniAppSubscriptionPurchaseServersConfig - devices: MiniAppSubscriptionPurchaseDevicesConfig - selection: Dict[str, Any] = Field(default_factory=dict) - promo: Optional[Dict[str, Any]] = None - summary: Optional[Dict[str, Any]] = None - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - - -class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel): - success: bool = True - data: MiniAppSubscriptionPurchaseOptions - - -class MiniAppSubscriptionPurchasePreview(BaseModel): - period_days: int - months: int - total_price_kopeks: int = Field(..., alias="totalPriceKopeks") - total_price_label: str = Field(..., alias="totalPriceLabel") - original_price_kopeks: int = Field(..., alias="originalPriceKopeks") - original_price_label: str = Field(..., alias="originalPriceLabel") - per_month_price_kopeks: int = Field(..., alias="perMonthPriceKopeks") - per_month_price_label: str = Field(..., alias="perMonthPriceLabel") - discount_percent: Optional[int] = Field(default=None, alias="discountPercent") - discount_label: Optional[str] = Field(default=None, alias="discountLabel") - discount_lines: List[str] = Field(default_factory=list, alias="discountLines") - breakdown: List[MiniAppPurchaseBreakdownItem] = Field(default_factory=list) - balance_kopeks: int = Field(..., alias="balanceKopeks") - balance_label: str = Field(..., alias="balanceLabel") - missing_amount_kopeks: int = Field(..., alias="missingAmountKopeks") - missing_amount_label: Optional[str] = Field(default=None, alias="missingAmountLabel") - can_purchase: bool = Field(True, alias="canPurchase") - promo_discount_percent: Optional[int] = Field(default=None, alias="promoDiscountPercent") - promo_discount_value: Optional[int] = Field(default=None, alias="promoDiscountValue") - promo_discount_label: Optional[str] = Field(default=None, alias="promoDiscountLabel") - status_message: Optional[str] = Field(default=None, alias="statusMessage") - - -class MiniAppSubscriptionPurchasePreviewResponse(BaseModel): - success: bool = True - preview: MiniAppSubscriptionPurchasePreview - - -class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - -class MiniAppSubscriptionPurchasePreviewRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - selection: Optional[MiniAppSubscriptionPurchaseSelection] = None - - model_config = ConfigDict(populate_by_name=True, extra="allow") - - @model_validator(mode="before") - @classmethod - def _merge_selection(cls, values: Any) -> Any: - if isinstance(values, dict): - if "selection" not in values: - selection_fields = { - key: values.get(key) - for key in ( - "periodId", - "period_id", - "periodDays", - "period_days", - "periodMonths", - "period_months", - "trafficValue", - "traffic_value", - "traffic_gb", - "trafficGb", - "limit", - "servers", - "countries", - "server_uuids", - "serverUuids", - "devices", - "device_limit", - "deviceLimit", - ) - if key in values - } - if selection_fields: - values["selection"] = selection_fields - return values - - -class MiniAppSubscriptionPurchaseSubmitRequest(MiniAppSubscriptionPurchasePreviewRequest): - pass - - -class MiniAppSubscriptionPurchaseSubmitResponse(BaseModel): - success: bool = True - message: Optional[str] = None