diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 66aa1000..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 @@ -15,7 +14,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.config import settings, PERIOD_PRICES +from app.config import settings from app.database.crud.discount_offer import ( get_latest_claimed_offer_for_user, get_offer_by_id, @@ -28,15 +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 import add_subscription_servers, remove_subscription_servers from app.database.crud.transaction import ( create_transaction, get_user_total_spent_kopeks, @@ -51,7 +45,6 @@ from app.database.models import ( TransactionType, PaymentMethod, User, - SubscriptionStatus, ) from app.services.faq_service import FaqService from app.services.privacy_policy_service import PrivacyPolicyService @@ -74,18 +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, - calculate_months_from_days, - format_period_description, ) -from app.utils.promo_offer import get_user_active_promo_discount_percent -from app.localization.texts import get_texts -from app.database.crud.subscription_conversion import create_subscription_conversion from ..dependencies import get_db_session from ..schemas.miniapp import ( @@ -139,18 +126,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionTrafficUpdateRequest, MiniAppSubscriptionDevicesUpdateRequest, MiniAppSubscriptionUpdateResponse, - MiniAppSubscriptionPurchaseOptions, - MiniAppSubscriptionPurchaseOptionsRequest, - MiniAppSubscriptionPurchaseOptionsResponse, - MiniAppSubscriptionPurchasePreviewRequest, - MiniAppSubscriptionPurchasePreviewResponse, - MiniAppSubscriptionPurchasePreview, - MiniAppSubscriptionPurchaseSubmitRequest, - MiniAppSubscriptionPurchaseSubmitResponse, - MiniAppSubscriptionPurchasePeriod, - MiniAppSubscriptionPurchaseTrafficConfig, - MiniAppSubscriptionPurchaseServersConfig, - MiniAppSubscriptionPurchaseDevicesConfig, ) @@ -2838,1236 +2813,6 @@ async def _authorize_miniapp_user( return user -@dataclass -class SubscriptionPurchaseCalculation: - period_days: int - months: int - base_price_original: int - base_discount_percent: int - base_discount_total: int - base_price: int - traffic_gb: int - traffic_price_per_month: int - traffic_discount_percent: int - traffic_discount_per_month: int - traffic_discounted_per_month: int - total_traffic_price: int - traffic_original_total: int - servers_selected: List[str] - servers_price_per_month: int - servers_discount_percent: int - servers_discounted_per_month: int - servers_discount_total: int - total_servers_price: int - servers_original_total: int - server_prices_for_period: List[int] - devices: int - devices_price_per_month: int - devices_discount_percent: int - devices_discounted_per_month: int - devices_discount_total: int - total_devices_price: int - devices_original_total: int - discounted_monthly_additions: int - original_total_price: int - total_price_before_promo: int - promo_discount_percent: int - promo_discount_value: int - final_price: int - balance_kopeks: int - missing_amount_kopeks: int - - -def _normalize_language_code(language: Optional[str]) -> str: - base_language = language or settings.DEFAULT_LANGUAGE or "ru" - normalized = base_language.split("-")[0].lower().strip() - return normalized or "ru" - - -def _localize(language_code: str, ru_text: str, en_text: str) -> str: - return ru_text if language_code == "ru" else en_text - - -def _get_included_label(texts) -> str: - included = texts.t("subscription_purchase.price.included", "Включено") - if included and included != "subscription_purchase.price.included": - return included - language_code = _normalize_language_code(getattr(texts, "language", None)) - return _localize(language_code, "Включено", "Included") - - -def _ensure_purchase_eligibility(user: User) -> None: - subscription = getattr(user, "subscription", None) - if not subscription: - return - - if subscription.is_active and not subscription.is_trial: - language_code = _normalize_language_code(getattr(user, "language", None)) - message = _localize( - language_code, - "У вас уже есть активная платная подписка.", - "You already have an active paid subscription.", - ) - raise HTTPException( - status.HTTP_409_CONFLICT, - detail={"code": "subscription_active", "message": message}, - ) - - -def _build_purchase_periods(user: User, texts) -> List[MiniAppSubscriptionPurchasePeriod]: - language_code = _normalize_language_code(getattr(texts, "language", None)) - available_periods = settings.get_available_subscription_periods() - periods: List[MiniAppSubscriptionPurchasePeriod] = [] - - for period_days in available_periods: - base_price_original = PERIOD_PRICES.get(period_days, 0) - try: - discount_percent = int(user.get_promo_discount("period", period_days)) - except AttributeError: - discount_percent = 0 - discount_percent = max(0, min(100, discount_percent)) - discounted_price, discount_value = apply_percentage_discount( - base_price_original, - discount_percent, - ) - - months = max(1, calculate_months_from_days(period_days)) - per_month_value = math.ceil(discounted_price / months) if discounted_price > 0 else 0 - label = format_period_description(period_days, language_code) - - periods.append( - MiniAppSubscriptionPurchasePeriod( - id=str(period_days), - code=str(period_days), - period_days=period_days, - period_months=months, - months=months, - label=label, - price_kopeks=discounted_price, - price_label=settings.format_price(discounted_price), - original_price_kopeks=base_price_original if discount_value > 0 else None, - original_price_label=( - settings.format_price(base_price_original) - if discount_value > 0 - else None - ), - per_month_price_kopeks=per_month_value if per_month_value > 0 else None, - per_month_price_label=( - settings.format_price(per_month_value) - if per_month_value > 0 - else None - ), - discount_percent=discount_percent if discount_value > 0 else None, - ) - ) - - return periods - - -def _build_purchase_traffic_config(user: User, texts) -> MiniAppSubscriptionPurchaseTrafficConfig: - included_label = _get_included_label(texts) - config = MiniAppSubscriptionPurchaseTrafficConfig() - - if settings.is_traffic_fixed(): - value = settings.get_fixed_traffic_limit() - config.selectable = False - config.mode = "fixed" - config.default = value - config.current = value - config.options = [] - return config - - discount_percent = _get_addon_discount_percent_for_user(user, "traffic") - packages = [pkg for pkg in settings.get_traffic_packages() if pkg.get("enabled")] - options: List[MiniAppSubscriptionTrafficOption] = [] - - for index, package in enumerate(packages): - try: - gb_value = int(package.get("gb", 0) or 0) - except (TypeError, ValueError): - gb_value = 0 - try: - base_price = int(package.get("price", 0) or 0) - except (TypeError, ValueError): - base_price = 0 - - discounted_price, discount_value = apply_percentage_discount( - base_price, - discount_percent, - ) - - if gb_value <= 0: - label = texts.t("subscription_purchase.traffic.unlimited", "Безлимитный трафик") - if label == "subscription_purchase.traffic.unlimited": - label = _localize( - _normalize_language_code(getattr(texts, "language", None)), - "Безлимитный трафик", - "Unlimited traffic", - ) - else: - label = texts.format_traffic(gb_value) - - options.append( - MiniAppSubscriptionTrafficOption( - value=gb_value, - label=label, - price_kopeks=discounted_price, - price_label=( - settings.format_price(discounted_price) - if discounted_price > 0 - else included_label - ), - original_price_kopeks=base_price if discount_value > 0 else None, - original_price_label=( - settings.format_price(base_price) - if discount_value > 0 - else None - ), - is_default=index == 0, - is_available=True, - ) - ) - - if options: - config.options = options - config.default = options[0].value - config.current = options[0].value - else: - config.selectable = False - - return config - - -async def _build_purchase_servers_config( - db: AsyncSession, - user: User, - texts, -) -> MiniAppSubscriptionPurchaseServersConfig: - included_label = _get_included_label(texts) - config = MiniAppSubscriptionPurchaseServersConfig() - discount_percent = _get_addon_discount_percent_for_user(user, "servers") - - available_servers = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - - options: List[MiniAppSubscriptionServerOption] = [] - for server in available_servers: - base_price = int(getattr(server, "price_kopeks", 0) or 0) - discounted_price, discount_value = apply_percentage_discount( - base_price, - discount_percent, - ) - options.append( - MiniAppSubscriptionServerOption( - uuid=server.squad_uuid, - name=getattr(server, "display_name", server.squad_uuid), - price_kopeks=discounted_price, - price_label=( - settings.format_price(discounted_price) - if discounted_price > 0 - else included_label - ), - original_price_kopeks=base_price if discount_value > 0 else None, - original_price_label=( - settings.format_price(base_price) - if discount_value > 0 - else None - ), - discount_percent=discount_percent if discount_value > 0 else None, - is_available=bool(server.is_available and not server.is_full), - description=getattr(server, "description", None), - ) - ) - - config.options = options - if options: - config.min = 1 - config.max = len(options) - config.selectable = len(options) > 1 - - subscription = getattr(user, "subscription", None) - default_selection = [] - if subscription and getattr(subscription, "connected_squads", None): - default_selection = [ - uuid - for uuid in subscription.connected_squads - if any(option.uuid == uuid for option in options) - ] - - if not default_selection and options: - first_available = next((opt.uuid for opt in options if opt.is_available), None) - if first_available: - default_selection = [first_available] - - config.default = default_selection - config.selected = list(default_selection) - return config - - -def _build_purchase_devices_config(user: User, texts) -> MiniAppSubscriptionPurchaseDevicesConfig: - included_label = _get_included_label(texts) - config = MiniAppSubscriptionPurchaseDevicesConfig() - - subscription = getattr(user, "subscription", None) - default_limit = settings.DEFAULT_DEVICE_LIMIT or 1 - if subscription and getattr(subscription, "device_limit", None): - try: - default_limit = max(default_limit, int(subscription.device_limit)) - except (TypeError, ValueError): - default_limit = max(1, settings.DEFAULT_DEVICE_LIMIT or 1) - - max_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None - discount_percent = _get_addon_discount_percent_for_user(user, "devices") - base_price = max(0, settings.PRICE_PER_DEVICE) - discounted_price, discount_value = apply_percentage_discount(base_price, discount_percent) - - config.min = max(1, settings.DEFAULT_DEVICE_LIMIT or 1) - config.max = max_limit if max_limit is not None else 0 - config.default = default_limit - config.current = default_limit - config.included = settings.DEFAULT_DEVICE_LIMIT - config.price_kopeks = discounted_price if discounted_price > 0 else 0 - config.price_label = ( - settings.format_price(discounted_price) - if discounted_price > 0 - else included_label - ) - config.original_price_kopeks = base_price if discount_value > 0 else None - config.original_price_label = ( - settings.format_price(base_price) - if discount_value > 0 - else None - ) - - return config - - -def _build_purchase_selection( - periods: List[MiniAppSubscriptionPurchasePeriod], - traffic: MiniAppSubscriptionPurchaseTrafficConfig, - servers: MiniAppSubscriptionPurchaseServersConfig, - devices: MiniAppSubscriptionPurchaseDevicesConfig, -) -> Dict[str, Any]: - selection: Dict[str, Any] = {} - - if periods: - period = periods[0] - if period.id is not None: - selection["period_id"] = str(period.id) - if period.period_days is not None: - selection["period_days"] = int(period.period_days) - if period.period_months is not None: - selection["period_months"] = int(period.period_months) - - if traffic.current is not None: - selection["traffic_value"] = traffic.current - elif traffic.default is not None: - selection["traffic_value"] = traffic.default - - if servers.selected: - selection["servers"] = list(servers.selected) - elif servers.default: - selection["servers"] = list(servers.default) - - if devices.current is not None: - selection["devices"] = int(devices.current) - elif devices.default is not None: - selection["devices"] = int(devices.default) - - return selection - - -def _build_selection_from_request( - payload: MiniAppSubscriptionPurchasePreviewRequest, - base_selection: Dict[str, Any], -) -> Dict[str, Any]: - selection = dict(base_selection or {}) - - if getattr(payload, "period_id", None) is not None: - selection["period_id"] = str(payload.period_id) - if getattr(payload, "period_days", None) is not None: - try: - selection["period_days"] = int(payload.period_days) - except (TypeError, ValueError): - pass - if getattr(payload, "period_months", None) is not None: - try: - selection["period_months"] = int(payload.period_months) - except (TypeError, ValueError): - pass - - for key in ("traffic_value", "traffic", "traffic_gb"): - value = getattr(payload, key, None) - if value is not None: - try: - selection["traffic_value"] = int(value) - except (TypeError, ValueError): - selection["traffic_value"] = value - break - - server_values: List[str] = [] - for key in ("servers", "server_uuids", "squads", "squad_uuids"): - value = getattr(payload, key, None) - if not value: - continue - if isinstance(value, (list, tuple, set)): - iterable = value - else: - iterable = [value] - for item in iterable: - if item is None: - continue - uuid = str(item).strip() - if uuid and uuid not in server_values: - server_values.append(uuid) - if server_values: - selection["servers"] = server_values - - device_value = getattr(payload, "devices", None) - if device_value is None: - device_value = getattr(payload, "device_limit", None) - if device_value is not None: - try: - selection["devices"] = int(device_value) - except (TypeError, ValueError): - pass - - return selection - - -def _resolve_period_by_selection( - periods: List[MiniAppSubscriptionPurchasePeriod], - selection: Dict[str, Any], -) -> Optional[MiniAppSubscriptionPurchasePeriod]: - if not periods: - return None - - period_id = selection.get("period_id") - if period_id is not None: - for period in periods: - identifiers = [period.id, period.code, period.period_days] - if any(str(identifier) == str(period_id) for identifier in identifiers if identifier is not None): - selection["period_id"] = str(period.id or period.code or period.period_days) - if period.period_days is not None: - selection["period_days"] = int(period.period_days) - if period.period_months is not None: - selection["period_months"] = int(period.period_months) - return period - - period_days = selection.get("period_days") - if period_days is not None: - for period in periods: - if period.period_days == period_days: - selection["period_id"] = str(period.id or period.code or period.period_days) - return period - - period_months = selection.get("period_months") - if period_months: - try: - target_days = int(period_months) * 30 - except (TypeError, ValueError): - target_days = None - if target_days: - for period in periods: - if period.period_days == target_days: - selection["period_id"] = str(period.id or period.code or period.period_days) - selection["period_days"] = int(period.period_days) - selection["period_months"] = int(period.period_months or period_months) - return period - - period = periods[0] - selection["period_id"] = str(period.id or period.code or period.period_days) - if period.period_days is not None: - selection["period_days"] = int(period.period_days) - if period.period_months is not None: - selection["period_months"] = int(period.period_months) - return period - - -def _resolve_traffic_value( - selection: Dict[str, Any], - config: MiniAppSubscriptionPurchaseTrafficConfig, -) -> Optional[int]: - if not config.selectable or str(config.mode).lower() == "fixed": - return config.current if config.current is not None else config.default - - value = selection.get("traffic_value") - if value is None: - value = selection.get("traffic") - - available_values: List[int] = [] - for option in config.options: - if option is None or option.value is None: - continue - try: - available_values.append(int(option.value)) - except (TypeError, ValueError): - continue - - if not available_values: - return config.default - - try: - numeric_value = int(value) - except (TypeError, ValueError): - numeric_value = available_values[0] - - if numeric_value not in available_values: - numeric_value = available_values[0] - - return numeric_value - - -def _resolve_server_selection( - selection: Dict[str, Any], - config: MiniAppSubscriptionPurchaseServersConfig, - language_code: str, -) -> List[str]: - available_map = {option.uuid: option for option in config.options if option.uuid} - - raw_values: List[str] = [] - for key in ("servers", "server_uuids", "squads", "squad_uuids"): - value = selection.get(key) - if not value: - continue - if isinstance(value, (list, tuple, set)): - iterable = value - else: - iterable = [value] - for item in iterable: - if item is None: - continue - uuid = str(item).strip() - if uuid and uuid not in raw_values: - raw_values.append(uuid) - - if not raw_values: - raw_values = list(config.selected or config.default or []) - - if not raw_values and available_map: - first_available = next((uuid for uuid, opt in available_map.items() if opt.is_available), None) - if first_available: - raw_values.append(first_available) - - valid_selection: List[str] = [] - for uuid in raw_values: - option = available_map.get(uuid) - if not option: - continue - if not option.is_available: - continue - valid_selection.append(uuid) - - if config.min and len(valid_selection) < config.min: - message = _localize( - language_code, - "Недостаточно доступных серверов для оформления подписки.", - "Not enough available servers to complete the purchase.", - ) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_servers", "message": message}, - ) - - return valid_selection - - -def _resolve_devices_selection( - selection: Dict[str, Any], - config: MiniAppSubscriptionPurchaseDevicesConfig, -) -> int: - value = selection.get("devices") or selection.get("device_limit") - if value is None: - value = config.current or config.default or config.min or 1 - - try: - numeric_value = int(value) - except (TypeError, ValueError): - numeric_value = config.current or config.default or config.min or 1 - - if config.min and numeric_value < config.min: - numeric_value = config.min - - if config.max and config.max > 0 and numeric_value > config.max: - numeric_value = config.max - - return numeric_value - - -async def _calculate_purchase_summary( - db: AsyncSession, - user: User, - texts, - periods: List[MiniAppSubscriptionPurchasePeriod], - traffic: MiniAppSubscriptionPurchaseTrafficConfig, - servers: MiniAppSubscriptionPurchaseServersConfig, - devices: MiniAppSubscriptionPurchaseDevicesConfig, - selection: Dict[str, Any], -) -> SubscriptionPurchaseCalculation: - language_code = _normalize_language_code(getattr(texts, "language", None)) - - period = _resolve_period_by_selection(periods, selection) - if not period or period.period_days is None: - message = _localize( - language_code, - "Не удалось определить период подписки.", - "Unable to determine the subscription period.", - ) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_period", "message": message}, - ) - - period_days = int(period.period_days) - months = max(1, 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_discount_percent = max(0, min(100, base_discount_percent)) - base_price, base_discount_total = apply_percentage_discount( - base_price_original, - base_discount_percent, - ) - - traffic_value = _resolve_traffic_value(selection, traffic) - if traffic_value is None: - traffic_value = 0 - - if settings.is_traffic_fixed(): - traffic_gb = settings.get_fixed_traffic_limit() - else: - try: - traffic_gb = int(traffic_value) - except (TypeError, ValueError): - traffic_gb = 0 - - traffic_price_per_month = settings.get_traffic_price(traffic_gb) - 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, - ) - total_traffic_price = traffic_discounted_per_month * months - traffic_original_total = traffic_price_per_month * months - traffic_discount_total = traffic_discount_per_month * months - - servers_selected = _resolve_server_selection(selection, servers, language_code) - available_map = {option.uuid: option for option in servers.options if option.uuid} - servers_discount_percent = _get_addon_discount_percent_for_user( - user, - "servers", - period_days, - ) - servers_price_per_month = 0 - discounted_servers_price_per_month = 0 - servers_original_total = 0 - servers_discount_total = 0 - server_prices_for_period: List[int] = [] - - for uuid in servers_selected: - option = available_map.get(uuid) - if not option or not option.is_available: - message = _localize( - language_code, - "Выбран недоступный сервер.", - "Selected server is not available.", - ) - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "server_unavailable", "message": message}, - ) - - discounted_price = int(option.price_kopeks or 0) - original_price = int( - option.original_price_kopeks - if option.original_price_kopeks is not None - else option.price_kopeks or 0 - ) - - servers_price_per_month += original_price - discounted_servers_price_per_month += discounted_price - servers_original_total += original_price * months - servers_discount_total += (original_price - discounted_price) * months - server_prices_for_period.append(discounted_price * months) - - total_servers_price = discounted_servers_price_per_month * months - - devices_value = _resolve_devices_selection(selection, devices) - included_devices = max(1, settings.DEFAULT_DEVICE_LIMIT or 1) - additional_devices = max(0, devices_value - included_devices) - base_device_price = max(0, settings.PRICE_PER_DEVICE) - devices_price_per_month = additional_devices * base_device_price - 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_original_total = devices_price_per_month * months - devices_discount_total = devices_discount_per_month * months - total_devices_price = devices_discounted_per_month * months - - discounted_monthly_additions = ( - traffic_discounted_per_month - + discounted_servers_price_per_month - + devices_discounted_per_month - ) - - original_total_price = ( - base_price_original - + traffic_original_total - + servers_original_total - + devices_original_total - ) - - total_price_before_promo = ( - base_price - + total_traffic_price - + total_servers_price - + total_devices_price - ) - - promo_discount_percent = get_user_active_promo_discount_percent(user) - final_price, promo_discount_value = apply_percentage_discount( - total_price_before_promo, - promo_discount_percent, - ) - - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - missing_amount_kopeks = max(0, final_price - balance_kopeks) - - return SubscriptionPurchaseCalculation( - period_days=period_days, - months=months, - base_price_original=base_price_original, - base_discount_percent=base_discount_percent, - base_discount_total=base_discount_total, - base_price=base_price, - traffic_gb=traffic_gb, - traffic_price_per_month=traffic_price_per_month, - traffic_discount_percent=traffic_discount_percent, - traffic_discount_per_month=traffic_discount_per_month, - traffic_discounted_per_month=traffic_discounted_per_month, - total_traffic_price=total_traffic_price, - traffic_original_total=traffic_original_total, - servers_selected=servers_selected, - servers_price_per_month=servers_price_per_month, - servers_discount_percent=servers_discount_percent, - servers_discounted_per_month=discounted_servers_price_per_month, - servers_discount_total=servers_discount_total, - total_servers_price=total_servers_price, - servers_original_total=servers_original_total, - server_prices_for_period=server_prices_for_period, - devices=devices_value, - devices_price_per_month=devices_price_per_month, - devices_discount_percent=devices_discount_percent, - devices_discounted_per_month=devices_discounted_per_month, - devices_discount_total=devices_discount_total, - total_devices_price=total_devices_price, - devices_original_total=devices_original_total, - discounted_monthly_additions=discounted_monthly_additions, - original_total_price=original_total_price, - total_price_before_promo=total_price_before_promo, - promo_discount_percent=promo_discount_percent, - promo_discount_value=promo_discount_value, - final_price=final_price, - balance_kopeks=balance_kopeks, - missing_amount_kopeks=missing_amount_kopeks, - ) - - -def _build_preview_from_calculation( - calculation: SubscriptionPurchaseCalculation, - texts, -) -> MiniAppSubscriptionPurchasePreview: - language_code = _normalize_language_code(getattr(texts, "language", None)) - included_label = _get_included_label(texts) - - total_discount_amount = calculation.original_total_price - calculation.final_price - discount_percent = None - if calculation.original_total_price > 0 and total_discount_amount > 0: - discount_percent = int( - round(total_discount_amount * 100 / calculation.original_total_price) - ) - - discount_lines: List[str] = [] - if calculation.base_discount_total > 0: - discount_lines.append( - _localize( - language_code, - f"Скидка на период {calculation.base_discount_percent}%: -{settings.format_price(calculation.base_discount_total)}", - f"Period discount {calculation.base_discount_percent}%: -{settings.format_price(calculation.base_discount_total)}", - ) - ) - - traffic_discount_total = calculation.traffic_discount_per_month * calculation.months - if traffic_discount_total > 0: - discount_lines.append( - _localize( - language_code, - f"Скидка на трафик {calculation.traffic_discount_percent}%: -{settings.format_price(traffic_discount_total)}", - f"Traffic discount {calculation.traffic_discount_percent}%: -{settings.format_price(traffic_discount_total)}", - ) - ) - - if calculation.servers_discount_total > 0: - discount_lines.append( - _localize( - language_code, - f"Скидка на серверы {calculation.servers_discount_percent}%: -{settings.format_price(calculation.servers_discount_total)}", - f"Server discount {calculation.servers_discount_percent}%: -{settings.format_price(calculation.servers_discount_total)}", - ) - ) - - if calculation.devices_discount_total > 0: - discount_lines.append( - _localize( - language_code, - f"Скидка на устройства {calculation.devices_discount_percent}%: -{settings.format_price(calculation.devices_discount_total)}", - f"Device discount {calculation.devices_discount_percent}%: -{settings.format_price(calculation.devices_discount_total)}", - ) - ) - - if calculation.promo_discount_value > 0: - discount_lines.append( - _localize( - language_code, - f"Промо-скидка {calculation.promo_discount_percent}%: -{settings.format_price(calculation.promo_discount_value)}", - f"Promo discount {calculation.promo_discount_percent}%: -{settings.format_price(calculation.promo_discount_value)}", - ) - ) - - breakdown: List[MiniAppSubscriptionPurchasePreviewItem] = [] - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Период", "Period"), - value=settings.format_price(calculation.base_price), - highlight=True, - ) - ) - - if calculation.total_traffic_price > 0: - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Трафик", "Traffic"), - value=settings.format_price(calculation.total_traffic_price), - ) - ) - elif calculation.traffic_price_per_month > 0: - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Трафик", "Traffic"), - value=included_label, - ) - ) - - if calculation.total_servers_price > 0: - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Серверы", "Servers"), - value=settings.format_price(calculation.total_servers_price), - ) - ) - elif calculation.servers_price_per_month > 0 and calculation.servers_selected: - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Серверы", "Servers"), - value=included_label, - ) - ) - - if calculation.total_devices_price > 0: - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Устройства", "Devices"), - value=settings.format_price(calculation.total_devices_price), - ) - ) - elif calculation.devices_price_per_month > 0: - breakdown.append( - MiniAppSubscriptionPurchasePreviewItem( - label=_localize(language_code, "Устройства", "Devices"), - value=included_label, - ) - ) - - total_price_label = settings.format_price(calculation.final_price) - original_price_label = ( - settings.format_price(calculation.original_total_price) - if calculation.original_total_price > calculation.final_price - else None - ) - - per_month_value = math.ceil(calculation.final_price / calculation.months) if calculation.final_price > 0 else 0 - per_month_label = ( - settings.format_price(per_month_value) - if per_month_value > 0 - else None - ) - - balance_label = settings.format_price(calculation.balance_kopeks) - missing_amount_label = ( - settings.format_price(calculation.missing_amount_kopeks) - if calculation.missing_amount_kopeks > 0 - else None - ) - - status_message = None - if calculation.missing_amount_kopeks > 0: - status_message = _localize( - language_code, - f"Недостаточно средств. Не хватает {settings.format_price(calculation.missing_amount_kopeks)}", - f"Insufficient funds. Missing {settings.format_price(calculation.missing_amount_kopeks)}", - ) - - discount_label = None - if discount_lines: - discount_label = _localize(language_code, "Скидки", "Discounts") - - return MiniAppSubscriptionPurchasePreview( - total_price_kopeks=calculation.final_price, - total_price_label=total_price_label, - original_price_kopeks=calculation.original_total_price if original_price_label else None, - original_price_label=original_price_label, - per_month_price_kopeks=per_month_value if per_month_label else None, - per_month_price_label=per_month_label, - discount_percent=discount_percent, - discount_label=discount_label, - discount_lines=discount_lines, - breakdown=breakdown, - balance_kopeks=calculation.balance_kopeks, - balance_label=balance_label, - missing_amount_kopeks=calculation.missing_amount_kopeks if missing_amount_label else None, - missing_amount_label=missing_amount_label, - can_purchase=calculation.missing_amount_kopeks <= 0, - status_message=status_message, - ) - - -async def _prepare_purchase_context( - db: AsyncSession, - user: User, -) -> Tuple[ - Any, - List[MiniAppSubscriptionPurchasePeriod], - MiniAppSubscriptionPurchaseTrafficConfig, - MiniAppSubscriptionPurchaseServersConfig, - MiniAppSubscriptionPurchaseDevicesConfig, - Dict[str, Any], -]: - language_code = _normalize_language_code(getattr(user, "language", None)) - texts = get_texts(language_code) - periods = _build_purchase_periods(user, texts) - traffic = _build_purchase_traffic_config(user, texts) - servers = await _build_purchase_servers_config(db, user, texts) - devices = _build_purchase_devices_config(user, texts) - selection = _build_purchase_selection(periods, traffic, servers, devices) - return texts, periods, traffic, servers, devices, selection - - -async def _build_purchase_options_payload( - db: AsyncSession, - user: User, -) -> MiniAppSubscriptionPurchaseOptions: - texts, periods, traffic, servers, devices, selection = await _prepare_purchase_context(db, user) - - currency = (getattr(user, "balance_currency", None) or "RUB").upper() - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - balance_label = settings.format_price(balance_kopeks) - - promo_percent = get_user_active_promo_discount_percent(user) - promo_payload: Optional[Dict[str, Any]] = None - if promo_percent > 0: - promo_label = texts.t( - "subscription_purchase.promo.active", - "Активна дополнительная скидка {percent}%", - ) - if promo_label == "subscription_purchase.promo.active": - promo_label = _localize( - _normalize_language_code(getattr(texts, "language", None)), - f"Активна дополнительная скидка {promo_percent}%", - f"Additional {promo_percent}% discount is active", - ) - promo_payload = { - "discount_percent": promo_percent, - "label": promo_label.format(percent=promo_percent), - "expires_at": getattr(user, "promo_offer_discount_expires_at", None), - } - - subscription_id = getattr(getattr(user, "subscription", None), "id", None) - - return MiniAppSubscriptionPurchaseOptions( - currency=currency, - balance_kopeks=balance_kopeks, - balance_label=balance_label, - subscription_id=subscription_id, - periods=periods, - traffic=traffic, - servers=servers, - devices=devices, - selection=selection, - promo=promo_payload, - ) - - -async def _perform_subscription_purchase( - db: AsyncSession, - user: User, - calculation: SubscriptionPurchaseCalculation, - selection: Dict[str, Any], - texts, -) -> None: - language_code = _normalize_language_code(getattr(texts, "language", None)) - - if calculation.final_price > calculation.balance_kopeks: - message = _localize( - language_code, - "Недостаточно средств на балансе.", - "Insufficient balance.", - ) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={"code": "insufficient_funds", "message": message}, - ) - - description = _localize( - language_code, - f"Покупка подписки на {calculation.period_days} дней", - f"Subscription purchase for {calculation.period_days} days", - ) - - consume_promo = calculation.promo_discount_value > 0 - success = await subtract_user_balance( - db, - user, - calculation.final_price, - description, - consume_promo_offer=consume_promo, - ) - - if not success: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={"code": "balance_charge_failed", "message": "Failed to charge user balance"}, - ) - - await db.refresh(user) - - subscription = getattr(user, "subscription", None) - now = datetime.utcnow() - bonus_period = timedelta() - was_trial_conversion = False - trial_duration_days = 0 - - if subscription: - if subscription.is_trial: - was_trial_conversion = True - start_date = subscription.start_date or now - trial_duration_days = max(0, (now - start_date).days) - if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and subscription.end_date: - remaining = subscription.end_date - now - if remaining.total_seconds() > 0: - bonus_period = remaining - - subscription.is_trial = False - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.traffic_limit_gb = calculation.traffic_gb - subscription.device_limit = calculation.devices - subscription.connected_squads = list(calculation.servers_selected) - subscription.start_date = now - subscription.end_date = now + timedelta(days=calculation.period_days) + bonus_period - subscription.updated_at = now - subscription.traffic_used_gb = 0.0 - - await db.commit() - await db.refresh(subscription) - else: - subscription = await create_paid_subscription( - db=db, - user_id=user.id, - duration_days=calculation.period_days, - traffic_limit_gb=calculation.traffic_gb, - device_limit=calculation.devices, - connected_squads=list(calculation.servers_selected), - ) - - await mark_user_as_had_paid_subscription(db, user) - await db.refresh(user) - - server_ids = [] - if calculation.servers_selected: - server_ids = await get_server_ids_by_uuids(db, calculation.servers_selected) - if server_ids: - await add_subscription_servers( - db, - subscription, - server_ids, - calculation.server_prices_for_period, - ) - await add_user_to_servers(db, server_ids) - - subscription_service = SubscriptionService() - reset_reason = _localize(language_code, "покупка подписки", "subscription purchase") - - if user.remnawave_uuid: - remnawave_user = await subscription_service.update_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason=reset_reason, - ) - else: - remnawave_user = await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason=reset_reason, - ) - - if not remnawave_user: - remnawave_user = await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason=reset_reason, - ) - - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=calculation.final_price, - description=f"Подписка на {calculation.period_days} дней ({calculation.months} мес)", - ) - - if was_trial_conversion: - try: - await create_subscription_conversion( - db=db, - user_id=user.id, - trial_duration_days=trial_duration_days, - payment_method="balance", - first_payment_amount_kopeks=calculation.final_price, - first_paid_period_days=calculation.period_days, - ) - except Exception as error: # pragma: no cover - safety logging - logger.error("Failed to record trial conversion: %s", error) - - await db.refresh(user) - await db.refresh(subscription) - - -@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) - _ensure_purchase_eligibility(user) - - subscription = getattr(user, "subscription", None) - if payload.subscription_id and subscription: - _validate_subscription_id(payload.subscription_id, subscription) - - options = await _build_purchase_options_payload(db, user) - return MiniAppSubscriptionPurchaseOptionsResponse(data=options) - - -@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) - _ensure_purchase_eligibility(user) - - subscription = getattr(user, "subscription", None) - if payload.subscription_id and subscription: - _validate_subscription_id(payload.subscription_id, subscription) - - texts, periods, traffic, servers, devices, base_selection = await _prepare_purchase_context(db, user) - selection = _build_selection_from_request(payload, base_selection) - calculation = await _calculate_purchase_summary( - db, - user, - texts, - periods, - traffic, - servers, - devices, - selection, - ) - preview = _build_preview_from_calculation(calculation, texts) - return MiniAppSubscriptionPurchasePreviewResponse(preview=preview) - - -@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) - _ensure_purchase_eligibility(user) - - subscription = getattr(user, "subscription", None) - if payload.subscription_id and subscription: - _validate_subscription_id(payload.subscription_id, subscription) - - texts, periods, traffic, servers, devices, base_selection = await _prepare_purchase_context(db, user) - selection = _build_selection_from_request(payload, base_selection) - calculation = await _calculate_purchase_summary( - db, - user, - texts, - periods, - traffic, - servers, - devices, - selection, - ) - - await _perform_subscription_purchase(db, user, calculation, selection, texts) - - await db.refresh(user) - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - balance_label = settings.format_price(balance_kopeks) - - language_code = _normalize_language_code(getattr(texts, "language", None)) - success_message = texts.t("subscription_purchase.submit.success", "") - if not success_message or success_message == "subscription_purchase.submit.success": - success_message = _localize( - language_code, - "Подписка успешно оформлена!", - "Subscription purchased successfully!", - ) - - return MiniAppSubscriptionPurchaseSubmitResponse( - success=True, - message=success_message, - balance_kopeks=balance_kopeks, - balance_label=balance_label, - ) def _ensure_paid_subscription(user: User) -> Subscription: subscription = getattr(user, "subscription", None) if not subscription: diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 640854c2..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,11 +371,8 @@ 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 is_current: bool = False is_available: bool = True - is_default: bool = False description: Optional[str] = None @@ -387,10 +381,6 @@ class MiniAppSubscriptionDeviceOption(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 - included: Optional[int] = None - is_default: bool = False class MiniAppSubscriptionCurrentSettings(BaseModel): @@ -421,8 +411,6 @@ class MiniAppSubscriptionDevicesSettings(BaseModel): max: int = 0 step: int = 1 current: int = 0 - default: Optional[int] = None - included: Optional[int] = None price_kopeks: Optional[int] = None price_label: Optional[str] = None @@ -536,188 +524,3 @@ class MiniAppSubscriptionUpdateResponse(BaseModel): success: bool = True message: Optional[str] = None - -def _merge_purchase_selection(values: Any) -> Any: - if not isinstance(values, dict): - return values - - alias_map = { - "periodId": "period_id", - "periodDays": "period_days", - "periodMonths": "period_months", - "trafficValue": "traffic_value", - "trafficGb": "traffic_gb", - "serverUuids": "server_uuids", - "squadUuids": "squad_uuids", - "deviceLimit": "device_limit", - } - - selection = values.get("selection") - if isinstance(selection, dict): - for alias, target in alias_map.items(): - if alias in selection and target not in values: - values[target] = selection[alias] - for key in ("servers", "server_uuids", "squads", "squad_uuids"): - if key in selection and key not in values: - values[key] = selection[key] - if "devices" in selection and "devices" not in values: - values["devices"] = selection["devices"] - if "traffic" in selection and "traffic" not in values: - values["traffic"] = selection["traffic"] - - for alias, target in alias_map.items(): - if alias in values and target not in values: - values[target] = values[alias] - - return values - - -class MiniAppSubscriptionPurchasePeriod(BaseModel): - id: Optional[str] = None - code: Optional[str] = None - period_days: Optional[int] = Field(None, alias="periodDays") - period_months: Optional[int] = Field(None, alias="periodMonths") - months: Optional[int] = None - label: Optional[str] = None - description: Optional[str] = None - note: 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 - per_month_price_kopeks: Optional[int] = None - per_month_price_label: Optional[str] = None - discount_percent: Optional[int] = None - is_available: bool = True - promo_badges: List[str] = Field(default_factory=list) - - -class MiniAppSubscriptionPurchaseTrafficConfig(BaseModel): - selectable: bool = True - mode: str = "selectable" - options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list) - default: Optional[int] = None - current: Optional[int] = None - hint: Optional[str] = None - - -class MiniAppSubscriptionPurchaseServersConfig(BaseModel): - selectable: bool = True - min: int = 0 - max: int = 0 - options: List[MiniAppSubscriptionServerOption] = Field(default_factory=list) - default: List[str] = Field(default_factory=list) - selected: List[str] = Field(default_factory=list) - hint: Optional[str] = None - - -class MiniAppSubscriptionPurchaseDevicesConfig(BaseModel): - min: int = 1 - max: Optional[int] = None - step: int = 1 - default: Optional[int] = None - current: Optional[int] = None - included: Optional[int] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None - original_price_kopeks: Optional[int] = None - original_price_label: Optional[str] = None - hint: Optional[str] = None - - -class MiniAppSubscriptionPurchaseOptions(BaseModel): - currency: str = "RUB" - balance_kopeks: Optional[int] = None - balance_label: Optional[str] = None - subscription_id: Optional[int] = Field(None, alias="subscriptionId") - periods: List[MiniAppSubscriptionPurchasePeriod] = Field(default_factory=list) - traffic: MiniAppSubscriptionPurchaseTrafficConfig = Field(default_factory=MiniAppSubscriptionPurchaseTrafficConfig) - servers: MiniAppSubscriptionPurchaseServersConfig = Field(default_factory=MiniAppSubscriptionPurchaseServersConfig) - devices: MiniAppSubscriptionPurchaseDevicesConfig = Field(default_factory=MiniAppSubscriptionPurchaseDevicesConfig) - selection: Dict[str, Any] = Field(default_factory=dict) - promo: Optional[Dict[str, Any]] = None - summary: Optional[Dict[str, Any]] = None - - -class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(None, alias="subscriptionId") - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _normalize(cls, values: Any) -> Any: - return _merge_purchase_selection(values) - - -class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel): - success: bool = True - data: MiniAppSubscriptionPurchaseOptions - - -class MiniAppSubscriptionPurchasePreviewRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(None, alias="subscriptionId") - selection: Optional[Dict[str, Any]] = None - period_id: Optional[str] = Field(None, alias="periodId") - period_days: Optional[int] = Field(None, alias="periodDays") - period_months: Optional[int] = Field(None, alias="periodMonths") - traffic_value: Optional[int] = Field(None, alias="trafficValue") - traffic_gb: Optional[int] = Field(None, alias="trafficGb") - traffic: Optional[int] = None - servers: Optional[List[str]] = None - server_uuids: Optional[List[str]] = Field(None, alias="serverUuids") - squads: Optional[List[str]] = None - squad_uuids: Optional[List[str]] = Field(None, alias="squadUuids") - devices: Optional[int] = None - device_limit: Optional[int] = Field(None, alias="deviceLimit") - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _normalize(cls, values: Any) -> Any: - return _merge_purchase_selection(values) - - -class MiniAppSubscriptionPurchasePreviewItem(BaseModel): - label: str - value: str - highlight: bool = False - - -class MiniAppSubscriptionPurchasePreview(BaseModel): - total_price_kopeks: Optional[int] = Field(None, alias="totalPriceKopeks") - total_price_label: Optional[str] = Field(None, alias="totalPriceLabel") - original_price_kopeks: Optional[int] = Field(None, alias="originalPriceKopeks") - original_price_label: Optional[str] = Field(None, alias="originalPriceLabel") - per_month_price_kopeks: Optional[int] = Field(None, alias="perMonthPriceKopeks") - per_month_price_label: Optional[str] = Field(None, alias="perMonthPriceLabel") - discount_percent: Optional[int] = None - discount_label: Optional[str] = None - discount_lines: List[str] = Field(default_factory=list) - breakdown: List[MiniAppSubscriptionPurchasePreviewItem] = Field(default_factory=list) - balance_kopeks: Optional[int] = None - balance_label: Optional[str] = None - missing_amount_kopeks: Optional[int] = None - missing_amount_label: Optional[str] = None - can_purchase: bool = True - status_message: Optional[str] = None - - -class MiniAppSubscriptionPurchasePreviewResponse(BaseModel): - success: bool = True - preview: MiniAppSubscriptionPurchasePreview - - -class MiniAppSubscriptionPurchaseSubmitRequest(MiniAppSubscriptionPurchasePreviewRequest): - pass - - -class MiniAppSubscriptionPurchaseSubmitResponse(BaseModel): - success: bool = True - message: Optional[str] = None - balance_kopeks: Optional[int] = None - balance_label: Optional[str] = None - diff --git a/miniapp/index.html b/miniapp/index.html index dfbc9b0a..a19febf0 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -4347,9 +4347,6 @@ 'subscription_purchase.servers.empty': 'No servers available', 'subscription_purchase.servers.single': 'Included server: {name}', 'subscription_purchase.servers.limit': 'Select up to {count}', - 'subscription_purchase.servers.range': 'Select {min}–{max} servers', - 'subscription_purchase.servers.minimum': 'Select at least {count} servers', - 'subscription_purchase.servers.exact': 'Select {count} servers', 'subscription_purchase.servers.selected': 'Selected: {count}', 'subscription_purchase.devices.title': 'Devices', 'subscription_purchase.devices.subtitle': 'Simultaneous connections.', @@ -4676,9 +4673,6 @@ 'subscription_purchase.servers.empty': 'Нет доступных серверов', 'subscription_purchase.servers.single': 'Включён сервер: {name}', 'subscription_purchase.servers.limit': 'Можно выбрать до {count}', - 'subscription_purchase.servers.range': 'Выберите от {min} до {max} серверов', - 'subscription_purchase.servers.minimum': 'Нужно выбрать минимум {count} сервер(ов)', - 'subscription_purchase.servers.exact': 'Нужно выбрать {count} сервер(ов)', 'subscription_purchase.servers.selected': 'Выбрано: {count}', 'subscription_purchase.devices.title': 'Устройства', 'subscription_purchase.devices.subtitle': 'Одновременные подключения.', @@ -5804,17 +5798,14 @@ setLanguage(event.target.value, { persist: true }); }); - function createError(title, message, status, code) { + function createError(title, message, status) { const error = new Error(message || title); if (title) { error.title = title; } - if (status !== undefined && status !== null) { + if (status) { error.status = status; } - if (code !== undefined && code !== null) { - error.code = code; - } return error; } @@ -11079,21 +11070,6 @@ return formatTrafficLimit(numeric); } - function formatPurchaseServersLimit(key, replacements, fallback = '') { - let template = t(key); - if (!template || template === key) { - template = fallback || ''; - } - if (!template) { - return ''; - } - let formatted = template; - Object.entries(replacements || {}).forEach(([token, value]) => { - formatted = formatted.split(`{${token}}`).join(String(value)); - }); - return formatted; - } - function normalizePurchaseServerOption(option, currency) { if (!option) { return null; @@ -11423,40 +11399,22 @@ } } - function extractPurchaseError(payload, status) { - const defaultMessage = t('subscription_purchase.error.default'); + function extractPurchaseErrorMessage(payload, status) { if (!payload || typeof payload !== 'object') { - const message = status === 401 + return status === 401 ? t('subscription_settings.error.unauthorized') - : defaultMessage; - return { message, code: null }; + : t('subscription_purchase.error.default'); } - - let code = null; - let message = defaultMessage; - if (typeof payload.detail === 'string') { - message = payload.detail; - } else if (payload.detail && typeof payload.detail === 'object') { - if (typeof payload.detail.message === 'string') { - message = payload.detail.message; - } - if (payload.detail.code !== undefined && payload.detail.code !== null) { - code = payload.detail.code; - } - } else if (typeof payload.message === 'string') { - message = payload.message; + return payload.detail; } - - if ((message == null || String(message).trim() === '') && typeof payload.error === 'string') { - message = payload.error; + if (payload.detail && typeof payload.detail === 'object' && typeof payload.detail.message === 'string') { + return payload.detail.message; } - - if (code == null && typeof payload.code !== 'undefined') { - code = payload.code; + if (typeof payload.message === 'string') { + return payload.message; } - - return { message: message || defaultMessage, code }; + return t('subscription_purchase.error.default'); } function ensureSubscriptionPurchaseData(options = {}) { @@ -11492,8 +11450,8 @@ }).then(async response => { const body = await parseJsonSafe(response); if (!response.ok || (body && body.success === false)) { - const { message, code } = extractPurchaseError(body, response.status); - throw createError('Subscription purchase error', message, response.status, code); + const message = extractPurchaseErrorMessage(body, response.status); + throw createError('Subscription purchase error', message, response.status); } const normalized = normalizeSubscriptionPurchasePayload(body); @@ -11546,18 +11504,7 @@ selection.code = idString; } - let periodDays = resolvePurchasePeriodDays(period); - if (periodDays === null) { - const parsedDays = coercePositiveInt( - period?.period_days - ?? period?.periodDays - ?? periodId, - null, - ); - if (parsedDays !== null) { - periodDays = parsedDays; - } - } + const periodDays = resolvePurchasePeriodDays(period); if (periodDays !== null) { selection.period_days = periodDays; selection.periodDays = periodDays; @@ -11565,26 +11512,21 @@ selection.durationDays = periodDays; } - let periodMonths = coercePositiveInt( + const periodMonths = coercePositiveInt( period?.months ?? period?.period ?? period?.period_months ?? period?.periodMonths, null, ); - if (periodMonths === null && periodDays !== null) { - periodMonths = Math.max(1, Math.round(periodDays / 30)); - } if (periodMonths !== null) { selection.months = periodMonths; selection.period_months = periodMonths; selection.periodMonths = periodMonths; } - const trafficRaw = subscriptionPurchaseSelections.trafficValue; - if (trafficRaw !== null && trafficRaw !== undefined) { - const numericTraffic = coercePositiveInt(trafficRaw, null); - const trafficValue = numericTraffic !== null ? numericTraffic : trafficRaw; + const trafficValue = subscriptionPurchaseSelections.trafficValue; + if (trafficValue !== null && trafficValue !== undefined) { selection.traffic_value = trafficValue; selection.traffic = trafficValue; selection.traffic_gb = trafficValue; @@ -12250,30 +12192,10 @@ if (metaElement) { let metaText = ''; - if (maxSelectable && minSelectable && maxSelectable === minSelectable) { - metaText = formatPurchaseServersLimit( - 'subscription_purchase.servers.exact', - { count: String(maxSelectable) }, - `Select ${maxSelectable} servers` - ); - } else if (maxSelectable && minSelectable && maxSelectable > minSelectable) { - metaText = formatPurchaseServersLimit( - 'subscription_purchase.servers.range', - { min: String(minSelectable), max: String(maxSelectable) }, - `Select ${minSelectable}–${maxSelectable} servers` - ); - } else if (maxSelectable) { - metaText = formatPurchaseServersLimit( - 'subscription_purchase.servers.limit', - { count: String(maxSelectable) }, - `Select up to ${maxSelectable}` - ); + if (maxSelectable && maxSelectable !== minSelectable) { + metaText = t('subscription_purchase.servers.limit').replace('{count}', String(maxSelectable)); } else if (selectedCount) { - metaText = formatPurchaseServersLimit( - 'subscription_purchase.servers.selected', - { count: String(selectedCount) }, - `Selected: ${selectedCount}` - ); + metaText = t('subscription_purchase.servers.selected').replace('{count}', String(selectedCount)); } metaElement.textContent = metaText; } @@ -12283,15 +12205,8 @@ if (config?.hint) { hintParts.push(config.hint); } - if (minSelectable > 0) { - const minimumHint = formatPurchaseServersLimit( - 'subscription_purchase.servers.minimum', - { count: String(minSelectable) }, - `Select at least ${minSelectable}` - ); - if (minimumHint && !hintParts.includes(minimumHint)) { - hintParts.push(minimumHint); - } + if (!config?.hint && minSelectable > 0) { + hintParts.push(t('subscription_purchase.servers.limit').replace('{count}', String(minSelectable))); } const hintText = hintParts.join(' '); hintElement.textContent = hintText; @@ -12550,8 +12465,8 @@ }).then(async response => { const body = await parseJsonSafe(response); if (!response.ok || (body && body.success === false)) { - const { message, code } = extractPurchaseError(body, response.status); - throw createError('Subscription purchase preview error', message, response.status, code); + const message = extractPurchaseErrorMessage(body, response.status); + throw createError('Subscription purchase preview error', message, response.status); } const normalized = normalizeSubscriptionPurchasePreview(body); subscriptionPurchasePreview = normalized; @@ -12561,29 +12476,10 @@ renderSubscriptionPurchaseCard(); return normalized; }).catch(error => { - subscriptionPurchasePreviewLoading = false; - subscriptionPurchasePreviewPromise = null; - - const currentPeriodId = subscriptionPurchaseSelections.periodId; - const fallbackPeriod = Array.isArray(subscriptionPurchaseData?.periods) - ? subscriptionPurchaseData.periods.find(Boolean) - : null; - const fallbackId = resolvePurchasePeriodId(fallbackPeriod); - - if (error?.code === 'invalid_period' && fallbackId && String(fallbackId) !== String(currentPeriodId)) { - console.warn('Preview returned invalid period. Resetting selection to default.', error); - subscriptionPurchaseSelections.periodId = String(fallbackId); - if (fallbackPeriod) { - ensureSubscriptionPurchaseSelectionsValidForPeriod(fallbackPeriod); - } - subscriptionPurchasePreview = null; - subscriptionPurchasePreviewError = null; - renderSubscriptionPurchaseCard(); - return updateSubscriptionPurchasePreview({ immediate: true, force: true }); - } - subscriptionPurchasePreview = null; subscriptionPurchasePreviewError = error; + subscriptionPurchasePreviewLoading = false; + subscriptionPurchasePreviewPromise = null; console.warn('Failed to fetch subscription purchase preview:', error); renderSubscriptionPurchaseCard(); throw error; @@ -12881,8 +12777,8 @@ }); const body = await parseJsonSafe(response); if (!response.ok || (body && body.success === false)) { - const { message, code } = extractPurchaseError(body, response.status); - throw createError('Subscription purchase error', message, response.status, code); + const message = extractPurchaseErrorMessage(body, response.status); + throw createError('Subscription purchase error', message, response.status); } const successMessage = body?.message