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