diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index ad7a3405..2fd18b30 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3,12 +3,10 @@ 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 types import SimpleNamespace from uuid import uuid4 -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status @@ -16,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, @@ -29,30 +27,19 @@ from app.database.crud.promo_offer_template import get_promo_offer_template_by_i from app.database.crud.server_squad import ( get_available_server_squads, get_server_squad_by_uuid, - get_server_ids_by_uuids, add_user_to_servers, remove_user_from_servers, ) -from app.database.crud.subscription import ( - add_subscription_servers, - remove_subscription_servers, - create_paid_subscription, -) -from app.database.crud.subscription_conversion import create_subscription_conversion +from app.database.crud.subscription import add_subscription_servers, remove_subscription_servers from app.database.crud.transaction import ( create_transaction, get_user_total_spent_kopeks, ) -from app.database.crud.user import ( - get_user_by_telegram_id, - subtract_user_balance, - add_user_balance, -) +from app.database.crud.user import get_user_by_telegram_id, subtract_user_balance from app.database.models import ( PromoGroup, PromoOfferTemplate, Subscription, - SubscriptionStatus, SubscriptionTemporaryAccess, Transaction, TransactionType, @@ -73,7 +60,6 @@ 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.subscription_utils import get_happ_cryptolink_redirect_link -from app.utils.promo_offer import get_user_active_promo_discount_percent from app.utils.telegram_webapp import ( TelegramWebAppAuthError, parse_webapp_init_data, @@ -81,14 +67,11 @@ 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 ..dependencies import get_db_session @@ -143,15 +126,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionTrafficUpdateRequest, MiniAppSubscriptionDevicesUpdateRequest, MiniAppSubscriptionUpdateResponse, - MiniAppSubscriptionPurchaseOptions, - MiniAppSubscriptionPurchaseOptionsRequest, - MiniAppSubscriptionPurchaseOptionsResponse, - MiniAppSubscriptionPurchasePreview, - MiniAppSubscriptionPurchasePreviewRequest, - MiniAppSubscriptionPurchasePreviewResponse, - MiniAppSubscriptionPurchaseSubmitRequest, - MiniAppSubscriptionPurchaseSubmitResponse, - MiniAppSubscriptionPurchasePeriod, ) @@ -2755,769 +2729,6 @@ def _get_addon_discount_percent_for_user( return 0 -@dataclass -class PurchaseSelection: - period_days: int - traffic_gb: int - servers: List[str] - devices: int - - -def _format_price_label(amount_kopeks: Optional[int]) -> Optional[str]: - if amount_kopeks is None: - return None - try: - return settings.format_price(int(amount_kopeks)) - except Exception: # pragma: no cover - defensive fallback - return None - - -def _normalize_server_list(values: Optional[Iterable[Any]]) -> List[str]: - normalized: List[str] = [] - seen: set[str] = set() - if not values: - return normalized - - for value in values: - if value is None: - continue - text = str(value).strip() - if not text or text in seen: - continue - seen.add(text) - normalized.append(text) - - return normalized - - -def _get_available_purchase_periods() -> List[int]: - raw_periods = settings.get_available_subscription_periods() - normalized: List[int] = [] - - for raw in raw_periods: - try: - days = int(raw) - except (TypeError, ValueError): - continue - if days <= 0: - continue - if PERIOD_PRICES.get(days, 0) <= 0 and days not in PERIOD_PRICES: - continue - normalized.append(days) - - if not normalized: - normalized = [days for days in sorted(PERIOD_PRICES.keys()) if PERIOD_PRICES.get(days, 0) > 0] - - if not normalized: - normalized = [30] - - normalized = sorted(set(normalized)) - return normalized - - -def _get_available_traffic_packages() -> List[int]: - packages: List[int] = [] - for package in settings.get_traffic_packages(): - try: - gb_value = int(package.get("gb")) - except (TypeError, ValueError): - continue - - if gb_value < 0: - continue - - is_enabled = bool(package.get("enabled", True)) - if package.get("is_active") is False: - is_enabled = False - - if is_enabled: - packages.append(gb_value) - - packages = sorted(set(packages)) - return packages - - -def _resolve_purchase_period_days( - payload: Any, - available_periods: List[int], -) -> int: - if not available_periods: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "No subscription periods are available"}, - ) - - candidates = [ - getattr(payload, "period_id", None), - getattr(payload, "period_key", None), - getattr(payload, "period_code", None), - getattr(payload, "period", None), - ] - - for candidate in candidates: - if candidate is None: - continue - text = str(candidate).strip() - if not text: - continue - if text.startswith("days:"): - text = text.split(":", 1)[1] - try: - value = int(float(text)) - except (TypeError, ValueError): - continue - if value in available_periods: - return value - - month_candidates = [ - getattr(payload, "period_months", None), - getattr(payload, "months", None), - ] - for candidate in month_candidates: - if candidate is None: - continue - try: - months = int(float(candidate)) - except (TypeError, ValueError): - continue - days = months * 30 - if days in available_periods: - return days - - day_candidates = [getattr(payload, "period_days", None)] - for candidate in day_candidates: - if candidate is None: - continue - try: - days = int(float(candidate)) - except (TypeError, ValueError): - continue - if days in available_periods: - return days - - return available_periods[0] - - -def _resolve_purchase_traffic_value( - payload: Any, - subscription: Optional[Subscription], - selectable: bool, - available_packages: List[int], -) -> int: - if not selectable: - return settings.get_fixed_traffic_limit() - - candidates = [ - getattr(payload, "traffic", None), - getattr(payload, "traffic_value", None), - getattr(payload, "traffic_gb", None), - getattr(payload, "limit", None), - ] - for candidate in candidates: - if candidate is None: - continue - try: - value = int(float(candidate)) - except (TypeError, ValueError): - continue - if value < 0: - continue - if available_packages and value not in available_packages: - continue - return value - - if subscription and subscription.traffic_limit_gb is not None: - value = int(subscription.traffic_limit_gb) - if value >= 0 and (not available_packages or value in available_packages): - return value - - if available_packages: - return available_packages[0] - - return 0 - - -def _resolve_purchase_devices_value( - payload: Any, - subscription: Optional[Subscription], -) -> int: - candidates = [ - getattr(payload, "devices", None), - getattr(payload, "device_limit", None), - ] - for candidate in candidates: - if candidate is None: - continue - try: - value = int(float(candidate)) - except (TypeError, ValueError): - continue - if value > 0: - return value - - if subscription and subscription.device_limit: - try: - value = int(subscription.device_limit) - except (TypeError, ValueError): - value = None - if value and value > 0: - return value - - default_limit = max(1, int(getattr(settings, "DEFAULT_DEVICE_LIMIT", 1))) - return default_limit - - -def _resolve_purchase_servers_selection( - payload: Any, - subscription: Optional[Subscription], - available_servers: List[str], -) -> List[str]: - raw_servers: List[str] = [] - for attr_name in ("servers", "countries", "server_uuids", "squad_uuids"): - attr_value = getattr(payload, attr_name, None) - if isinstance(attr_value, (list, tuple, set)): - raw_servers.extend(_normalize_server_list(attr_value)) - - selection = _normalize_server_list(raw_servers) - - if not selection and subscription and getattr(subscription, "connected_squads", None): - selection = _normalize_server_list(subscription.connected_squads) - - if not selection and available_servers: - selection = [available_servers[0]] - - return selection - - -def _build_default_purchase_selection( - payload: Any, - user: User, - available_periods: List[int], - available_packages: List[int], - available_servers: List[str], -) -> PurchaseSelection: - subscription = getattr(user, "subscription", None) - period_days = _resolve_purchase_period_days(payload, available_periods) - traffic_value = _resolve_purchase_traffic_value( - payload, - subscription, - settings.is_traffic_selectable(), - available_packages, - ) - servers = _resolve_purchase_servers_selection(payload, subscription, available_servers) - devices = _resolve_purchase_devices_value(payload, subscription) - - return PurchaseSelection( - period_days=period_days, - traffic_gb=traffic_value, - servers=servers, - devices=devices, - ) - - -def _serialize_purchase_selection(selection: PurchaseSelection) -> Dict[str, Any]: - return { - "period_id": str(selection.period_days), - "periodId": str(selection.period_days), - "period": str(selection.period_days), - "period_days": selection.period_days, - "periodDays": selection.period_days, - "traffic": selection.traffic_gb, - "traffic_value": selection.traffic_gb, - "trafficValue": selection.traffic_gb, - "traffic_gb": selection.traffic_gb, - "trafficGb": selection.traffic_gb, - "servers": selection.servers, - "countries": selection.servers, - "server_uuids": selection.servers, - "serverUuids": selection.servers, - "devices": selection.devices, - "device_limit": selection.devices, - "deviceLimit": selection.devices, - } - - -async def _build_subscription_purchase_options_payload( - db: AsyncSession, - user: User, - payload: Optional[Any] = None, -) -> MiniAppSubscriptionPurchaseOptions: - payload = payload or SimpleNamespace() - available_periods = _get_available_purchase_periods() - available_packages = _get_available_traffic_packages() - available_servers_models = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - available_server_uuids = [ - server.squad_uuid - for server in available_servers_models - if getattr(server, "squad_uuid", None) - ] - - selection = _build_default_purchase_selection( - payload, - user, - available_periods, - available_packages, - available_server_uuids, - ) - - subscription = getattr(user, "subscription", None) - currency = (getattr(user, "balance_currency", None) or "RUB").upper() - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - - traffic_config: Dict[str, Any] = { - "mode": "selectable" if settings.is_traffic_selectable() else "fixed", - "selectable": settings.is_traffic_selectable(), - "options": [ - { - "value": gb, - "label": f"{gb} GB", - } - for gb in available_packages - ], - "current": selection.traffic_gb, - "default": selection.traffic_gb, - } - if not settings.is_traffic_selectable(): - traffic_config.update( - { - "value": settings.get_fixed_traffic_limit(), - "label": f"{settings.get_fixed_traffic_limit()} GB", - } - ) - - servers_config: Dict[str, Any] = { - "options": [ - { - "uuid": server.squad_uuid, - "name": getattr(server, "display_name", server.squad_uuid), - } - for server in available_servers_models - ], - "min": 1 if available_servers_models else 0, - "max": len(available_servers_models), - "selectable": len(available_servers_models) > 1, - "selected": selection.servers, - "default": selection.servers, - } - - max_devices_setting = getattr(settings, "MAX_DEVICES_LIMIT", 0) - devices_config: Dict[str, Any] = { - "min": 1, - "max": max_devices_setting if max_devices_setting > 0 else 0, - "step": 1, - "default": selection.devices, - "current": selection.devices, - "price_per_device_kopeks": settings.PRICE_PER_DEVICE, - "price_per_device_label": _format_price_label(settings.PRICE_PER_DEVICE), - } - - language = getattr(user, "language", "ru") or "ru" - periods_payload: List[Dict[str, Any]] = [] - - for days in available_periods: - base_price_original = int(PERIOD_PRICES.get(days, 0) or 0) - if base_price_original <= 0: - continue - - months = calculate_months_from_days(days) - try: - period_discount_percent = int(user.get_promo_discount("period", days)) - except Exception: - period_discount_percent = 0 - discounted_base, base_discount_value = apply_percentage_discount( - base_price_original, - period_discount_percent, - ) - - per_month_price = discounted_base // max(1, months) - - period_payload: Dict[str, Any] = { - "id": str(days), - "days": days, - "months": months, - "priceKopeks": discounted_base, - "priceLabel": _format_price_label(discounted_base), - "perMonthPriceKopeks": per_month_price, - "perMonthPriceLabel": _format_price_label(per_month_price), - "description": format_period_description(days, language), - } - - if base_discount_value > 0: - period_payload.update( - { - "originalPriceKopeks": base_price_original, - "originalPriceLabel": _format_price_label(base_price_original), - "discountPercent": period_discount_percent, - } - ) - - if settings.is_traffic_selectable() and available_packages: - traffic_discount_percent = _get_addon_discount_percent_for_user( - user, - "traffic", - days, - ) - traffic_options_override: List[Dict[str, Any]] = [] - for gb in available_packages: - monthly_price = settings.get_traffic_price(gb) - discounted_monthly, _ = apply_percentage_discount( - monthly_price, - traffic_discount_percent, - ) - option_payload: Dict[str, Any] = { - "value": gb, - "priceKopeks": discounted_monthly, - "priceLabel": _format_price_label(discounted_monthly), - } - if discounted_monthly != monthly_price: - option_payload.update( - { - "originalPriceKopeks": monthly_price, - "originalPriceLabel": _format_price_label(monthly_price), - } - ) - if traffic_discount_percent: - option_payload["discountPercent"] = traffic_discount_percent - traffic_options_override.append(option_payload) - - if traffic_options_override: - period_payload["traffic"] = { - "options": traffic_options_override, - "selectable": True, - } - if traffic_discount_percent: - period_payload["traffic"]["discountPercent"] = traffic_discount_percent - - servers_discount_percent = _get_addon_discount_percent_for_user( - user, - "servers", - days, - ) - catalog_subscription = subscription or SimpleNamespace( - connected_squads=selection.servers, - ) - _, server_option_models, server_catalog = await _prepare_server_catalog( - db, - user, - catalog_subscription, - servers_discount_percent, - ) - server_override_options: List[Dict[str, Any]] = [] - for option in server_option_models: - entry = server_catalog.get(option.uuid, {}) - option_payload = { - "uuid": option.uuid, - "name": option.name, - "priceKopeks": option.price_kopeks, - "priceLabel": _format_price_label(option.price_kopeks), - "isAvailable": option.is_available, - } - original_per_month = int(entry.get("price_per_month", 0) or 0) - if original_per_month and original_per_month != option.price_kopeks: - option_payload.update( - { - "originalPriceKopeks": original_per_month, - "originalPriceLabel": _format_price_label(original_per_month), - } - ) - if option.discount_percent: - option_payload["discountPercent"] = option.discount_percent - server_override_options.append(option_payload) - - period_payload["servers"] = { - "options": server_override_options, - "min": 1 if server_override_options else 0, - "max": len(server_override_options), - "selectable": len(server_override_options) > 1, - } - - devices_discount_percent = _get_addon_discount_percent_for_user( - user, - "devices", - days, - ) - discounted_device_price, _ = apply_percentage_discount( - settings.PRICE_PER_DEVICE, - devices_discount_percent, - ) - devices_override: Dict[str, Any] = { - "pricePerDeviceKopeks": discounted_device_price, - "pricePerDeviceLabel": _format_price_label(discounted_device_price), - "min": devices_config["min"], - "max": devices_config["max"], - "step": devices_config["step"], - } - if discounted_device_price != settings.PRICE_PER_DEVICE: - devices_override.update( - { - "originalPricePerDeviceKopeks": settings.PRICE_PER_DEVICE, - "originalPricePerDeviceLabel": _format_price_label(settings.PRICE_PER_DEVICE), - } - ) - if devices_discount_percent: - devices_override["discountPercent"] = devices_discount_percent - period_payload["devices"] = devices_override - - periods_payload.append(period_payload) - - purchase_selection_dict = _serialize_purchase_selection(selection) - - options_model = MiniAppSubscriptionPurchaseOptions( - currency=currency, - balanceKopeks=balance_kopeks, - balanceLabel=_format_price_label(balance_kopeks), - subscriptionId=getattr(subscription, "id", None), - periods=[MiniAppSubscriptionPurchasePeriod(**period) for period in periods_payload], - traffic=traffic_config, - servers=servers_config, - devices=devices_config, - selection=purchase_selection_dict, - promo={ - "activePromoPercent": get_user_active_promo_discount_percent(user), - }, - ) - - return options_model - - -async def _calculate_subscription_purchase_preview( - db: AsyncSession, - user: User, - selection: PurchaseSelection, -) -> Dict[str, Any]: - available_periods = _get_available_purchase_periods() - if selection.period_days not in available_periods: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected subscription period is not available"}, - ) - - base_price_original = int(PERIOD_PRICES.get(selection.period_days, 0) or 0) - if base_price_original <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected subscription period is not available"}, - ) - - months = calculate_months_from_days(selection.period_days) - months = max(1, months) - - try: - period_discount_percent = int(user.get_promo_discount("period", selection.period_days)) - except Exception: - period_discount_percent = 0 - discounted_base, base_discount_value = apply_percentage_discount( - base_price_original, - period_discount_percent, - ) - - selectable_traffic = settings.is_traffic_selectable() - if selectable_traffic: - available_packages = _get_available_traffic_packages() - if available_packages and selection.traffic_gb not in available_packages: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "traffic_unavailable", "message": "Selected traffic package is not available"}, - ) - traffic_price_per_month = settings.get_traffic_price(selection.traffic_gb) - traffic_discount_percent = _get_addon_discount_percent_for_user( - user, - "traffic", - selection.period_days, - ) - discounted_traffic_per_month, traffic_discount_per_month = apply_percentage_discount( - traffic_price_per_month, - traffic_discount_percent, - ) - total_traffic_price = discounted_traffic_per_month * months - total_traffic_original = traffic_price_per_month * months - - servers_discount_percent = _get_addon_discount_percent_for_user( - user, - "servers", - selection.period_days, - ) - subscription = getattr(user, "subscription", None) - catalog_subscription = subscription or SimpleNamespace( - connected_squads=selection.servers, - ) - _, server_option_models, server_catalog = await _prepare_server_catalog( - db, - user, - catalog_subscription, - servers_discount_percent, - ) - - if not selection.servers: - if server_option_models: - selection.servers = [server_option_models[0].uuid] - else: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "servers_unavailable", "message": "No servers are available for purchase"}, - ) - - selected_entries = [] - for uuid in selection.servers: - entry = server_catalog.get(uuid) - if not entry: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_servers", "message": "Selected server is not available"}, - ) - if not entry.get("available_for_new", False) and not entry.get("is_connected", False): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "server_unavailable", "message": "Selected server is not available"}, - ) - selected_entries.append(entry) - - servers_original_per_month = sum(int(entry.get("price_per_month", 0) or 0) for entry in selected_entries) - servers_discounted_per_month = sum(int(entry.get("discounted_per_month", 0) or 0) for entry in selected_entries) - total_servers_original = servers_original_per_month * months - total_servers_price = servers_discounted_per_month * months - - default_device_limit = max(1, getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)) - max_devices_setting = getattr(settings, "MAX_DEVICES_LIMIT", 0) - if max_devices_setting > 0 and selection.devices > max_devices_setting: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "devices_limit_exceeded", - "message": f"Device limit exceeds maximum allowed ({max_devices_setting})", - }, - ) - - additional_devices = max(0, selection.devices - default_device_limit) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = _get_addon_discount_percent_for_user( - user, - "devices", - selection.period_days, - ) - discounted_devices_per_month, devices_discount_per_month = apply_percentage_discount( - devices_price_per_month, - devices_discount_percent, - ) - total_devices_price = discounted_devices_per_month * months - total_devices_original = devices_price_per_month * months - - total_original = ( - base_price_original - + total_traffic_original - + total_servers_original - + total_devices_original - ) - total_after_discounts = ( - discounted_base - + total_traffic_price - + total_servers_price - + total_devices_price - ) - - promo_offer_percent = get_user_active_promo_discount_percent(user) - final_price, promo_discount_value = apply_percentage_discount( - total_after_discounts, - promo_offer_percent, - ) - - breakdown: List[Dict[str, Any]] = [] - period_label = format_period_description(selection.period_days, getattr(user, "language", "ru")) - breakdown.append( - { - "label": period_label, - "value": _format_price_label(discounted_base), - } - ) - if total_servers_price > 0: - breakdown.append( - { - "label": f"Servers ({len(selection.servers)})", - "value": _format_price_label(total_servers_price), - } - ) - if total_traffic_price > 0: - breakdown.append( - { - "label": f"Traffic {selection.traffic_gb} GB", - "value": _format_price_label(total_traffic_price), - } - ) - if total_devices_price > 0: - breakdown.append( - { - "label": f"Devices ({selection.devices})", - "value": _format_price_label(total_devices_price), - } - ) - - discount_lines: List[str] = [] - if base_discount_value > 0 and period_discount_percent > 0: - discount_lines.append(f"Period discount −{period_discount_percent}%") - if traffic_discount_per_month > 0 and traffic_discount_percent > 0: - discount_lines.append(f"Traffic discount −{traffic_discount_percent}%") - if devices_discount_per_month > 0 and devices_discount_percent > 0: - discount_lines.append(f"Devices discount −{devices_discount_percent}%") - if servers_original_per_month > servers_discounted_per_month and servers_discount_percent > 0: - discount_lines.append(f"Servers discount −{servers_discount_percent}%") - if promo_discount_value > 0 and promo_offer_percent > 0: - discount_lines.append(f"Promo offer −{promo_offer_percent}%") - - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - missing_amount = max(0, final_price - balance_kopeks) - - per_month_price = final_price // months - - preview_payload = { - "totalPriceKopeks": final_price, - "totalPriceLabel": _format_price_label(final_price), - "originalPriceKopeks": total_original if total_original != final_price else None, - "originalPriceLabel": _format_price_label(total_original) if total_original != final_price else None, - "perMonthPriceKopeks": per_month_price, - "perMonthPriceLabel": _format_price_label(per_month_price), - "discountPercent": promo_offer_percent if promo_offer_percent else None, - "discountLines": discount_lines, - "balanceKopeks": balance_kopeks, - "balanceLabel": _format_price_label(balance_kopeks), - "missingAmountKopeks": missing_amount if missing_amount > 0 else None, - "missingAmountLabel": _format_price_label(missing_amount) if missing_amount > 0 else None, - "breakdown": breakdown, - "statusMessage": "Insufficient balance" if missing_amount > 0 else None, - "canPurchase": missing_amount <= 0, - } - - calculation_details = { - "preview": preview_payload, - "selection": selection, - "total_price": final_price, - "total_original": total_original, - "months": months, - "base_price": discounted_base, - "base_price_original": base_price_original, - "promo_offer_percent": promo_offer_percent, - "promo_discount_value": promo_discount_value, - "traffic_total": total_traffic_price, - "servers_total": total_servers_price, - "devices_total": total_devices_price, - "server_catalog": server_catalog, - "server_option_models": server_option_models, - "server_prices_for_period": [ - int(entry.get("discounted_per_month", 0) or 0) * months - for entry in selected_entries - ], - "traffic_discount_total": traffic_discount_per_month * months, - "devices_discount_total": devices_discount_per_month * months, - "servers_discount_total": (servers_original_per_month - servers_discounted_per_month) * months, - "base_discount_total": base_discount_value, - "selected_server_entries": selected_entries, - } - - return calculation_details - - def _get_period_hint_from_subscription( subscription: Optional[Subscription], ) -> Optional[int]: @@ -3883,297 +3094,6 @@ async def _build_subscription_settings( return settings_payload -@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) - - options_payload = await _build_subscription_purchase_options_payload(db, user, payload) - - return MiniAppSubscriptionPurchaseOptionsResponse(data=options_payload) - - -@router.post( - "/subscription/purchase/preview", - response_model=MiniAppSubscriptionPurchasePreviewResponse, -) -async def preview_subscription_purchase_endpoint( - payload: MiniAppSubscriptionPurchasePreviewRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionPurchasePreviewResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - - available_periods = _get_available_purchase_periods() - available_packages = _get_available_traffic_packages() - available_servers_models = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - available_server_uuids = [ - server.squad_uuid - for server in available_servers_models - if getattr(server, "squad_uuid", None) - ] - - selection = _build_default_purchase_selection( - payload, - user, - available_periods, - available_packages, - available_server_uuids, - ) - - calculation = await _calculate_subscription_purchase_preview(db, user, selection) - preview_model = MiniAppSubscriptionPurchasePreview(**calculation["preview"]) - - return MiniAppSubscriptionPurchasePreviewResponse(preview=preview_model) - - -@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) - - available_periods = _get_available_purchase_periods() - available_packages = _get_available_traffic_packages() - available_servers_models = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - available_server_uuids = [ - server.squad_uuid - for server in available_servers_models - if getattr(server, "squad_uuid", None) - ] - - selection = _build_default_purchase_selection( - payload, - user, - available_periods, - available_packages, - available_server_uuids, - ) - - calculation = await _calculate_subscription_purchase_preview(db, user, selection) - preview_payload = calculation["preview"] - - missing_amount = preview_payload.get("missingAmountKopeks") or 0 - if missing_amount and missing_amount > 0: - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": "Insufficient funds on balance", - "missing_amount_kopeks": missing_amount, - }, - ) - - final_price = calculation["total_price"] - description = f"Purchase of subscription for {selection.period_days} days" - - balance_debited = False - purchase_completed = False - subscription = getattr(user, "subscription", None) - server_entries = calculation.get("selected_server_entries", []) - server_prices_for_period = calculation.get("server_prices_for_period", []) - - try: - if final_price > 0: - success = await subtract_user_balance( - db, - user, - final_price, - description, - consume_promo_offer=calculation.get("promo_discount_value", 0) > 0, - ) - if not success: - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": "Insufficient funds on balance", - }, - ) - balance_debited = True - - current_time = datetime.utcnow() - traffic_limit_gb = ( - selection.traffic_gb - if settings.is_traffic_selectable() - else settings.get_fixed_traffic_limit() - ) - - if subscription: - bonus_period = timedelta() - if subscription.is_trial: - try: - trial_duration = (current_time - (subscription.start_date or current_time)).days - except Exception: - trial_duration = 0 - - if ( - settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID - and getattr(subscription, "end_date", None) - ): - remaining = subscription.end_date - current_time - if remaining.total_seconds() > 0: - bonus_period = remaining - - try: - await create_subscription_conversion( - db=db, - user_id=user.id, - trial_duration_days=trial_duration, - payment_method="balance", - first_payment_amount_kopeks=final_price, - first_paid_period_days=selection.period_days, - ) - except Exception as conversion_error: - logger.warning( - "Failed to record trial conversion for user %s: %s", - user.id, - conversion_error, - ) - - subscription.is_trial = False - - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.start_date = current_time - subscription.end_date = current_time + timedelta(days=selection.period_days) + bonus_period - subscription.traffic_limit_gb = traffic_limit_gb - subscription.device_limit = selection.devices - subscription.connected_squads = selection.servers - subscription.traffic_used_gb = 0.0 - subscription.updated_at = current_time - - await db.commit() - await db.refresh(subscription) - else: - subscription = await create_paid_subscription( - db=db, - user_id=user.id, - duration_days=selection.period_days, - traffic_limit_gb=traffic_limit_gb, - device_limit=selection.devices, - connected_squads=selection.servers, - update_server_counters=False, - ) - - server_data: List[Tuple[int, int]] = [] - for entry, price in zip(server_entries, server_prices_for_period): - server_id = entry.get("server_id") if isinstance(entry, dict) else None - if server_id is None: - try: - server = await get_server_squad_by_uuid(db, entry.get("uuid")) - server_id = getattr(server, "id", None) - except Exception: - server_id = None - if server_id is not None: - server_data.append((int(server_id), int(price))) - - if server_data: - server_ids = [item[0] for item in server_data] - server_paid_prices = [item[1] for item in server_data] - await add_subscription_servers(db, subscription, server_ids, server_paid_prices) - await add_user_to_servers(db, server_ids) - - await mark_user_as_had_paid_subscription(db, user) - - service = SubscriptionService() - try: - if getattr(user, "remnawave_uuid", None): - remna_user = await service.update_remnawave_user( - db, - subscription, - reset_traffic=getattr(settings, "RESET_TRAFFIC_ON_PAYMENT", False), - reset_reason="subscription_purchase", - ) - else: - remna_user = await service.create_remnawave_user( - db, - subscription, - reset_traffic=getattr(settings, "RESET_TRAFFIC_ON_PAYMENT", False), - reset_reason="subscription_purchase", - ) - except Exception as service_error: - logger.warning( - "Failed to synchronize RemnaWave user for %s: %s", - user.id, - service_error, - ) - remna_user = None - - if not remna_user: - try: - remna_user = await service.create_remnawave_user( - db, - subscription, - reset_traffic=getattr(settings, "RESET_TRAFFIC_ON_PAYMENT", False), - reset_reason="subscription_purchase_retry", - ) - except Exception as retry_error: - logger.warning( - "Failed to create RemnaWave user on retry for %s: %s", - user.id, - retry_error, - ) - - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=final_price, - description=f"Subscription for {selection.period_days} days", - ) - - await db.refresh(user) - purchase_completed = True - - except HTTPException: - raise - except Exception as purchase_error: - logger.error("Subscription purchase failed for user %s: %s", user.id, purchase_error) - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "purchase_failed", "message": "Failed to complete subscription purchase"}, - ) from purchase_error - finally: - if not purchase_completed and balance_debited and final_price > 0: - try: - await add_user_balance( - db, - user, - final_price, - "Refund for failed subscription purchase", - ) - except Exception as refund_error: - logger.error( - "Failed to refund user %s after purchase failure: %s", - user.id, - refund_error, - ) - - response = MiniAppSubscriptionPurchaseSubmitResponse( - success=True, - message="Subscription purchased successfully", - subscriptionId=getattr(subscription, "id", None), - balanceKopeks=getattr(user, "balance_kopeks", 0), - balanceLabel=_format_price_label(getattr(user, "balance_kopeks", 0)), - ) - - return response - - @router.post( "/subscription/settings", response_model=MiniAppSubscriptionSettingsResponse, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index a2a41ca6..f461f372 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -436,128 +436,6 @@ class MiniAppSubscriptionSettingsResponse(BaseModel): settings: MiniAppSubscriptionSettings -class MiniAppSubscriptionPurchasePeriod(BaseModel): - id: str - days: int - months: int - price_kopeks: int = Field(..., alias="priceKopeks") - price_label: Optional[str] = Field(default=None, alias="priceLabel") - original_price_kopeks: Optional[int] = Field(default=None, alias="originalPriceKopeks") - original_price_label: Optional[str] = Field(default=None, alias="originalPriceLabel") - per_month_price_kopeks: Optional[int] = Field(default=None, alias="perMonthPriceKopeks") - per_month_price_label: Optional[str] = Field(default=None, alias="perMonthPriceLabel") - discount_percent: Optional[int] = Field(default=None, alias="discountPercent") - description: Optional[str] = None - traffic: Optional[Dict[str, Any]] = None - servers: Optional[Dict[str, Any]] = None - devices: Optional[Dict[str, Any]] = None - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionPurchaseOptions(BaseModel): - currency: str = "RUB" - balance_kopeks: int = Field(..., alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - periods: List[Dict[str, Any]] = Field(default_factory=list) - traffic: Dict[str, Any] = Field(default_factory=dict) - servers: Dict[str, Any] = Field(default_factory=dict) - devices: Dict[str, Any] = Field(default_factory=dict) - selection: Dict[str, Any] = Field(default_factory=dict) - summary: Optional[Dict[str, Any]] = None - promo: Optional[Dict[str, Any]] = None - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel): - success: bool = True - data: MiniAppSubscriptionPurchaseOptions - - -class MiniAppSubscriptionPurchasePreview(BaseModel): - total_price_kopeks: int = Field(..., alias="totalPriceKopeks") - total_price_label: str = Field(..., alias="totalPriceLabel") - original_price_kopeks: Optional[int] = Field(default=None, alias="originalPriceKopeks") - original_price_label: Optional[str] = Field(default=None, alias="originalPriceLabel") - per_month_price_kopeks: Optional[int] = Field(default=None, alias="perMonthPriceKopeks") - per_month_price_label: Optional[str] = Field(default=None, 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[Dict[str, Any]] = Field(default_factory=list) - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - missing_amount_kopeks: Optional[int] = Field(default=None, alias="missingAmountKopeks") - missing_amount_label: Optional[str] = Field(default=None, alias="missingAmountLabel") - status_message: Optional[str] = Field(default=None, alias="statusMessage") - can_purchase: bool = Field(default=True, alias="canPurchase") - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionPurchasePreviewResponse(BaseModel): - success: bool = True - preview: MiniAppSubscriptionPurchasePreview - - -class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - -class MiniAppSubscriptionPurchaseBaseRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - selection: Optional[Dict[str, Any]] = None - period_id: Optional[str] = Field(default=None, alias="periodId") - period_key: Optional[str] = Field(default=None, alias="periodKey") - period_code: Optional[str] = Field(default=None, alias="periodCode") - period: Optional[str] = None - period_days: Optional[int] = Field(default=None, alias="periodDays") - period_months: Optional[int] = Field(default=None, alias="periodMonths") - months: Optional[int] = None - traffic: Optional[int] = None - traffic_value: Optional[int] = Field(default=None, alias="trafficValue") - traffic_gb: Optional[int] = Field(default=None, alias="trafficGb") - limit: Optional[int] = None - servers: Optional[List[str]] = None - countries: Optional[List[str]] = None - server_uuids: Optional[List[str]] = Field(default=None, alias="serverUuids") - squad_uuids: Optional[List[str]] = Field(default=None, alias="squadUuids") - devices: Optional[int] = None - device_limit: Optional[int] = Field(default=None, alias="deviceLimit") - - 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): - selection = values.get("selection") - if isinstance(selection, dict): - merged = dict(selection) - merged.update(values) - return merged - return values - - -class MiniAppSubscriptionPurchasePreviewRequest(MiniAppSubscriptionPurchaseBaseRequest): - pass - - -class MiniAppSubscriptionPurchaseSubmitRequest(MiniAppSubscriptionPurchaseBaseRequest): - pass - - -class MiniAppSubscriptionPurchaseSubmitResponse(BaseModel): - success: bool = True - message: Optional[str] = None - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - - class MiniAppSubscriptionSettingsRequest(BaseModel): init_data: str = Field(..., alias="initData") subscription_id: Optional[int] = None diff --git a/miniapp/index.html b/miniapp/index.html index b1ab2291..a19febf0 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -9751,56 +9751,28 @@ return false; } + const typeRaw = String(userData.subscription_type || '').toLowerCase(); + if (typeRaw === 'trial') { + return false; + } + if (typeRaw === 'paid') { + return true; + } + + if (userData.user.has_active_subscription === false) { + return false; + } + const statusRaw = String( userData.user.subscription_actual_status || userData.user.subscription_status - || userData.subscription_status || '' ).toLowerCase(); - const inactiveStatuses = new Set([ - 'trial', - 'expired', - 'disabled', - 'inactive', - 'stopped', - 'ended', - 'cancelled', - 'canceled', - 'pending', - 'pending_payment', - 'awaiting_payment', - 'none', - ]); - if (statusRaw && inactiveStatuses.has(statusRaw)) { + if (['trial', 'expired', 'disabled'].includes(statusRaw)) { return false; } - const hasActiveFlag = userData.user.has_active_subscription; - if (hasActiveFlag === true) { - return true; - } - if (hasActiveFlag === false) { - return false; - } - - const activeStatuses = new Set(['active', 'running', 'enabled']); - if (statusRaw && activeStatuses.has(statusRaw)) { - return true; - } - - const typeRaw = String( - userData.subscription_type - || userData.subscriptionType - || '' - ).toLowerCase(); - if (['trial', 'free', 'none'].includes(typeRaw)) { - return false; - } - if (['paid', 'active'].includes(typeRaw)) { - return true; - } - - return false; + return true; } function normalizeServerEntry(entry) {