diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 5ec70d63..3f559697 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,20 @@ from ..schemas.miniapp import ( MiniAppSubscriptionResponse, MiniAppSubscriptionUser, MiniAppTransaction, + MiniAppSubscriptionSettingsRequest, + MiniAppSubscriptionSettingsResponse, + MiniAppSubscriptionSettings, + MiniAppSubscriptionCurrentSettings, + MiniAppSubscriptionServersSettings, + MiniAppSubscriptionServerOption, + MiniAppSubscriptionTrafficSettings, + MiniAppSubscriptionTrafficOption, + MiniAppSubscriptionDevicesSettings, + MiniAppSubscriptionDeviceOption, + MiniAppSubscriptionServersUpdateRequest, + MiniAppSubscriptionTrafficUpdateRequest, + MiniAppSubscriptionDevicesUpdateRequest, + MiniAppSubscriptionUpdateResponse, ) @@ -2674,3 +2702,834 @@ 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", + }, + ) + + return subscription + + +async def _prepare_server_catalog( + db: AsyncSession, + user: User, + subscription: Subscription, + discount_percent: int, +) -> Tuple[ + List[MiniAppConnectedServer], + List[MiniAppSubscriptionServerOption], + Dict[str, Dict[str, Any]], +]: + available_servers = await get_available_server_squads( + db, + promo_group_id=getattr(user, "promo_group_id", None), + ) + available_by_uuid = {server.squad_uuid: server for server in available_servers} + + current_squads = list(subscription.connected_squads or []) + catalog: Dict[str, Dict[str, Any]] = {} + ordered_uuids: List[str] = [] + + def _register_server(server: Optional[Any], *, is_connected: bool = False) -> None: + if server is None: + return + + uuid = server.squad_uuid + discounted_per_month, discount_per_month = apply_percentage_discount( + int(getattr(server, "price_kopeks", 0) or 0), + discount_percent, + ) + available_for_new = bool(getattr(server, "is_available", True) and not server.is_full) + + entry = catalog.get(uuid) + if entry: + entry.update( + { + "name": getattr(server, "display_name", uuid), + "server_id": getattr(server, "id", None), + "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), + "discounted_per_month": discounted_per_month, + "discount_per_month": discount_per_month, + "available_for_new": available_for_new, + } + ) + entry["is_connected"] = entry["is_connected"] or is_connected + return + + catalog[uuid] = { + "uuid": uuid, + "name": getattr(server, "display_name", uuid), + "server_id": getattr(server, "id", None), + "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), + "discounted_per_month": discounted_per_month, + "discount_per_month": discount_per_month, + "available_for_new": available_for_new, + "is_connected": is_connected, + } + ordered_uuids.append(uuid) + + def _register_placeholder(uuid: str, *, is_connected: bool = False) -> None: + if uuid in catalog: + catalog[uuid]["is_connected"] = catalog[uuid]["is_connected"] or is_connected + return + + catalog[uuid] = { + "uuid": uuid, + "name": uuid, + "server_id": None, + "price_per_month": 0, + "discounted_per_month": 0, + "discount_per_month": 0, + "available_for_new": False, + "is_connected": is_connected, + } + ordered_uuids.append(uuid) + + current_set = set(current_squads) + + for uuid in current_squads: + server = available_by_uuid.get(uuid) + if server: + _register_server(server, is_connected=True) + continue + + server = await get_server_squad_by_uuid(db, uuid) + if server: + _register_server(server, is_connected=True) + else: + _register_placeholder(uuid, is_connected=True) + + for server in available_servers: + _register_server(server, is_connected=server.squad_uuid in current_set) + + current_servers = [ + MiniAppConnectedServer( + uuid=uuid, + name=catalog.get(uuid, {}).get("name", uuid), + ) + for uuid in current_squads + ] + + server_options: List[MiniAppSubscriptionServerOption] = [] + discount_value = discount_percent if discount_percent > 0 else None + + for uuid in ordered_uuids: + entry = catalog[uuid] + available_for_new = bool(entry.get("available_for_new", False)) + is_connected = bool(entry.get("is_connected", False)) + option_available = available_for_new or is_connected + server_options.append( + MiniAppSubscriptionServerOption( + uuid=uuid, + name=entry.get("name", uuid), + price_kopeks=int(entry.get("discounted_per_month", 0)), + price_label=None, + discount_percent=discount_value, + is_connected=is_connected, + is_available=option_available, + disabled_reason=None if option_available else "Server is not available", + ) + ) + + return current_servers, server_options, catalog + + +async def _build_subscription_settings( + db: AsyncSession, + user: User, + subscription: Subscription, +) -> MiniAppSubscriptionSettings: + period_hint_days = _get_period_hint_from_subscription(subscription) + 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(): + 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, + ), + ) + + 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..a1fae7cb 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,166 @@ class MiniAppSubscriptionResponse(BaseModel): legal_documents: Optional[MiniAppLegalDocuments] = None referral: Optional[MiniAppReferralInfo] = None + +class MiniAppSubscriptionServerOption(BaseModel): + uuid: str + name: Optional[str] = None + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + discount_percent: Optional[int] = None + is_connected: bool = False + is_available: bool = True + disabled_reason: Optional[str] = None + + +class MiniAppSubscriptionTrafficOption(BaseModel): + value: Optional[int] = None + label: Optional[str] = None + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + is_current: bool = False + is_available: bool = True + description: Optional[str] = None + + +class MiniAppSubscriptionDeviceOption(BaseModel): + value: int + label: Optional[str] = None + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + + +class MiniAppSubscriptionCurrentSettings(BaseModel): + servers: List[MiniAppConnectedServer] = Field(default_factory=list) + traffic_limit_gb: Optional[int] = None + traffic_limit_label: Optional[str] = None + device_limit: int = 0 + + +class MiniAppSubscriptionServersSettings(BaseModel): + available: List[MiniAppSubscriptionServerOption] = Field(default_factory=list) + min: int = 0 + max: int = 0 + can_update: bool = True + hint: Optional[str] = None + + +class MiniAppSubscriptionTrafficSettings(BaseModel): + options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list) + can_update: bool = True + current_value: Optional[int] = None + + +class MiniAppSubscriptionDevicesSettings(BaseModel): + options: List[MiniAppSubscriptionDeviceOption] = Field(default_factory=list) + can_update: bool = True + min: int = 0 + max: int = 0 + step: int = 1 + current: int = 0 + price_kopeks: Optional[int] = None + price_label: Optional[str] = None + + +class MiniAppSubscriptionSettings(BaseModel): + subscription_id: int + currency: str = "RUB" + current: MiniAppSubscriptionCurrentSettings + servers: MiniAppSubscriptionServersSettings + traffic: MiniAppSubscriptionTrafficSettings + devices: MiniAppSubscriptionDevicesSettings + + +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 +