From c2a6dfb3b4c4e05f36ddb3526dd59fb6688963ed Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 07:03:31 +0300 Subject: [PATCH] Improve subscription settings updates --- app/webapi/routes/miniapp.py | 887 +++++++++++++++++++++++++++++++++- app/webapi/schemas/miniapp.py | 172 ++++++- miniapp/index.html | 324 +++++++++++++ 3 files changed, 1379 insertions(+), 4 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 5ec70d63..a2606549 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -24,9 +24,18 @@ from app.database.crud.discount_offer import ( from app.database.crud.promo_group import get_auto_assign_promo_groups from app.database.crud.rules import get_rules_by_language from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.server_squad import get_server_squad_by_uuid -from app.database.crud.transaction import get_user_total_spent_kopeks -from app.database.crud.user import get_user_by_telegram_id +from app.database.crud.server_squad import ( + get_available_server_squads, + get_server_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.models import ( PromoGroup, PromoOfferTemplate, @@ -59,6 +68,11 @@ 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 ( @@ -97,6 +111,21 @@ from ..schemas.miniapp import ( MiniAppSubscriptionResponse, MiniAppSubscriptionUser, MiniAppTransaction, + MiniAppSubscriptionSettingsRequest, + MiniAppSubscriptionSettingsResponse, + MiniAppSubscriptionSettings, + MiniAppSubscriptionCurrentSettings, + MiniAppSubscriptionServersSettings, + MiniAppSubscriptionServerOption, + MiniAppSubscriptionTrafficSettings, + MiniAppSubscriptionTrafficOption, + MiniAppSubscriptionDevicesSettings, + MiniAppSubscriptionDeviceOption, + MiniAppSubscriptionBillingInfo, + MiniAppSubscriptionServersUpdateRequest, + MiniAppSubscriptionTrafficUpdateRequest, + MiniAppSubscriptionDevicesUpdateRequest, + MiniAppSubscriptionUpdateResponse, ) @@ -2674,3 +2703,855 @@ 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 3f668548..fbeb39ea 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 +from pydantic import BaseModel, Field, ConfigDict, model_validator class MiniAppBranding(BaseModel): @@ -354,3 +354,173 @@ 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 acbd526b..fd200afc 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -4014,6 +4014,18 @@ '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', @@ -4277,6 +4289,18 @@ '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': 'Введите промокод', @@ -6759,6 +6783,20 @@ 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)) { @@ -6790,6 +6828,60 @@ 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 '—'; @@ -8118,6 +8210,58 @@ 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'); @@ -9044,6 +9188,30 @@ 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 @@ -9097,6 +9265,12 @@ 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, + }, }; } @@ -9781,6 +9955,74 @@ 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); @@ -9845,6 +10087,48 @@ 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); @@ -9916,6 +10200,46 @@ 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);