From da1ed55fd7d173c7b5e163733c059d16bebdbb0f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 10:22:50 +0300 Subject: [PATCH] Add subscription purchase flow for mini app --- app/webapi/routes/miniapp.py | 1184 ++++++++++++++++++++++++++++++++- app/webapi/schemas/miniapp.py | 202 ++++++ 2 files changed, 1385 insertions(+), 1 deletion(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 2fd18b30..655da597 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3,6 +3,7 @@ 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 @@ -27,10 +28,16 @@ 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 +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.transaction import ( create_transaction, get_user_total_spent_kopeks, @@ -41,11 +48,14 @@ 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 @@ -67,12 +77,15 @@ 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 ( @@ -126,6 +139,19 @@ from ..schemas.miniapp import ( MiniAppSubscriptionTrafficUpdateRequest, MiniAppSubscriptionDevicesUpdateRequest, MiniAppSubscriptionUpdateResponse, + MiniAppSubscriptionPurchaseOptionsRequest, + MiniAppSubscriptionPurchaseOptionsResponse, + MiniAppSubscriptionPurchaseOptions, + MiniAppSubscriptionPurchasePeriod, + MiniAppSubscriptionPurchaseTrafficConfig, + MiniAppSubscriptionPurchaseServersConfig, + MiniAppSubscriptionPurchaseDevicesConfig, + MiniAppSubscriptionPurchasePreviewRequest, + MiniAppSubscriptionPurchasePreviewResponse, + MiniAppSubscriptionPurchasePreview, + MiniAppSubscriptionPurchaseSubmitRequest, + MiniAppSubscriptionPurchaseSubmitResponse, + MiniAppPurchaseBreakdownItem, ) @@ -1828,6 +1854,734 @@ 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]: @@ -2321,6 +3075,434 @@ 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 f461f372..cbe521e4 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -360,10 +360,13 @@ 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): @@ -371,6 +374,9 @@ 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 @@ -524,3 +530,199 @@ 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