From 82b2127e5532cece303dba0231fdab5225053195 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 07:18:41 +0300 Subject: [PATCH] Revert "Require active subscription and confirm add-on charges" --- app/webapi/routes/miniapp.py | 887 +--------------------------------- app/webapi/schemas/miniapp.py | 172 +------ miniapp/index.html | 324 ------------- 3 files changed, 4 insertions(+), 1379 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index a2606549..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, - MiniAppSubscriptionBillingInfo, - MiniAppSubscriptionServersUpdateRequest, - MiniAppSubscriptionTrafficUpdateRequest, - MiniAppSubscriptionDevicesUpdateRequest, - MiniAppSubscriptionUpdateResponse, ) @@ -2703,855 +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 subscription.is_active: - raise HTTPException( - status.HTTP_403_FORBIDDEN, - detail={ - "code": "subscription_inactive", - "message": "Subscription is not active. Please renew your plan to manage add-ons.", - }, - ) - - 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: - now = datetime.utcnow() - months_to_charge = max(1, get_remaining_months(subscription.end_date)) - days_left = max(0, (subscription.end_date - now).days) - - period_hint_days = _get_period_hint_from_subscription(subscription) - 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(): - if not bool(package.get("enabled", True)): - 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=bool(package.get("enabled", 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=MiniAppSubscriptionBillingInfo( - months_to_charge=months_to_charge, - days_left=days_left, - period_end=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 fbeb39ea..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 MiniAppSubscriptionBillingInfo(BaseModel): - months_to_charge: int = 1 - days_left: int = 0 - period_end: Optional[datetime] = 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 MiniAppSubscriptionSettings(BaseModel): - subscription_id: int - currency: str = "RUB" - current: MiniAppSubscriptionCurrentSettings - servers: MiniAppSubscriptionServersSettings - traffic: MiniAppSubscriptionTrafficSettings - devices: MiniAppSubscriptionDevicesSettings - billing: Optional[MiniAppSubscriptionBillingInfo] = 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 fd200afc..acbd526b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -4014,18 +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': 'Confirm changes', - 'subscription_settings.confirm.confirm': 'Confirm', - 'subscription_settings.confirm.cancel': 'Cancel', - 'subscription_settings.confirm.total_charge': 'We will charge {amount}.', - 'subscription_settings.confirm.no_charge': 'No additional charges will be applied.', - 'subscription_settings.confirm.server_add_paid': 'Server “{name}” will be connected for {amount}.', - 'subscription_settings.confirm.server_add_free': 'Server “{name}” will be connected without extra charge.', - 'subscription_settings.confirm.server_remove': 'Server “{name}” will be disconnected without a refund.', - 'subscription_settings.confirm.traffic_increase': 'Traffic limit will change from {from} to {to}. We will charge {amount}.', - 'subscription_settings.confirm.traffic_decrease': 'Traffic limit will change from {from} to {to}. This change is free.', - 'subscription_settings.confirm.devices_increase': 'Device limit will change from {from} to {to}. We will charge {amount}.', - 'subscription_settings.confirm.devices_decrease': 'Device limit will change from {from} to {to}. This change is free.', 'promo_code.title': 'Activate promo code', 'promo_code.subtitle': 'Enter a promo code to unlock rewards instantly.', 'promo_code.placeholder': 'Enter promo code', @@ -4289,18 +4277,6 @@ 'subscription_settings.error.unauthorized': 'Не удалось пройти авторизацию. Откройте мини-приложение заново из Telegram.', 'subscription_settings.error.validation': 'Проверьте выбранные параметры и попробуйте ещё раз.', 'subscription_settings.pending_action': 'Сохраняем…', - 'subscription_settings.confirm.title': 'Подтвердите изменения', - 'subscription_settings.confirm.confirm': 'Подтвердить', - 'subscription_settings.confirm.cancel': 'Отмена', - 'subscription_settings.confirm.total_charge': 'Будет списано {amount}.', - 'subscription_settings.confirm.no_charge': 'Дополнительного списания не потребуется.', - 'subscription_settings.confirm.server_add_paid': 'Сервер «{name}» будет подключён за {amount}.', - 'subscription_settings.confirm.server_add_free': 'Сервер «{name}» будет подключён без дополнительной оплаты.', - 'subscription_settings.confirm.server_remove': 'Сервер «{name}» будет отключён без возврата средств.', - 'subscription_settings.confirm.traffic_increase': 'Лимит трафика изменится с {from} до {to}. Будет списано {amount}.', - 'subscription_settings.confirm.traffic_decrease': 'Лимит трафика изменится с {from} до {to}. Это изменение бесплатное.', - 'subscription_settings.confirm.devices_increase': 'Лимит устройств изменится с {from} до {to}. Будет списано {amount}.', - 'subscription_settings.confirm.devices_decrease': 'Лимит устройств изменится с {from} до {to}. Это изменение бесплатное.', 'promo_code.title': 'Активировать промокод', 'promo_code.subtitle': 'Введите промокод и сразу получите бонусы.', 'promo_code.placeholder': 'Введите промокод', @@ -6783,20 +6759,6 @@ return `${numeric.toFixed(0)} ${t('units.gb')}`; } - function formatDeviceLimitLabel(limit) { - const numeric = coercePositiveInt(limit, null); - if (numeric === null) { - return t('values.not_available'); - } - if (numeric <= 0) { - const unlimited = t('subscription_settings.devices.unlimited'); - return typeof unlimited === 'string' && unlimited !== 'subscription_settings.devices.unlimited' - ? unlimited - : 'Unlimited'; - } - return String(numeric); - } - function formatCurrency(value, currency = 'RUB') { const numeric = typeof value === 'number' ? value : Number.parseFloat(value ?? '0'); if (!Number.isFinite(numeric)) { @@ -6828,60 +6790,6 @@ return formatCurrency(normalized / 100, currencyCode); } - function applyTemplate(template, replacements = {}) { - if (typeof template !== 'string') { - return ''; - } - let output = template; - Object.entries(replacements || {}).forEach(([key, value]) => { - const pattern = new RegExp(`\\{${key}\\}`, 'g'); - output = output.replace(pattern, String(value ?? '')); - }); - return output; - } - - function resolveSubscriptionBillingMonths(data) { - const months = coercePositiveInt( - data?.billing?.monthsToCharge - ?? data?.billing?.months_to_charge - ?? data?.billingMonths - ?? data?.monthsToCharge - ?? null, - null, - ); - if (Number.isFinite(months) && months > 0) { - return months; - } - - const periodEndRaw = data?.billing?.periodEnd - ?? data?.billing?.period_end - ?? data?.current?.periodEnd - ?? data?.current?.period_end - ?? userData?.user?.expires_at - ?? userData?.user?.expiresAt - ?? userData?.subscription_expires_at - ?? userData?.subscriptionExpiresAt - ?? null; - const periodEnd = parseDate(periodEndRaw); - if (!periodEnd) { - return 1; - } - - const now = new Date(); - const diffMs = periodEnd.getTime() - now.getTime(); - if (!Number.isFinite(diffMs) || diffMs <= 0) { - return 1; - } - - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); - if (!Number.isFinite(diffDays) || diffDays <= 0) { - return 1; - } - - const approxMonths = Math.round(diffDays / 30); - return Math.max(1, approxMonths || 1); - } - function formatDate(value) { if (!value) { return '—'; @@ -8210,58 +8118,6 @@ return 'Device'; } - function showSubscriptionChangeConfirm(messageLines, options = {}) { - const { titleKey = 'subscription_settings.confirm.title', fallbackTitle = 'Confirm changes' } = options || {}; - const linesArray = Array.isArray(messageLines) ? messageLines.filter(Boolean) : [messageLines]; - const normalizedMessage = linesArray - .filter(line => typeof line === 'string' && line.trim().length) - .join('\n'); - - if (!normalizedMessage) { - return Promise.resolve(true); - } - - const titleTranslation = t(titleKey); - const confirmTranslation = t('subscription_settings.confirm.confirm'); - const cancelTranslation = t('subscription_settings.confirm.cancel'); - - const title = typeof titleTranslation === 'string' && titleTranslation !== titleKey - ? titleTranslation - : fallbackTitle; - const confirmLabel = typeof confirmTranslation === 'string' && confirmTranslation !== 'subscription_settings.confirm.confirm' - ? confirmTranslation - : 'Confirm'; - const cancelLabel = typeof cancelTranslation === 'string' && cancelTranslation !== 'subscription_settings.confirm.cancel' - ? cancelTranslation - : 'Cancel'; - - return new Promise(resolve => { - if (typeof tg.showPopup === 'function') { - tg.showPopup({ - title, - message: normalizedMessage, - buttons: [ - { - id: 'confirm', - type: 'default', - text: confirmLabel, - }, - { - id: 'cancel', - type: 'cancel', - text: cancelLabel, - }, - ], - }, buttonId => { - resolve(buttonId === 'confirm'); - }); - return; - } - - resolve(window.confirm(normalizedMessage)); - }); - } - function confirmDeviceRemoval(deviceName) { const label = resolveDeviceLabel(deviceName); const template = t('devices.remove_confirm.message'); @@ -9188,30 +9044,6 @@ 0 ); - const billingInfo = root.billing || root.billing_info || {}; - const billingMonths = coercePositiveInt( - billingInfo.months_to_charge - ?? billingInfo.monthsToCharge - ?? root.months_to_charge - ?? root.monthsToCharge - ?? null, - null, - ); - const billingDaysLeft = coercePositiveInt( - billingInfo.days_left - ?? billingInfo.daysLeft - ?? root.days_left - ?? root.daysLeft - ?? null, - null, - ); - const billingPeriodEndRaw = billingInfo.period_end - ?? billingInfo.periodEnd - ?? root.period_end - ?? root.periodEnd - ?? null; - const billingPeriodEnd = parseDate(billingPeriodEndRaw); - return { raw: payload, subscriptionId: root.subscription_id @@ -9265,12 +9097,6 @@ step: coercePositiveInt(devicesInfo.step ?? devicesInfo.increment ?? 1, 1) || 1, current: currentDeviceLimit, }, - billing: { - monthsToCharge: Number.isFinite(billingMonths) && billingMonths > 0 ? billingMonths : null, - daysLeft: Number.isFinite(billingDaysLeft) && billingDaysLeft >= 0 ? billingDaysLeft : null, - periodEnd: billingPeriodEnd, - periodEndRaw: billingPeriodEndRaw || null, - }, }; } @@ -9955,74 +9781,6 @@ subscription_id: data.subscriptionId || userData?.subscription_id || userData?.subscriptionId || null, }; - const billingMonths = Math.max(1, resolveSubscriptionBillingMonths(data)); - const currencyCode = data.currency || userData?.balance_currency || 'RUB'; - const availableServers = ensureArray(data.servers?.available); - const availableMap = new Map(availableServers.map(option => [option.uuid, option])); - const currentServersList = ensureArray(data.current?.servers); - const added = selected.filter(uuid => !currentSet.has(uuid)); - const removed = Array.from(currentSet).filter(uuid => !selectionSet.has(uuid)); - const confirmationLines = []; - let totalCharge = 0; - - added.forEach(uuid => { - const option = availableMap.get(uuid) || {}; - const name = option.name || uuid; - const pricePerMonth = coercePositiveInt(option.priceKopeks ?? option.price_kopeks, 0); - const total = pricePerMonth * billingMonths; - if (total > 0) { - totalCharge += total; - const template = t('subscription_settings.confirm.server_add_paid'); - const line = applyTemplate( - typeof template === 'string' ? template : 'Server “{name}” will be connected for {amount}.', - { - name, - amount: formatPriceFromKopeks(total, currencyCode), - }, - ); - confirmationLines.push(line); - } else { - const template = t('subscription_settings.confirm.server_add_free'); - const line = applyTemplate( - typeof template === 'string' ? template : 'Server “{name}” will be connected without extra charge.', - { name }, - ); - confirmationLines.push(line); - } - }); - - removed.forEach(uuid => { - const option = availableMap.get(uuid) || currentServersList.find(server => server.uuid === uuid) || {}; - const name = option.name || uuid; - const template = t('subscription_settings.confirm.server_remove'); - const line = applyTemplate( - typeof template === 'string' ? template : 'Server “{name}” will be disconnected without a refund.', - { name }, - ); - confirmationLines.push(line); - }); - - if (totalCharge > 0) { - const totalTemplate = t('subscription_settings.confirm.total_charge'); - const totalLine = applyTemplate( - typeof totalTemplate === 'string' ? totalTemplate : 'We will charge {amount}.', - { amount: formatPriceFromKopeks(totalCharge, currencyCode) }, - ); - confirmationLines.push(totalLine); - } else if ((added.length || removed.length) && !confirmationLines.length) { - const freeTemplate = t('subscription_settings.confirm.no_charge'); - confirmationLines.push( - typeof freeTemplate === 'string' && freeTemplate !== 'subscription_settings.confirm.no_charge' - ? freeTemplate - : 'No additional charges will be applied.', - ); - } - - const confirmed = await showSubscriptionChangeConfirm(confirmationLines); - if (!confirmed) { - return; - } - subscriptionSettingsAction = 'servers'; setSubscriptionSettingsActionLoading('servers', true); @@ -10087,48 +9845,6 @@ subscription_id: data.subscriptionId || userData?.subscription_id || userData?.subscriptionId || null, }; - const billingMonths = Math.max(1, resolveSubscriptionBillingMonths(data)); - const currencyCode = data.currency || userData?.balance_currency || 'RUB'; - const trafficOptions = ensureArray(data.traffic?.options); - const currentOption = trafficOptions.find(option => Number(option.value) === Number(currentValue)) || null; - const newOption = trafficOptions.find(option => Number(option.value) === Number(selected)) || null; - const currentPrice = coercePositiveInt(currentOption?.priceKopeks ?? currentOption?.price_kopeks, 0); - const newPrice = coercePositiveInt(newOption?.priceKopeks ?? newOption?.price_kopeks, 0); - const priceDiff = newPrice - currentPrice; - const totalCharge = priceDiff > 0 ? priceDiff * billingMonths : 0; - const fallbackCurrentLimit = currentValue ?? data.current?.trafficLimitGb ?? data.current?.trafficLimit ?? 0; - const fallbackCurrentLabel = data.current?.trafficLabel || formatTrafficLimit(fallbackCurrentLimit); - const fromLabel = currentOption?.label || fallbackCurrentLabel; - const toLabel = newOption?.label || formatTrafficLimit(selected); - const confirmationLines = []; - - if (priceDiff > 0) { - const template = t('subscription_settings.confirm.traffic_increase'); - const amountLabel = formatPriceFromKopeks(totalCharge > 0 ? totalCharge : priceDiff, currencyCode); - confirmationLines.push(applyTemplate( - typeof template === 'string' ? template : 'Traffic limit will change from {from} to {to}. We will charge {amount}.', - { from: fromLabel, to: toLabel, amount: amountLabel }, - )); - if (totalCharge > 0 && billingMonths > 1) { - const totalTemplate = t('subscription_settings.confirm.total_charge'); - confirmationLines.push(applyTemplate( - typeof totalTemplate === 'string' ? totalTemplate : 'We will charge {amount}.', - { amount: amountLabel }, - )); - } - } else { - const template = t('subscription_settings.confirm.traffic_decrease'); - confirmationLines.push(applyTemplate( - typeof template === 'string' ? template : 'Traffic limit will change from {from} to {to}. This change is free.', - { from: fromLabel, to: toLabel }, - )); - } - - const confirmed = await showSubscriptionChangeConfirm(confirmationLines); - if (!confirmed) { - return; - } - subscriptionSettingsAction = 'traffic'; setSubscriptionSettingsActionLoading('traffic', true); @@ -10200,46 +9916,6 @@ subscription_id: data.subscriptionId || userData?.subscription_id || userData?.subscriptionId || null, }; - const billingMonths = Math.max(1, resolveSubscriptionBillingMonths(data)); - const currencyCode = data.currency || userData?.balance_currency || 'RUB'; - const deviceOptions = ensureArray(data.devices?.options); - const currentOption = deviceOptions.find(option => Number(option.value) === Number(current)) || null; - const newOption = deviceOptions.find(option => Number(option.value) === Number(selected)) || null; - const currentPrice = coercePositiveInt(currentOption?.priceKopeks ?? currentOption?.price_kopeks, 0); - const newPrice = coercePositiveInt(newOption?.priceKopeks ?? newOption?.price_kopeks, 0); - const priceDiff = newPrice - currentPrice; - const totalCharge = priceDiff > 0 ? priceDiff * billingMonths : 0; - const fromLabel = formatDeviceLimitLabel(current); - const toLabel = formatDeviceLimitLabel(selected); - const confirmationLines = []; - - if (priceDiff > 0) { - const template = t('subscription_settings.confirm.devices_increase'); - const amountLabel = formatPriceFromKopeks(totalCharge > 0 ? totalCharge : priceDiff, currencyCode); - confirmationLines.push(applyTemplate( - typeof template === 'string' ? template : 'Device limit will change from {from} to {to}. We will charge {amount}.', - { from: fromLabel, to: toLabel, amount: amountLabel }, - )); - if (totalCharge > 0 && billingMonths > 1) { - const totalTemplate = t('subscription_settings.confirm.total_charge'); - confirmationLines.push(applyTemplate( - typeof totalTemplate === 'string' ? totalTemplate : 'We will charge {amount}.', - { amount: amountLabel }, - )); - } - } else { - const template = t('subscription_settings.confirm.devices_decrease'); - confirmationLines.push(applyTemplate( - typeof template === 'string' ? template : 'Device limit will change from {from} to {to}. This change is free.', - { from: fromLabel, to: toLabel }, - )); - } - - const confirmed = await showSubscriptionChangeConfirm(confirmationLines); - if (!confirmed) { - return; - } - subscriptionSettingsAction = 'devices'; setSubscriptionSettingsActionLoading('devices', true);