From b5c2ad4c07dc62c855fd2c19c795e90ce6ade1cf Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 06:30:38 +0300 Subject: [PATCH] Add miniapp subscription settings management endpoints --- app/webapi/routes/miniapp.py | 1102 ++++++++++++++++++++++++++++++++- app/webapi/schemas/miniapp.py | 108 ++++ 2 files changed, 1206 insertions(+), 4 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 5ec70d63..404509ed 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6,7 +6,7 @@ import math from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR from datetime import datetime, timedelta, timezone from uuid import uuid4 -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Union from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status @@ -24,9 +24,16 @@ from app.database.crud.discount_offer import ( from app.database.crud.promo_group import get_auto_assign_promo_groups from app.database.crud.rules import get_rules_by_language from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.server_squad import get_server_squad_by_uuid -from app.database.crud.transaction import get_user_total_spent_kopeks -from app.database.crud.user import get_user_by_telegram_id +from app.database.crud.server_squad import ( + get_available_server_squads, + get_server_ids_by_uuids, + get_server_squad_by_uuid, +) +from app.database.crud.transaction import ( + create_transaction, + get_user_total_spent_kopeks, +) +from app.database.crud.user import get_user_by_telegram_id, subtract_user_balance from app.database.models import ( PromoGroup, PromoOfferTemplate, @@ -50,6 +57,12 @@ from app.services.promocode_service import PromoCodeService from app.services.subscription_service import SubscriptionService from app.services.tribute_service import TributeService from app.utils.currency_converter import currency_converter +from app.utils.cache import cache, cache_key +from app.utils.pricing_utils import ( + apply_percentage_discount, + calculate_prorated_price, + get_remaining_months, +) from app.utils.subscription_utils import get_happ_cryptolink_redirect_link from app.utils.telegram_webapp import ( TelegramWebAppAuthError, @@ -93,8 +106,14 @@ from ..schemas.miniapp import ( MiniAppReferralStats, MiniAppReferralTerms, MiniAppRichTextDocument, + MiniAppSubscriptionDevicesUpdateRequest, MiniAppSubscriptionRequest, MiniAppSubscriptionResponse, + MiniAppSubscriptionServersUpdateRequest, + MiniAppSubscriptionSettingsRequest, + MiniAppSubscriptionSettingsResponse, + MiniAppSubscriptionSettingsUpdateResponse, + MiniAppSubscriptionTrafficUpdateRequest, MiniAppSubscriptionUser, MiniAppTransaction, ) @@ -391,6 +410,616 @@ def _normalize_amount_kopeks( return normalized if normalized >= 0 else None +def _resolve_language(language: Optional[str]) -> str: + default_language = str(getattr(settings, "DEFAULT_LANGUAGE", "ru") or "ru") + if not language: + return default_language.lower() + return str(language).lower() + + +def _get_period_hint_from_subscription( + subscription: Optional[Subscription], +) -> Optional[int]: + if not subscription or not getattr(subscription, "end_date", None): + return None + + months_remaining = get_remaining_months(subscription.end_date) + if months_remaining <= 0: + return None + + return months_remaining * 30 + + +def _get_addon_discount_percent( + user: Optional[User], + category: str, + period_days_hint: Optional[int] = None, +) -> int: + if user is None: + return 0 + + promo_group = getattr(user, "promo_group", None) + if promo_group is None: + return 0 + + if not getattr(promo_group, "apply_discounts_to_addons", True): + return 0 + + try: + return int(user.get_promo_discount(category, period_days_hint)) + except AttributeError: + return 0 + except (TypeError, ValueError): + return 0 + + +def _format_price_label( + amount_kopeks: int, + discount_total_kopeks: int = 0, + discount_percent: int = 0, +) -> Optional[str]: + if amount_kopeks <= 0: + return None + + label = settings.format_price(amount_kopeks) + if discount_total_kopeks > 0 and discount_percent > 0: + label += f" (-{settings.format_price(discount_total_kopeks)})" + return label + + +def _format_traffic_option_label( + language: str, + current_limit: int, + *, + additional: Optional[int] = None, + target: Optional[int] = None, + unlimited: bool = False, +) -> str: + is_ru = language.startswith("ru") + + if unlimited: + return "♾️ Безлимитный трафик" if is_ru else "♾️ Unlimited traffic" + + if additional and additional > 0: + if is_ru: + base = f"+{additional} ГБ" + if target is not None and target > 0: + base += f" (до {target} ГБ)" + else: + base = f"+{additional} GB" + if target is not None and target > 0: + base += f" (up to {target} GB)" + return base + + if current_limit <= 0: + return "♾️ Безлимитный трафик" if is_ru else "♾️ Unlimited traffic" + + return f"{current_limit} ГБ" if is_ru else f"{current_limit} GB" + + +def _format_devices_label(language: str, value: int) -> str: + is_ru = language.startswith("ru") + if is_ru: + if value == 1: + return "1 устройство" + if 2 <= value <= 4: + return f"{value} устройства" + return f"{value} устройств" + return f"{value} device" if value == 1 else f"{value} devices" + + +async def _load_available_servers( + db: AsyncSession, + promo_group_id: Optional[int], +) -> List[Dict[str, Any]]: + cache_key_value = cache_key( + "miniapp", + "available_servers", + str(promo_group_id or "all"), + ) + cached = await cache.get(cache_key_value) + if isinstance(cached, list): + return cached + + entries: List[Dict[str, Any]] = [] + + servers = await get_available_server_squads(db, promo_group_id=promo_group_id) + for server in servers: + name = server.display_name or server.original_name or server.squad_uuid + entries.append( + { + "uuid": server.squad_uuid, + "name": name, + "price_kopeks": int(server.price_kopeks or 0), + "is_available": bool(server.is_available and not server.is_full), + "is_full": bool(server.is_full), + } + ) + + if not entries: + service = RemnaWaveService() + squads: List[Dict[str, Any]] = [] + if service.is_configured: + try: + squads = await service.get_all_squads() + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load squads from RemnaWave: %s", error) + + for squad in squads or []: + uuid = str(squad.get("uuid") or squad.get("short_uuid") or "").strip() + if not uuid: + continue + name = str( + squad.get("name") + or squad.get("display_name") + or squad.get("original_name") + or uuid + ) + if not any(flag in name for flag in [ + "🇳🇱", + "🇩🇪", + "🇺🇸", + "🇫🇷", + "🇬🇧", + "🇮🇹", + "🇪🇸", + "🇨🇦", + "🇯🇵", + "🇸🇬", + "🇦🇺", + ]): + name_lower = name.lower() + if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: + name = f"🇳🇱 {name}" + elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower: + name = f"🇩🇪 {name}" + elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower: + name = f"🇺🇸 {name}" + else: + name = f"🌐 {name}" + + entries.append( + { + "uuid": uuid, + "name": name, + "price_kopeks": int(squad.get("price") or 0), + "is_available": True, + "is_full": False, + } + ) + + if not entries: + entries.append( + { + "uuid": "default-free", + "name": "🆓 Бесплатный сервер", + "price_kopeks": 0, + "is_available": True, + "is_full": False, + } + ) + + await cache.set(cache_key_value, entries, 300) + return entries + + +def _compute_server_pricing( + subscription: Subscription, + entry: Dict[str, Any], + discount_percent: int, +) -> Dict[str, int]: + base_price = int(entry.get("price_kopeks") or 0) + if base_price <= 0 or not getattr(subscription, "end_date", None): + charged_months = get_remaining_months(subscription.end_date) if getattr(subscription, "end_date", None) else 1 + return { + "price": 0, + "discount_percent": 0, + "discount_total": 0, + "charged_months": max(1, charged_months), + } + + discounted_per_month, discount_per_month = apply_percentage_discount( + base_price, + discount_percent, + ) + total_price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months + + return { + "price": total_price, + "discount_percent": discount_percent if total_discount > 0 else 0, + "discount_total": total_discount, + "charged_months": charged_months, + } + + +def _compute_traffic_pricing( + subscription: Subscription, + user: User, + target_limit: int, +) -> Dict[str, int]: + current_limit = int(getattr(subscription, "traffic_limit_gb", 0) or 0) + if target_limit < 0: + target_limit = 0 + + period_hint = _get_period_hint_from_subscription(subscription) + discount_percent = _get_addon_discount_percent(user, "traffic", period_hint) + + if target_limit == current_limit: + charged_months = get_remaining_months(subscription.end_date) if getattr(subscription, "end_date", None) else 1 + return { + "price": 0, + "discount_percent": 0, + "discount_total": 0, + "charged_months": max(1, charged_months), + "additional_gb": 0, + "set_unlimited": target_limit == 0, + } + + if target_limit == 0: + base_price = settings.get_traffic_price(0) + discounted_per_month, discount_per_month = apply_percentage_discount( + base_price, + discount_percent, + ) + total_price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months + return { + "price": total_price, + "discount_percent": discount_percent if total_discount > 0 else 0, + "discount_total": total_discount, + "charged_months": charged_months, + "additional_gb": 0, + "set_unlimited": True, + } + + if current_limit == 0 or target_limit < current_limit: + charged_months = get_remaining_months(subscription.end_date) if getattr(subscription, "end_date", None) else 1 + return { + "price": 0, + "discount_percent": 0, + "discount_total": 0, + "charged_months": max(1, charged_months), + "additional_gb": 0, + "set_unlimited": False, + } + + additional = max(0, target_limit - current_limit) + base_price = settings.get_traffic_price(additional) + discounted_per_month, discount_per_month = apply_percentage_discount( + base_price, + discount_percent, + ) + total_price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months + + return { + "price": total_price, + "discount_percent": discount_percent if total_discount > 0 else 0, + "discount_total": total_discount, + "charged_months": charged_months, + "additional_gb": additional, + "set_unlimited": False, + } + + +def _compute_devices_pricing( + subscription: Subscription, + user: User, + target_devices: int, +) -> Dict[str, int]: + if target_devices < 0: + target_devices = 0 + + current_devices = int(getattr(subscription, "device_limit", 0) or 0) + period_hint = _get_period_hint_from_subscription(subscription) + discount_percent = _get_addon_discount_percent(user, "devices", period_hint) + + additional = max(0, target_devices - current_devices) + current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) + new_chargeable = max(0, target_devices - settings.DEFAULT_DEVICE_LIMIT) + chargeable_devices = max(0, new_chargeable - current_chargeable) + price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE + + if price_per_month <= 0 or not getattr(subscription, "end_date", None): + charged_months = get_remaining_months(subscription.end_date) if getattr(subscription, "end_date", None) else 1 + return { + "price": 0, + "discount_percent": 0, + "discount_total": 0, + "charged_months": max(1, charged_months), + "chargeable_devices": chargeable_devices, + } + + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price, charged_months = calculate_prorated_price( + discounted_per_month, + subscription.end_date, + ) + total_discount = discount_per_month * charged_months + + return { + "price": total_price, + "discount_percent": discount_percent if total_discount > 0 else 0, + "discount_total": total_discount, + "charged_months": charged_months, + "chargeable_devices": chargeable_devices, + } + + +async def _build_servers_settings( + db: AsyncSession, + user: User, + subscription: Subscription, + language: str, + current_servers: List[MiniAppConnectedServer], +) -> MiniAppSubscriptionSettingsServers: + promo_group_id = getattr(user, "promo_group_id", None) + available_entries = await _load_available_servers(db, promo_group_id) + current_set: Set[str] = set(subscription.connected_squads or []) + + period_hint = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent(user, "servers", period_hint) + + options: List[MiniAppSubscriptionSettingsServer] = [] + seen: Set[str] = set() + + for entry in available_entries: + uuid = str(entry.get("uuid") or "").strip() + if not uuid or uuid in seen: + continue + seen.add(uuid) + pricing = _compute_server_pricing(subscription, entry, servers_discount_percent) + price_label = _format_price_label( + pricing["price"], + pricing["discount_total"], + pricing["discount_percent"], + ) + options.append( + MiniAppSubscriptionSettingsServer( + uuid=uuid, + name=str(entry.get("name") or uuid), + price_kopeks=pricing["price"], + price_label=price_label, + discount_percent=pricing["discount_percent"], + is_connected=uuid in current_set, + is_available=bool(entry.get("is_available", True)), + disabled_reason=None, + ) + ) + + known_uuids = {option.uuid for option in options} + for server in current_servers: + if server.uuid not in known_uuids: + options.append( + MiniAppSubscriptionSettingsServer( + uuid=server.uuid, + name=server.name, + price_kopeks=0, + price_label=None, + discount_percent=0, + is_connected=True, + is_available=False, + disabled_reason=None, + ) + ) + + return MiniAppSubscriptionSettingsServers( + available=options, + min=1 if options else 0, + max=0, + can_update=True, + hint=None, + ) + + +def _build_traffic_settings( + user: User, + subscription: Subscription, + language: str, +) -> MiniAppSubscriptionSettingsTraffic: + current_limit = int(getattr(subscription, "traffic_limit_gb", 0) or 0) + current_value = 0 if current_limit == 0 else current_limit + options: List[MiniAppSubscriptionSettingsTrafficOption] = [] + seen_values: Set[int] = set() + + base_label = _format_traffic_option_label(language, current_limit) + options.append( + MiniAppSubscriptionSettingsTrafficOption( + value=current_value, + label=base_label, + price_kopeks=0, + price_label=None, + discount_percent=0, + is_current=True, + is_available=True, + description=None, + ) + ) + seen_values.add(current_value) + + packages = settings.get_traffic_packages() + for package in packages: + if not package.get("enabled", True): + continue + try: + gb = int(package.get("gb")) + except (TypeError, ValueError): + continue + + if gb < 0: + continue + + if gb == 0 and 0 in seen_values: + continue + + if gb == 0: + target_limit = 0 + unlimited = True + additional = None + else: + unlimited = False + if current_limit == 0: + target_limit = gb + else: + target_limit = current_limit + gb + additional = gb if current_limit > 0 else None + + if target_limit in seen_values: + continue + + pricing = _compute_traffic_pricing(subscription, user, target_limit) + price_label = _format_price_label( + pricing["price"], + pricing["discount_total"], + pricing["discount_percent"], + ) + options.append( + MiniAppSubscriptionSettingsTrafficOption( + value=target_limit, + label=_format_traffic_option_label( + language, + current_limit, + additional=gb if gb > 0 else additional, + target=None if unlimited else target_limit, + unlimited=unlimited, + ), + price_kopeks=pricing["price"], + price_label=price_label, + discount_percent=pricing["discount_percent"], + is_current=(target_limit == current_value), + is_available=True, + description=None, + ) + ) + seen_values.add(target_limit) + + return MiniAppSubscriptionSettingsTraffic( + options=options, + can_update=not settings.is_traffic_fixed(), + current_value=current_value, + current_label=_format_limit_label(current_limit), + ) + + +def _build_devices_settings( + user: User, + subscription: Subscription, + language: str, +) -> MiniAppSubscriptionSettingsDevices: + current_devices = int(getattr(subscription, "device_limit", 0) or 0) + max_devices_setting = settings.MAX_DEVICES_LIMIT or 0 + if max_devices_setting > 0: + max_devices = max(max_devices_setting, current_devices) + else: + baseline = max(current_devices, settings.DEFAULT_DEVICE_LIMIT) + max_devices = baseline + 5 + + min_devices = 1 + if current_devices > 0 and current_devices < min_devices: + min_devices = current_devices + + options: List[MiniAppSubscriptionSettingsDeviceOption] = [] + seen_values: Set[int] = set() + + for value in range(min_devices, max_devices + 1): + pricing = _compute_devices_pricing(subscription, user, value) + price_label = _format_price_label( + pricing["price"], + pricing["discount_total"], + pricing["discount_percent"], + ) + options.append( + MiniAppSubscriptionSettingsDeviceOption( + value=value, + label=_format_devices_label(language, value), + price_kopeks=pricing["price"], + price_label=price_label, + ) + ) + seen_values.add(value) + + if current_devices not in seen_values: + options.append( + MiniAppSubscriptionSettingsDeviceOption( + value=current_devices, + label=_format_devices_label(language, current_devices), + price_kopeks=0, + price_label=None, + ) + ) + + options.sort(key=lambda option: option.value) + + return MiniAppSubscriptionSettingsDevices( + options=options, + can_update=True, + min=min_devices, + max=max_devices, + step=1, + current=current_devices, + ) + + +async def _build_subscription_settings_payload( + db: AsyncSession, + user: User, + subscription: Subscription, +) -> MiniAppSubscriptionSettings: + language = _resolve_language(getattr(user, "language", None)) + connected_squads = list(subscription.connected_squads or []) + connected_servers = await _resolve_connected_servers(db, connected_squads) + + current_servers = [ + MiniAppConnectedServer(uuid=server.uuid, name=server.name) + for server in connected_servers + ] + + current_payload = MiniAppSubscriptionSettingsCurrent( + servers=current_servers, + traffic_limit_gb=int(getattr(subscription, "traffic_limit_gb", 0) or 0), + traffic_limit_label=_format_limit_label(subscription.traffic_limit_gb), + device_limit=int(getattr(subscription, "device_limit", 0) or 0), + ) + + servers_payload = await _build_servers_settings( + db, + user, + subscription, + language, + current_servers, + ) + traffic_payload = _build_traffic_settings(user, subscription, language) + devices_payload = _build_devices_settings(user, subscription, language) + + currency = getattr(user, "balance_currency", None) + if isinstance(currency, str) and currency: + currency = currency.upper() + else: + currency = "RUB" + + return MiniAppSubscriptionSettings( + subscription_id=subscription.id, + currency=currency, + current=current_payload, + servers=servers_payload, + traffic=traffic_payload, + devices=devices_payload, + ) + + @router.post( "/payments/methods", response_model=MiniAppPaymentMethodsResponse, @@ -2292,6 +2921,471 @@ async def get_subscription_details( ) +@router.post( + "/subscription/settings", + response_model=MiniAppSubscriptionSettingsResponse, +) +async def get_subscription_settings_details( + payload: MiniAppSubscriptionSettingsRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionSettingsResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + + subscription = getattr(user, "subscription", None) + if not subscription or subscription.is_trial: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "code": "subscription_required", + "message": "Active paid subscription required", + }, + ) + + if payload.subscription_id and payload.subscription_id != subscription.id: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={ + "code": "subscription_not_found", + "message": "Subscription not found", + }, + ) + + settings_payload = await _build_subscription_settings_payload(db, user, subscription) + return MiniAppSubscriptionSettingsResponse(settings=settings_payload) + + +@router.post( + "/subscription/servers", + response_model=MiniAppSubscriptionSettingsUpdateResponse, +) +async def update_subscription_servers( + payload: MiniAppSubscriptionServersUpdateRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionSettingsUpdateResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + subscription = getattr(user, "subscription", None) + if not subscription or subscription.is_trial: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "code": "subscription_required", + "message": "Active paid subscription required", + }, + ) + + if payload.subscription_id and payload.subscription_id != subscription.id: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={ + "code": "subscription_not_found", + "message": "Subscription not found", + }, + ) + + selected_candidates: List[str] = [] + for values in ( + payload.squad_uuids, + payload.server_uuids, + payload.squads, + payload.servers, + ): + if values: + selected_candidates.extend(values) + + selected: List[str] = [] + seen: Set[str] = set() + for item in selected_candidates: + if not item: + continue + value = str(item).strip() + if not value or value in seen: + continue + seen.add(value) + selected.append(value) + + if not selected: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_selection", "message": "No servers selected"}, + ) + + current_set: Set[str] = set(subscription.connected_squads or []) + available_entries = await _load_available_servers(db, getattr(user, "promo_group_id", None)) + available_map: Dict[str, Dict[str, Any]] = { + str(entry.get("uuid") or "").strip(): entry for entry in available_entries + } + + allowed_uuids = {uuid for uuid in available_map.keys() if uuid} + allowed_uuids.update(current_set) + + filtered = [uuid for uuid in selected if uuid in allowed_uuids] + if not filtered: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_selection", "message": "Selected servers are not available"}, + ) + + selected = filtered + + added = [uuid for uuid in selected if uuid not in current_set] + removed = [uuid for uuid in current_set if uuid not in selected] + + if not added and not removed: + return MiniAppSubscriptionSettingsUpdateResponse( + success=True, + charged_amount_kopeks=0, + balance_kopeks=user.balance_kopeks, + ) + + connected_servers = await _resolve_connected_servers(db, list(current_set)) + connected_names = {server.uuid: server.name for server in connected_servers} + + period_hint = _get_period_hint_from_subscription(subscription) + servers_discount_percent = _get_addon_discount_percent( + user, + "servers", + period_hint, + ) + + total_price = 0 + total_discount = 0 + added_names: List[str] = [] + + for uuid in added: + entry = available_map.get(uuid) + if entry is None: + server = await get_server_squad_by_uuid(db, uuid) + if server: + entry = { + "uuid": uuid, + "name": server.display_name or server.squad_uuid, + "price_kopeks": int(server.price_kopeks or 0), + "is_available": bool(server.is_available and not server.is_full), + } + else: + entry = { + "uuid": uuid, + "name": connected_names.get(uuid, uuid), + "price_kopeks": 0, + "is_available": True, + } + + if not entry.get("is_available", True): + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "code": "server_unavailable", + "message": "Selected server is not available", + }, + ) + + pricing = _compute_server_pricing( + subscription, + entry, + servers_discount_percent, + ) + total_price += pricing["price"] + total_discount += pricing["discount_total"] + added_names.append(str(entry.get("name") or uuid)) + + if total_price > 0 and user.balance_kopeks < total_price: + raise HTTPException( + status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": "Insufficient funds on balance", + }, + ) + + description = "Server update" + if added_names: + description = f"Adding servers: {', '.join(added_names)}" + + charged_amount = total_price + + if total_price > 0: + success = await subtract_user_balance( + db, + user, + total_price, + description, + ) + if not success: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "charge_failed", "message": "Failed to charge balance"}, + ) + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=total_price, + description=description, + ) + + subscription.connected_squads = selected + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to sync servers to RemnaWave: %s", error) + + return MiniAppSubscriptionSettingsUpdateResponse( + success=True, + charged_amount_kopeks=charged_amount, + balance_kopeks=user.balance_kopeks, + ) + + +@router.post( + "/subscription/traffic", + response_model=MiniAppSubscriptionSettingsUpdateResponse, +) +async def update_subscription_traffic( + payload: MiniAppSubscriptionTrafficUpdateRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionSettingsUpdateResponse: + if settings.is_traffic_fixed(): + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "code": "traffic_fixed", + "message": "Traffic limit cannot be updated", + }, + ) + + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + subscription = getattr(user, "subscription", None) + if not subscription or subscription.is_trial: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "code": "subscription_required", + "message": "Active paid subscription required", + }, + ) + + if payload.subscription_id and payload.subscription_id != subscription.id: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={ + "code": "subscription_not_found", + "message": "Subscription not found", + }, + ) + + raw_value = ( + payload.traffic + if payload.traffic is not None + else payload.traffic_gb + ) + + if raw_value is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_value", "message": "Traffic value is required"}, + ) + + try: + target_limit = int(raw_value) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_value", "message": "Invalid traffic value"}, + ) from None + + if target_limit < 0: + target_limit = 0 + + pricing = _compute_traffic_pricing(subscription, user, target_limit) + total_price = pricing["price"] + + if total_price > 0 and user.balance_kopeks < total_price: + raise HTTPException( + status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": "Insufficient funds on balance", + }, + ) + + current_limit = int(getattr(subscription, "traffic_limit_gb", 0) or 0) + + if total_price > 0: + target_label = ( + "unlimited" + if target_limit == 0 + else f"{target_limit} GB" + ) + description = f"Updating traffic to {target_label}" + success = await subtract_user_balance( + db, + user, + total_price, + description, + ) + if not success: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "charge_failed", "message": "Failed to charge balance"}, + ) + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=total_price, + description=description, + ) + + if pricing["set_unlimited"]: + subscription.traffic_limit_gb = 0 + elif current_limit == 0 or target_limit <= current_limit: + subscription.traffic_limit_gb = target_limit + else: + subscription.traffic_limit_gb = current_limit + pricing.get("additional_gb", 0) + + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to sync traffic to RemnaWave: %s", error) + + return MiniAppSubscriptionSettingsUpdateResponse( + success=True, + charged_amount_kopeks=total_price, + balance_kopeks=user.balance_kopeks, + ) + + +@router.post( + "/subscription/devices", + response_model=MiniAppSubscriptionSettingsUpdateResponse, +) +async def update_subscription_devices( + payload: MiniAppSubscriptionDevicesUpdateRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionSettingsUpdateResponse: + user, _ = await _resolve_user_from_init_data(db, payload.init_data) + subscription = getattr(user, "subscription", None) + if not subscription or subscription.is_trial: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail={ + "code": "subscription_required", + "message": "Active paid subscription required", + }, + ) + + if payload.subscription_id and payload.subscription_id != subscription.id: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={ + "code": "subscription_not_found", + "message": "Subscription not found", + }, + ) + + raw_value = ( + payload.devices + if payload.devices is not None + else payload.device_limit + ) + + if raw_value is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_value", "message": "Device limit is required"}, + ) + + try: + target_devices = int(raw_value) + except (TypeError, ValueError): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_value", "message": "Invalid device limit"}, + ) from None + + if target_devices < 1: + target_devices = 1 + + max_devices_setting = settings.MAX_DEVICES_LIMIT or 0 + if max_devices_setting > 0 and target_devices > max_devices_setting: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={ + "code": "devices_limit_exceeded", + "message": "Device limit exceeds maximum allowed", + }, + ) + + pricing = _compute_devices_pricing(subscription, user, target_devices) + total_price = pricing["price"] + + if total_price > 0 and user.balance_kopeks < total_price: + raise HTTPException( + status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": "Insufficient funds on balance", + }, + ) + + if total_price > 0: + description = f"Updating devices to {target_devices}" + success = await subtract_user_balance( + db, + user, + total_price, + description, + ) + if not success: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "charge_failed", "message": "Failed to charge balance"}, + ) + + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=total_price, + description=description, + ) + + subscription.device_limit = target_devices + subscription.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to sync devices to RemnaWave: %s", error) + + return MiniAppSubscriptionSettingsUpdateResponse( + success=True, + charged_amount_kopeks=total_price, + balance_kopeks=user.balance_kopeks, + ) + + @router.post( "/promo-codes/activate", response_model=MiniAppPromoCodeActivationResponse, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 3f668548..d256f5d7 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -15,6 +15,114 @@ class MiniAppSubscriptionRequest(BaseModel): init_data: str = Field(..., alias="initData") +class MiniAppSubscriptionSettingsRequest(BaseModel): + init_data: str = Field(..., alias="initData") + subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + + +class MiniAppSubscriptionServersUpdateRequest(BaseModel): + init_data: str = Field(..., alias="initData") + squads: Optional[List[str]] = None + servers: Optional[List[str]] = None + squad_uuids: Optional[List[str]] = Field(default=None, alias="squadUuids") + server_uuids: Optional[List[str]] = Field(default=None, alias="serverUuids") + subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + + +class MiniAppSubscriptionTrafficUpdateRequest(BaseModel): + init_data: str = Field(..., alias="initData") + traffic: Optional[int] = None + traffic_gb: Optional[int] = Field(default=None, alias="trafficGb") + subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + + +class MiniAppSubscriptionDevicesUpdateRequest(BaseModel): + init_data: str = Field(..., alias="initData") + devices: Optional[int] = None + device_limit: Optional[int] = Field(default=None, alias="deviceLimit") + subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + + +class MiniAppSubscriptionSettingsServer(BaseModel): + uuid: str + name: Optional[str] = None + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + discount_percent: Optional[int] = None + is_connected: bool = False + is_available: bool = True + disabled_reason: Optional[str] = None + + +class MiniAppSubscriptionSettingsServers(BaseModel): + available: List[MiniAppSubscriptionSettingsServer] = Field(default_factory=list) + min: int = 0 + max: int = 0 + can_update: bool = True + hint: Optional[str] = None + + +class MiniAppSubscriptionSettingsTrafficOption(BaseModel): + value: Optional[int] = None + label: Optional[str] = None + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + discount_percent: Optional[int] = None + is_current: bool = False + is_available: bool = True + description: Optional[str] = None + + +class MiniAppSubscriptionSettingsTraffic(BaseModel): + options: List[MiniAppSubscriptionSettingsTrafficOption] = Field(default_factory=list) + can_update: bool = True + current_value: Optional[int] = None + current_label: Optional[str] = None + + +class MiniAppSubscriptionSettingsDeviceOption(BaseModel): + value: int + label: Optional[str] = None + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + + +class MiniAppSubscriptionSettingsDevices(BaseModel): + options: List[MiniAppSubscriptionSettingsDeviceOption] = Field(default_factory=list) + can_update: bool = True + min: int = 0 + max: int = 0 + step: int = 1 + current: int = 0 + + +class MiniAppSubscriptionSettingsCurrent(BaseModel): + servers: List[MiniAppConnectedServer] = Field(default_factory=list) + traffic_limit_gb: Optional[int] = None + traffic_limit_label: Optional[str] = None + device_limit: Optional[int] = None + + +class MiniAppSubscriptionSettings(BaseModel): + subscription_id: int + currency: str = "RUB" + current: MiniAppSubscriptionSettingsCurrent + servers: MiniAppSubscriptionSettingsServers + traffic: MiniAppSubscriptionSettingsTraffic + devices: MiniAppSubscriptionSettingsDevices + + +class MiniAppSubscriptionSettingsResponse(BaseModel): + success: bool = True + settings: MiniAppSubscriptionSettings + + +class MiniAppSubscriptionSettingsUpdateResponse(BaseModel): + success: bool = True + charged_amount_kopeks: int = 0 + balance_kopeks: Optional[int] = None + + class MiniAppSubscriptionUser(BaseModel): telegram_id: int username: Optional[str] = None