Merge pull request #1038 from Fr1ngg/bblv1f-bedolaga/add-subscription-settings-endpoints

Require active subscription and confirm add-on charges
This commit is contained in:
Egor
2025-10-10 07:03:46 +03:00
committed by GitHub
3 changed files with 1379 additions and 4 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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);