diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 84b20076..5ec70d63 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -24,18 +24,9 @@ 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_available_server_squads, - get_server_squad_by_uuid, - add_user_to_servers, - remove_user_from_servers, -) -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 +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.models import ( PromoGroup, PromoOfferTemplate, @@ -68,11 +59,6 @@ from app.utils.user_utils import ( get_detailed_referral_list, get_user_referral_summary, ) -from app.utils.pricing_utils import ( - apply_percentage_discount, - calculate_prorated_price, - get_remaining_months, -) from ..dependencies import get_db_session from ..schemas.miniapp import ( @@ -111,21 +97,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionResponse, MiniAppSubscriptionUser, MiniAppTransaction, - MiniAppSubscriptionSettingsRequest, - MiniAppSubscriptionSettingsResponse, - MiniAppSubscriptionSettings, - MiniAppSubscriptionCurrentSettings, - MiniAppSubscriptionServersSettings, - MiniAppSubscriptionServerOption, - MiniAppSubscriptionTrafficSettings, - MiniAppSubscriptionTrafficOption, - MiniAppSubscriptionDevicesSettings, - MiniAppSubscriptionDeviceOption, - MiniAppSubscriptionBillingContext, - MiniAppSubscriptionServersUpdateRequest, - MiniAppSubscriptionTrafficUpdateRequest, - MiniAppSubscriptionDevicesUpdateRequest, - MiniAppSubscriptionUpdateResponse, ) @@ -2703,854 +2674,3 @@ def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]: } -def _get_addon_discount_percent_for_user( - 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: - percent = user.get_promo_discount(category, period_days_hint) - except AttributeError: - return 0 - - try: - return int(percent) - except (TypeError, ValueError): - return 0 - - -def _get_period_hint_from_subscription( - subscription: Optional[Subscription], -) -> Optional[int]: - if not subscription: - return None - - months_remaining = get_remaining_months(subscription.end_date) - if months_remaining <= 0: - return None - - return months_remaining * 30 - - -def _validate_subscription_id( - requested_id: Optional[int], - subscription: Subscription, -) -> None: - if requested_id is None: - return - - try: - requested = int(requested_id) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "invalid_subscription_id", - "message": "Invalid subscription identifier", - }, - ) from None - - if requested != subscription.id: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "subscription_mismatch", - "message": "Subscription does not belong to the authorized user", - }, - ) - - -async def _authorize_miniapp_user( - init_data: str, - db: AsyncSession, -) -> User: - if not init_data: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": "Authorization data is missing"}, - ) - - try: - webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN) - except TelegramWebAppAuthError as error: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, - ) from error - - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, - ) - - try: - telegram_id = int(telegram_user["id"]) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, - ) from None - - user = await get_user_by_telegram_id(db, telegram_id) - if not user: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, - ) - - return user - - -def _ensure_paid_subscription(user: User) -> Subscription: - subscription = getattr(user, "subscription", None) - if not subscription: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - detail={"code": "subscription_not_found", "message": "Subscription not found"}, - ) - - if getattr(subscription, "is_trial", False): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "paid_subscription_required", - "message": "This action is available only for paid subscriptions", - }, - ) - - if not getattr(subscription, "is_active", False): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "subscription_inactive", - "message": "Subscription must be active to manage settings", - }, - ) - - return subscription - - -async def _prepare_server_catalog( - db: AsyncSession, - user: User, - subscription: Subscription, - discount_percent: int, -) -> Tuple[ - List[MiniAppConnectedServer], - List[MiniAppSubscriptionServerOption], - Dict[str, Dict[str, Any]], -]: - available_servers = await get_available_server_squads( - db, - promo_group_id=getattr(user, "promo_group_id", None), - ) - available_by_uuid = {server.squad_uuid: server for server in available_servers} - - current_squads = list(subscription.connected_squads or []) - catalog: Dict[str, Dict[str, Any]] = {} - ordered_uuids: List[str] = [] - - def _register_server(server: Optional[Any], *, is_connected: bool = False) -> None: - if server is None: - return - - uuid = server.squad_uuid - discounted_per_month, discount_per_month = apply_percentage_discount( - int(getattr(server, "price_kopeks", 0) or 0), - discount_percent, - ) - available_for_new = bool(getattr(server, "is_available", True) and not server.is_full) - - entry = catalog.get(uuid) - if entry: - entry.update( - { - "name": getattr(server, "display_name", uuid), - "server_id": getattr(server, "id", None), - "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "available_for_new": available_for_new, - } - ) - entry["is_connected"] = entry["is_connected"] or is_connected - return - - catalog[uuid] = { - "uuid": uuid, - "name": getattr(server, "display_name", uuid), - "server_id": getattr(server, "id", None), - "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "available_for_new": available_for_new, - "is_connected": is_connected, - } - ordered_uuids.append(uuid) - - def _register_placeholder(uuid: str, *, is_connected: bool = False) -> None: - if uuid in catalog: - catalog[uuid]["is_connected"] = catalog[uuid]["is_connected"] or is_connected - return - - catalog[uuid] = { - "uuid": uuid, - "name": uuid, - "server_id": None, - "price_per_month": 0, - "discounted_per_month": 0, - "discount_per_month": 0, - "available_for_new": False, - "is_connected": is_connected, - } - ordered_uuids.append(uuid) - - current_set = set(current_squads) - - for uuid in current_squads: - server = available_by_uuid.get(uuid) - if server: - _register_server(server, is_connected=True) - continue - - server = await get_server_squad_by_uuid(db, uuid) - if server: - _register_server(server, is_connected=True) - else: - _register_placeholder(uuid, is_connected=True) - - for server in available_servers: - _register_server(server, is_connected=server.squad_uuid in current_set) - - current_servers = [ - MiniAppConnectedServer( - uuid=uuid, - name=catalog.get(uuid, {}).get("name", uuid), - ) - for uuid in current_squads - ] - - server_options: List[MiniAppSubscriptionServerOption] = [] - discount_value = discount_percent if discount_percent > 0 else None - - for uuid in ordered_uuids: - entry = catalog[uuid] - available_for_new = bool(entry.get("available_for_new", False)) - is_connected = bool(entry.get("is_connected", False)) - option_available = available_for_new or is_connected - server_options.append( - MiniAppSubscriptionServerOption( - uuid=uuid, - name=entry.get("name", uuid), - price_kopeks=int(entry.get("discounted_per_month", 0)), - price_label=None, - discount_percent=discount_value, - is_connected=is_connected, - is_available=option_available, - disabled_reason=None if option_available else "Server is not available", - ) - ) - - return current_servers, server_options, catalog - - -async def _build_subscription_settings( - db: AsyncSession, - user: User, - subscription: Subscription, -) -> MiniAppSubscriptionSettings: - period_hint_days = _get_period_hint_from_subscription(subscription) - months_remaining = get_remaining_months(subscription.end_date) - servers_discount = _get_addon_discount_percent_for_user( - user, - "servers", - period_hint_days, - ) - traffic_discount = _get_addon_discount_percent_for_user( - user, - "traffic", - period_hint_days, - ) - devices_discount = _get_addon_discount_percent_for_user( - user, - "devices", - period_hint_days, - ) - - current_servers, server_options, _ = await _prepare_server_catalog( - db, - user, - subscription, - servers_discount, - ) - - traffic_options: List[MiniAppSubscriptionTrafficOption] = [] - if settings.is_traffic_selectable(): - for package in settings.get_traffic_packages(): - is_enabled = bool(package.get("enabled", True)) - if package.get("is_active") is False: - is_enabled = False - if not is_enabled: - continue - try: - gb_value = int(package.get("gb")) - except (TypeError, ValueError): - continue - - price = int(package.get("price") or 0) - discounted_price, _ = apply_percentage_discount(price, traffic_discount) - traffic_options.append( - MiniAppSubscriptionTrafficOption( - value=gb_value, - label=None, - price_kopeks=discounted_price, - price_label=None, - is_current=(gb_value == subscription.traffic_limit_gb), - is_available=True, - description=None, - ) - ) - - default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1) - current_device_limit = int(subscription.device_limit or default_device_limit) - - max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None - if max_devices_setting is not None: - max_devices = max(max_devices_setting, current_device_limit, default_device_limit) - else: - max_devices = max(current_device_limit, default_device_limit) + 10 - - discounted_single_device, _ = apply_percentage_discount( - settings.PRICE_PER_DEVICE, - devices_discount, - ) - - devices_options: List[MiniAppSubscriptionDeviceOption] = [] - for value in range(1, max_devices + 1): - chargeable = max(0, value - default_device_limit) - discounted_per_month, _ = apply_percentage_discount( - chargeable * settings.PRICE_PER_DEVICE, - devices_discount, - ) - devices_options.append( - MiniAppSubscriptionDeviceOption( - value=value, - label=None, - price_kopeks=discounted_per_month, - price_label=None, - ) - ) - - settings_payload = MiniAppSubscriptionSettings( - subscription_id=subscription.id, - currency=(getattr(user, "balance_currency", None) or "RUB").upper(), - current=MiniAppSubscriptionCurrentSettings( - servers=current_servers, - traffic_limit_gb=subscription.traffic_limit_gb, - traffic_limit_label=None, - device_limit=current_device_limit, - ), - servers=MiniAppSubscriptionServersSettings( - available=server_options, - min=1 if server_options else 0, - max=len(server_options) if server_options else 0, - can_update=True, - hint=None, - ), - traffic=MiniAppSubscriptionTrafficSettings( - options=traffic_options, - can_update=settings.is_traffic_selectable(), - current_value=subscription.traffic_limit_gb, - ), - devices=MiniAppSubscriptionDevicesSettings( - options=devices_options, - can_update=True, - min=1, - max=max_devices_setting or 0, - step=1, - current=current_device_limit, - price_kopeks=discounted_single_device, - price_label=None, - ), - billing=MiniAppSubscriptionBillingContext( - months_remaining=max(1, months_remaining), - period_hint_days=period_hint_days, - renews_at=subscription.end_date, - ), - ) - - return settings_payload - - -@router.post( - "/subscription/settings", - response_model=MiniAppSubscriptionSettingsResponse, -) -async def get_subscription_settings_endpoint( - payload: MiniAppSubscriptionSettingsRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionSettingsResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_paid_subscription(user) - _validate_subscription_id(payload.subscription_id, subscription) - - settings_payload = await _build_subscription_settings(db, user, subscription) - - return MiniAppSubscriptionSettingsResponse(settings=settings_payload) - - -@router.post( - "/subscription/servers", - response_model=MiniAppSubscriptionUpdateResponse, -) -async def update_subscription_servers_endpoint( - payload: MiniAppSubscriptionServersUpdateRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionUpdateResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_paid_subscription(user) - _validate_subscription_id(payload.subscription_id, subscription) - - raw_selection: List[str] = [] - for collection in ( - payload.servers, - payload.squads, - payload.server_uuids, - payload.squad_uuids, - ): - if collection: - raw_selection.extend(collection) - - selected_order: List[str] = [] - seen: set[str] = set() - for item in raw_selection: - if not item: - continue - uuid = str(item).strip() - if not uuid or uuid in seen: - continue - seen.add(uuid) - selected_order.append(uuid) - - if not selected_order: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "validation_error", - "message": "At least one server must be selected", - }, - ) - - current_squads = list(subscription.connected_squads or []) - current_set = set(current_squads) - selected_set = set(selected_order) - - added = [uuid for uuid in selected_order if uuid not in current_set] - removed = [uuid for uuid in current_squads if uuid not in selected_set] - - if not added and not removed: - return MiniAppSubscriptionUpdateResponse( - success=True, - message="No changes", - ) - - period_hint_days = _get_period_hint_from_subscription(subscription) - servers_discount = _get_addon_discount_percent_for_user( - user, - "servers", - period_hint_days, - ) - - _, _, catalog = await _prepare_server_catalog( - db, - user, - subscription, - servers_discount, - ) - - invalid_servers = [uuid for uuid in selected_order if uuid not in catalog] - if invalid_servers: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "invalid_servers", - "message": "Some of the selected servers are not available", - }, - ) - - for uuid in added: - entry = catalog.get(uuid) - if not entry or not entry.get("available_for_new", False): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "server_unavailable", - "message": "Selected server is not available", - }, - ) - - cost_per_month = sum(int(catalog[uuid].get("discounted_per_month", 0)) for uuid in added) - total_cost = 0 - charged_months = 0 - if cost_per_month > 0: - total_cost, charged_months = calculate_prorated_price( - cost_per_month, - subscription.end_date, - ) - else: - charged_months = get_remaining_months(subscription.end_date) - - added_server_ids = [ - catalog[uuid].get("server_id") - for uuid in added - if catalog[uuid].get("server_id") is not None - ] - added_server_prices = [ - int(catalog[uuid].get("discounted_per_month", 0)) * charged_months - for uuid in added - if catalog[uuid].get("server_id") is not None - ] - - if total_cost > 0 and getattr(user, "balance_kopeks", 0) < total_cost: - missing = total_cost - getattr(user, "balance_kopeks", 0) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": ( - "Недостаточно средств на балансе. " - f"Не хватает {settings.format_price(missing)}" - ), - }, - ) - - if total_cost > 0: - added_names = [catalog[uuid].get("name", uuid) for uuid in added] - description = ( - f"Добавление серверов: {', '.join(added_names)} на {charged_months} мес" - if added_names - else "Изменение списка серверов" - ) - - success = await subtract_user_balance( - db, - user, - total_cost, - description, - ) - if not success: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={ - "code": "balance_charge_failed", - "message": "Failed to charge user balance", - }, - ) - - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=total_cost, - description=description, - ) - - if added_server_ids: - await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) - await add_user_to_servers(db, added_server_ids) - - removed_server_ids = [ - catalog[uuid].get("server_id") - for uuid in removed - if catalog[uuid].get("server_id") is not None - ] - - if removed_server_ids: - await remove_subscription_servers(db, subscription.id, removed_server_ids) - await remove_user_from_servers(db, removed_server_ids) - - ordered_selection = [] - seen_selection = set() - for uuid in selected_order: - if uuid in seen_selection: - continue - seen_selection.add(uuid) - ordered_selection.append(uuid) - - subscription.connected_squads = ordered_selection - subscription.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(subscription) - - service = SubscriptionService() - await service.update_remnawave_user(db, subscription) - - return MiniAppSubscriptionUpdateResponse(success=True) - - -@router.post( - "/subscription/traffic", - response_model=MiniAppSubscriptionUpdateResponse, -) -async def update_subscription_traffic_endpoint( - payload: MiniAppSubscriptionTrafficUpdateRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionUpdateResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_paid_subscription(user) - _validate_subscription_id(payload.subscription_id, subscription) - - 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": "validation_error", "message": "Traffic amount is required"}, - ) - - try: - new_traffic = int(raw_value) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Invalid traffic amount"}, - ) from None - - if new_traffic < 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Traffic amount must be non-negative"}, - ) - - if new_traffic == subscription.traffic_limit_gb: - return MiniAppSubscriptionUpdateResponse(success=True, message="No changes") - - if not settings.is_traffic_selectable(): - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "traffic_fixed", - "message": "Traffic cannot be changed for this subscription", - }, - ) - - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None - traffic_discount = _get_addon_discount_percent_for_user( - user, - "traffic", - period_hint_days, - ) - - old_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - new_price_per_month = settings.get_traffic_price(new_traffic) - - discounted_old_per_month, _ = apply_percentage_discount( - old_price_per_month, - traffic_discount, - ) - discounted_new_per_month, _ = apply_percentage_discount( - new_price_per_month, - traffic_discount, - ) - - price_difference_per_month = discounted_new_per_month - discounted_old_per_month - total_price_difference = 0 - - if price_difference_per_month > 0: - total_price_difference = price_difference_per_month * months_remaining - if getattr(user, "balance_kopeks", 0) < total_price_difference: - missing = total_price_difference - getattr(user, "balance_kopeks", 0) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": ( - "Недостаточно средств на балансе. " - f"Не хватает {settings.format_price(missing)}" - ), - }, - ) - - description = ( - "Переключение трафика с " - f"{subscription.traffic_limit_gb}GB на {new_traffic}GB" - ) - - success = await subtract_user_balance( - db, - user, - total_price_difference, - description, - ) - if not success: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={ - "code": "balance_charge_failed", - "message": "Failed to charge user balance", - }, - ) - - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=total_price_difference, - description=f"{description} на {months_remaining} мес", - ) - - subscription.traffic_limit_gb = new_traffic - subscription.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(subscription) - - service = SubscriptionService() - await service.update_remnawave_user(db, subscription) - - return MiniAppSubscriptionUpdateResponse(success=True) - - -@router.post( - "/subscription/devices", - response_model=MiniAppSubscriptionUpdateResponse, -) -async def update_subscription_devices_endpoint( - payload: MiniAppSubscriptionDevicesUpdateRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionUpdateResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_paid_subscription(user) - _validate_subscription_id(payload.subscription_id, subscription) - - 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": "validation_error", "message": "Device limit is required"}, - ) - - try: - new_devices = int(raw_value) - except (TypeError, ValueError): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Invalid device limit"}, - ) from None - - if new_devices <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Device limit must be positive"}, - ) - - if settings.MAX_DEVICES_LIMIT > 0 and new_devices > settings.MAX_DEVICES_LIMIT: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={ - "code": "devices_limit_exceeded", - "message": ( - "Превышен максимальный лимит устройств " - f"({settings.MAX_DEVICES_LIMIT})" - ), - }, - ) - - current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1) - - if new_devices == current_devices: - return MiniAppSubscriptionUpdateResponse(success=True, message="No changes") - - devices_difference = new_devices - current_devices - price_to_charge = 0 - charged_months = 0 - - if devices_difference > 0: - current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT) - new_chargeable = max(0, new_devices - settings.DEFAULT_DEVICE_LIMIT) - chargeable_diff = new_chargeable - current_chargeable - - price_per_month = chargeable_diff * settings.PRICE_PER_DEVICE - months_remaining = get_remaining_months(subscription.end_date) - period_hint_days = months_remaining * 30 if months_remaining > 0 else None - devices_discount = _get_addon_discount_percent_for_user( - user, - "devices", - period_hint_days, - ) - - discounted_per_month, _ = apply_percentage_discount( - price_per_month, - devices_discount, - ) - price_to_charge, charged_months = calculate_prorated_price( - discounted_per_month, - subscription.end_date, - ) - - if price_to_charge > 0 and getattr(user, "balance_kopeks", 0) < price_to_charge: - missing = price_to_charge - getattr(user, "balance_kopeks", 0) - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": ( - "Недостаточно средств на балансе. " - f"Не хватает {settings.format_price(missing)}" - ), - }, - ) - - if price_to_charge > 0: - description = ( - "Изменение количества устройств с " - f"{current_devices} до {new_devices}" - ) - success = await subtract_user_balance( - db, - user, - price_to_charge, - description, - ) - if not success: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={ - "code": "balance_charge_failed", - "message": "Failed to charge user balance", - }, - ) - - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=price_to_charge, - description=f"{description} на {charged_months or get_remaining_months(subscription.end_date)} мес", - ) - - subscription.device_limit = new_devices - subscription.updated_at = datetime.utcnow() - await db.commit() - await db.refresh(subscription) - - service = SubscriptionService() - await service.update_remnawave_user(db, subscription) - - return MiniAppSubscriptionUpdateResponse(success=True) - diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index f461f372..3f668548 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field, ConfigDict, model_validator +from pydantic import BaseModel, Field class MiniAppBranding(BaseModel): @@ -354,173 +354,3 @@ class MiniAppSubscriptionResponse(BaseModel): legal_documents: Optional[MiniAppLegalDocuments] = None referral: Optional[MiniAppReferralInfo] = None - -class MiniAppSubscriptionServerOption(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 MiniAppSubscriptionTrafficOption(BaseModel): - value: Optional[int] = None - label: Optional[str] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None - is_current: bool = False - is_available: bool = True - description: Optional[str] = None - - -class MiniAppSubscriptionDeviceOption(BaseModel): - value: int - label: Optional[str] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None - - -class MiniAppSubscriptionCurrentSettings(BaseModel): - servers: List[MiniAppConnectedServer] = Field(default_factory=list) - traffic_limit_gb: Optional[int] = None - traffic_limit_label: Optional[str] = None - device_limit: int = 0 - - -class MiniAppSubscriptionServersSettings(BaseModel): - available: List[MiniAppSubscriptionServerOption] = Field(default_factory=list) - min: int = 0 - max: int = 0 - can_update: bool = True - hint: Optional[str] = None - - -class MiniAppSubscriptionTrafficSettings(BaseModel): - options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list) - can_update: bool = True - current_value: Optional[int] = None - - -class MiniAppSubscriptionDevicesSettings(BaseModel): - options: List[MiniAppSubscriptionDeviceOption] = Field(default_factory=list) - can_update: bool = True - min: int = 0 - max: int = 0 - step: int = 1 - current: int = 0 - price_kopeks: Optional[int] = None - price_label: Optional[str] = None - - -class MiniAppSubscriptionBillingContext(BaseModel): - months_remaining: int = 1 - period_hint_days: Optional[int] = None - renews_at: Optional[datetime] = None - - -class MiniAppSubscriptionSettings(BaseModel): - subscription_id: int - currency: str = "RUB" - current: MiniAppSubscriptionCurrentSettings - servers: MiniAppSubscriptionServersSettings - traffic: MiniAppSubscriptionTrafficSettings - devices: MiniAppSubscriptionDevicesSettings - billing: Optional[MiniAppSubscriptionBillingContext] = None - - -class MiniAppSubscriptionSettingsResponse(BaseModel): - success: bool = True - settings: MiniAppSubscriptionSettings - - -class MiniAppSubscriptionSettingsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _populate_aliases(cls, values: Any) -> Any: - if isinstance(values, dict): - if "subscriptionId" in values and "subscription_id" not in values: - values["subscription_id"] = values["subscriptionId"] - return values - - -class MiniAppSubscriptionServersUpdateRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - servers: Optional[List[str]] = None - squads: Optional[List[str]] = None - server_uuids: Optional[List[str]] = None - squad_uuids: Optional[List[str]] = None - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _populate_aliases(cls, values: Any) -> Any: - if isinstance(values, dict): - alias_map = { - "subscriptionId": "subscription_id", - "serverUuids": "server_uuids", - "squadUuids": "squad_uuids", - } - for alias, target in alias_map.items(): - if alias in values and target not in values: - values[target] = values[alias] - return values - - -class MiniAppSubscriptionTrafficUpdateRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - traffic: Optional[int] = None - traffic_gb: Optional[int] = None - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _populate_aliases(cls, values: Any) -> Any: - if isinstance(values, dict): - alias_map = { - "subscriptionId": "subscription_id", - "trafficGb": "traffic_gb", - } - for alias, target in alias_map.items(): - if alias in values and target not in values: - values[target] = values[alias] - return values - - -class MiniAppSubscriptionDevicesUpdateRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - devices: Optional[int] = None - device_limit: Optional[int] = None - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _populate_aliases(cls, values: Any) -> Any: - if isinstance(values, dict): - alias_map = { - "subscriptionId": "subscription_id", - "deviceLimit": "device_limit", - } - for alias, target in alias_map.items(): - if alias in values and target not in values: - values[target] = values[alias] - return values - - -class MiniAppSubscriptionUpdateResponse(BaseModel): - success: bool = True - message: Optional[str] = None - diff --git a/miniapp/index.html b/miniapp/index.html index d8fdb3c8..acbd526b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -4014,24 +4014,6 @@ 'subscription_settings.error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram.', 'subscription_settings.error.validation': 'Please review your selection and try again.', 'subscription_settings.pending_action': 'Saving…', - 'subscription_settings.confirm.title.charge': 'Confirm payment', - 'subscription_settings.confirm.title.free': 'Confirm changes', - 'subscription_settings.confirm.action.pay': 'Pay', - 'subscription_settings.confirm.action.apply': 'Apply', - 'subscription_settings.confirm.action.cancel': 'Cancel', - 'subscription_settings.confirm.total.charge': 'You will be charged {amount}.', - 'subscription_settings.confirm.total.charge_period': 'You will be charged {amount} for {period}.', - 'subscription_settings.confirm.total.free': 'No charges will be made.', - 'subscription_settings.confirm.servers.add': 'The following servers will be connected:', - 'subscription_settings.confirm.servers.remove': 'The following servers will be disconnected without refunds:', - 'subscription_settings.confirm.servers.entry_paid': '{name} — {amount}', - 'subscription_settings.confirm.servers.entry_free': '{name} — included', - 'subscription_settings.confirm.traffic.increase': 'Traffic limit will change to {value}.', - 'subscription_settings.confirm.traffic.decrease': 'Traffic limit will change to {value}. No charges apply.', - 'subscription_settings.confirm.devices.increase': 'Device limit will change to {value}.', - 'subscription_settings.confirm.devices.decrease': 'Device limit will change to {value}. No charges apply.', - 'subscription_settings.confirm.months.one': '{count} month', - 'subscription_settings.confirm.months.other': '{count} months', 'promo_code.title': 'Activate promo code', 'promo_code.subtitle': 'Enter a promo code to unlock rewards instantly.', 'promo_code.placeholder': 'Enter promo code', @@ -4295,24 +4277,6 @@ 'subscription_settings.error.unauthorized': 'Не удалось пройти авторизацию. Откройте мини-приложение заново из Telegram.', 'subscription_settings.error.validation': 'Проверьте выбранные параметры и попробуйте ещё раз.', 'subscription_settings.pending_action': 'Сохраняем…', - 'subscription_settings.confirm.title.charge': 'Подтвердите оплату', - 'subscription_settings.confirm.title.free': 'Подтвердите изменения', - 'subscription_settings.confirm.action.pay': 'Оплатить', - 'subscription_settings.confirm.action.apply': 'Применить', - 'subscription_settings.confirm.action.cancel': 'Отменить', - 'subscription_settings.confirm.total.charge': 'Будет списано {amount}.', - 'subscription_settings.confirm.total.charge_period': 'Будет списано {amount} за {period}.', - 'subscription_settings.confirm.total.free': 'Списаний не будет.', - 'subscription_settings.confirm.servers.add': 'Будут подключены серверы:', - 'subscription_settings.confirm.servers.remove': 'Будут отключены серверы без возврата средств:', - 'subscription_settings.confirm.servers.entry_paid': '{name} — {amount}', - 'subscription_settings.confirm.servers.entry_free': '{name} — включено', - 'subscription_settings.confirm.traffic.increase': 'Лимит трафика изменится на {value}.', - 'subscription_settings.confirm.traffic.decrease': 'Лимит трафика изменится на {value}. Дополнительная оплата не требуется.', - 'subscription_settings.confirm.devices.increase': 'Лимит устройств изменится на {value}.', - 'subscription_settings.confirm.devices.decrease': 'Лимит устройств изменится на {value}. Дополнительная оплата не требуется.', - 'subscription_settings.confirm.months.one': '{count} месяц', - 'subscription_settings.confirm.months.other': '{count} месяцев', 'promo_code.title': 'Активировать промокод', 'promo_code.subtitle': 'Введите промокод и сразу получите бонусы.', 'promo_code.placeholder': 'Введите промокод', @@ -6826,361 +6790,6 @@ return formatCurrency(normalized / 100, currencyCode); } - function formatDeviceCountLabel(count) { - const normalized = coercePositiveInt(count, null); - if (normalized === null) { - return ''; - } - if (normalized <= 0) { - return t('subscription_settings.devices.unlimited'); - } - const key = normalized === 1 - ? 'subscription_settings.devices.value_one' - : 'subscription_settings.devices.value'; - const template = t(key); - if (!template || template === key) { - return String(normalized); - } - return template.replace('{count}', String(normalized)); - } - - function formatMonthsLabel(months) { - const normalized = coercePositiveInt(months, null); - if (!normalized) { - return ''; - } - - const language = (preferredLanguage || '').toLowerCase(); - if (language.startsWith('ru')) { - const mod10 = normalized % 10; - const mod100 = normalized % 100; - let word = 'месяцев'; - if (mod10 === 1 && mod100 !== 11) { - word = 'месяц'; - } else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { - word = 'месяца'; - } - return `${normalized} ${word}`; - } - - const key = normalized === 1 - ? 'subscription_settings.confirm.months.one' - : 'subscription_settings.confirm.months.other'; - const template = t(key); - if (!template || template === key) { - return normalized === 1 - ? `${normalized} month` - : `${normalized} months`; - } - return template.replace('{count}', String(normalized)); - } - - async function showConfirmationPopup({ title, message, confirmText, cancelText, destructive = false } = {}) { - const fallbackCancel = cancelText || (() => { - const cancelValue = t('subscription_settings.confirm.action.cancel'); - return cancelValue && cancelValue !== 'subscription_settings.confirm.action.cancel' - ? cancelValue - : 'Cancel'; - })(); - const fallbackConfirm = confirmText || 'OK'; - const popupMessage = message && message.trim().length ? message : ''; - if (typeof tg.showPopup === 'function') { - try { - const result = await tg.showPopup({ - title: title || undefined, - message: popupMessage || ' ', - buttons: [ - { id: 'cancel', type: 'cancel', text: fallbackCancel }, - { id: 'confirm', type: destructive ? 'destructive' : 'default', text: fallbackConfirm }, - ], - }); - return result?.button_id === 'confirm'; - } catch (error) { - console.error('Failed to show confirmation popup', error); - } - } - - const titlePart = title ? `${title}\n\n` : ''; - return window.confirm(`${titlePart}${popupMessage || ''}`); - } - - async function confirmSubscriptionSettingsChange({ - lines = [], - chargeAmount = 0, - currency = 'RUB', - months = 0, - addTotalLine = true, - destructive = false, - } = {}) { - const normalizedLines = Array.isArray(lines) - ? lines.filter(line => typeof line === 'string' && line.trim().length) - : []; - const hasCharge = typeof chargeAmount === 'number' && chargeAmount > 0; - const normalizedMonths = coercePositiveInt(months, null); - const effectiveMonths = hasCharge ? (normalizedMonths || 1) : (normalizedMonths || 0); - - if (addTotalLine) { - if (hasCharge) { - const amountLabel = formatPriceFromKopeks(chargeAmount, currency); - const periodLabel = effectiveMonths ? formatMonthsLabel(effectiveMonths) : ''; - const templateKey = periodLabel - ? 'subscription_settings.confirm.total.charge_period' - : 'subscription_settings.confirm.total.charge'; - const template = t(templateKey); - let line; - if (template && template !== templateKey) { - line = template - .replace('{amount}', amountLabel) - .replace('{period}', periodLabel || ''); - } else if (periodLabel) { - line = `You will be charged ${amountLabel} for ${periodLabel}.`; - } else { - line = `You will be charged ${amountLabel}.`; - } - normalizedLines.push(line); - } else { - const template = t('subscription_settings.confirm.total.free'); - normalizedLines.push( - template && template !== 'subscription_settings.confirm.total.free' - ? template - : 'No charges will be made.' - ); - } - } - - const titleKey = hasCharge - ? 'subscription_settings.confirm.title.charge' - : 'subscription_settings.confirm.title.free'; - const titleValue = t(titleKey); - const title = titleValue && titleValue !== titleKey - ? titleValue - : (hasCharge ? 'Confirm payment' : 'Confirm changes'); - - const confirmKey = hasCharge - ? 'subscription_settings.confirm.action.pay' - : 'subscription_settings.confirm.action.apply'; - const confirmValue = t(confirmKey); - const confirmText = confirmValue && confirmValue !== confirmKey - ? confirmValue - : (hasCharge ? 'Pay' : 'Apply'); - - const cancelValue = t('subscription_settings.confirm.action.cancel'); - const cancelText = cancelValue && cancelValue !== 'subscription_settings.confirm.action.cancel' - ? cancelValue - : 'Cancel'; - - const message = normalizedLines.join('\n\n').trim(); - const fallbackMessage = hasCharge - ? `You will be charged ${formatPriceFromKopeks(chargeAmount, currency)}.` - : 'No charges will be made.'; - - return showConfirmationPopup({ - title, - message: message || fallbackMessage, - confirmText, - cancelText, - destructive, - }); - } - - async function confirmSubscriptionServersUpdate(data, { added = [], removed = [] } = {}) { - const currency = data?.currency || userData?.balance_currency || 'RUB'; - const months = coercePositiveInt( - data?.billing?.monthsRemaining - ?? data?.billing?.months_remaining - ?? data?.billing?.months, - null, - ) || 1; - - const available = ensureArray(data?.servers?.available); - const currentServers = ensureArray(data?.current?.servers); - const findOption = uuid => available.find(option => option?.uuid === uuid) || null; - const findName = uuid => { - const option = findOption(uuid); - if (option?.name) { - return option.name; - } - const current = currentServers.find(server => server?.uuid === uuid); - if (current?.name) { - return current.name; - } - return uuid; - }; - - let totalCharge = 0; - const lines = []; - - if (added.length) { - const headerTemplate = t('subscription_settings.confirm.servers.add'); - lines.push( - headerTemplate && headerTemplate !== 'subscription_settings.confirm.servers.add' - ? headerTemplate - : 'The following servers will be connected:' - ); - - const details = []; - for (const uuid of added) { - const option = findOption(uuid); - const name = findName(uuid); - const monthlyPrice = coercePositiveInt(option?.priceKopeks, 0) || 0; - const charge = monthlyPrice * months; - totalCharge += charge; - if (monthlyPrice > 0) { - const template = t('subscription_settings.confirm.servers.entry_paid'); - const formatted = formatPriceFromKopeks(charge, currency); - details.push( - template && template !== 'subscription_settings.confirm.servers.entry_paid' - ? template.replace('{name}', name).replace('{amount}', formatted) - : `${name} — ${formatted}` - ); - } else { - const template = t('subscription_settings.confirm.servers.entry_free'); - const includedLabel = t('subscription_settings.price.included'); - const label = includedLabel && includedLabel !== 'subscription_settings.price.included' - ? includedLabel - : 'Included'; - details.push( - template && template !== 'subscription_settings.confirm.servers.entry_free' - ? template.replace('{name}', name).replace('{amount}', label) - : `${name} — ${label}` - ); - } - } - - if (details.length) { - lines.push(details.join('\n')); - } - } - - if (removed.length) { - const names = removed.map(findName).filter(Boolean); - if (names.length) { - const template = t('subscription_settings.confirm.servers.remove'); - lines.push( - template && template !== 'subscription_settings.confirm.servers.remove' - ? template.replace('{list}', names.join(', ')) - : `The following servers will be disconnected without refunds: ${names.join(', ')}` - ); - } - } - - return confirmSubscriptionSettingsChange({ - lines, - chargeAmount: totalCharge, - currency, - months, - destructive: removed.length > 0 && totalCharge === 0, - }); - } - - async function confirmSubscriptionTrafficUpdate(data, currentValue, nextValue) { - const currency = data?.currency || userData?.balance_currency || 'RUB'; - const months = coercePositiveInt( - data?.billing?.monthsRemaining - ?? data?.billing?.months_remaining - ?? data?.billing?.months, - null, - ) || 1; - - const options = ensureArray(data?.traffic?.options); - const findOption = value => options.find(option => Number(option?.value) === Number(value)) || null; - const currentOption = findOption(currentValue); - const nextOption = findOption(nextValue); - - const currentPrice = coercePositiveInt(currentOption?.priceKopeks, 0) || 0; - const nextPrice = coercePositiveInt(nextOption?.priceKopeks, 0) || 0; - const priceDiffPerMonth = nextPrice - currentPrice; - const chargeAmount = priceDiffPerMonth > 0 ? priceDiffPerMonth * months : 0; - - const valueLabel = (nextOption?.label && nextOption.label.trim().length) - ? nextOption.label - : formatTrafficLimit(nextValue); - - const lines = []; - if (priceDiffPerMonth > 0) { - const template = t('subscription_settings.confirm.traffic.increase'); - lines.push( - template && template !== 'subscription_settings.confirm.traffic.increase' - ? template.replace('{value}', valueLabel) - : `Traffic limit will change to ${valueLabel}.` - ); - return confirmSubscriptionSettingsChange({ - lines, - chargeAmount, - currency, - months, - }); - } - - const decreaseTemplate = t('subscription_settings.confirm.traffic.decrease'); - lines.push( - decreaseTemplate && decreaseTemplate !== 'subscription_settings.confirm.traffic.decrease' - ? decreaseTemplate.replace('{value}', valueLabel) - : `Traffic limit will change to ${valueLabel}. No charges apply.` - ); - - return confirmSubscriptionSettingsChange({ - lines, - chargeAmount: 0, - currency, - months, - addTotalLine: false, - }); - } - - async function confirmSubscriptionDevicesUpdate(data, currentValue, nextValue) { - const currency = data?.currency || userData?.balance_currency || 'RUB'; - const months = coercePositiveInt( - data?.billing?.monthsRemaining - ?? data?.billing?.months_remaining - ?? data?.billing?.months, - null, - ) || 1; - - const options = ensureArray(data?.devices?.options); - const findOption = value => options.find(option => coercePositiveInt(option?.value, null) === value) || null; - const currentOption = findOption(currentValue); - const nextOption = findOption(nextValue); - - const currentPrice = coercePositiveInt(currentOption?.priceKopeks, 0) || 0; - const nextPrice = coercePositiveInt(nextOption?.priceKopeks, 0) || 0; - const priceDiffPerMonth = nextPrice - currentPrice; - const chargeAmount = priceDiffPerMonth > 0 ? priceDiffPerMonth * months : 0; - - const valueLabel = formatDeviceCountLabel(nextValue); - - const lines = []; - if (priceDiffPerMonth > 0) { - const template = t('subscription_settings.confirm.devices.increase'); - lines.push( - template && template !== 'subscription_settings.confirm.devices.increase' - ? template.replace('{value}', valueLabel) - : `Device limit will change to ${valueLabel}.` - ); - return confirmSubscriptionSettingsChange({ - lines, - chargeAmount, - currency, - months, - }); - } - - const decreaseTemplate = t('subscription_settings.confirm.devices.decrease'); - lines.push( - decreaseTemplate && decreaseTemplate !== 'subscription_settings.confirm.devices.decrease' - ? decreaseTemplate.replace('{value}', valueLabel) - : `Device limit will change to ${valueLabel}. No charges apply.` - ); - - return confirmSubscriptionSettingsChange({ - lines, - chargeAmount: 0, - currency, - months, - addTotalLine: false, - }); - } - function formatDate(value) { if (!value) { return '—'; @@ -9387,8 +8996,7 @@ description: option.description || null, }; }) - .filter(option => option.value !== null || option.label) - .filter(option => option.isAvailable !== false || option.isCurrent); + .filter(option => option.value !== null || option.label); const currentTrafficLimit = coerceNumber( currentInfo.traffic_limit_gb @@ -9445,24 +9053,6 @@ ?? userData?.subscriptionId ?? null, currency: (root.currency || payload.currency || userData?.balance_currency || 'RUB').toString().toUpperCase(), - billing: { - monthsRemaining: coercePositiveInt( - root.billing?.months_remaining - ?? root.billing?.monthsRemaining - ?? root.billing?.months, - null - ), - periodDays: coercePositiveInt( - root.billing?.period_hint_days - ?? root.billing?.periodHintDays - ?? null, - null - ), - renewsAt: root.billing?.renews_at - ?? root.billing?.renewsAt - ?? root.billing?.renewal - ?? null, - }, current: { servers: normalizedCurrentServers, serverSet, @@ -10182,13 +9772,6 @@ return; } - const added = selected.filter(uuid => !currentSet.has(uuid)); - const removed = Array.from(currentSet).filter(uuid => !selected.includes(uuid)); - const proceedServers = await confirmSubscriptionServersUpdate(data, { added, removed }); - if (!proceedServers) { - return; - } - const payload = { initData, squads: selected, @@ -10254,11 +9837,6 @@ return; } - const proceedTraffic = await confirmSubscriptionTrafficUpdate(data, currentValue, selected); - if (!proceedTraffic) { - return; - } - const payload = { initData, traffic: selected, @@ -10330,11 +9908,6 @@ return; } - const proceedDevices = await confirmSubscriptionDevicesUpdate(data, current, selected); - if (!proceedDevices) { - return; - } - const payload = { initData, devices: selected,