mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-04-28 16:50:08 +00:00
Merge pull request #1028 from Fr1ngg/7c9pa7-bedolaga/add-subscription-settings-endpoints
Add miniapp subscription settings management endpoints
This commit is contained in:
@@ -24,9 +24,15 @@ 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,
|
||||
)
|
||||
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,
|
||||
@@ -50,6 +56,11 @@ from app.services.promocode_service import PromoCodeService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_prorated_price,
|
||||
get_remaining_months,
|
||||
)
|
||||
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
|
||||
from app.utils.telegram_webapp import (
|
||||
TelegramWebAppAuthError,
|
||||
@@ -96,6 +107,20 @@ from ..schemas.miniapp import (
|
||||
MiniAppSubscriptionRequest,
|
||||
MiniAppSubscriptionResponse,
|
||||
MiniAppSubscriptionUser,
|
||||
MiniAppSubscriptionSettingsActionResponse,
|
||||
MiniAppSubscriptionSettingsCurrent,
|
||||
MiniAppSubscriptionSettingsData,
|
||||
MiniAppSubscriptionSettingsDevices,
|
||||
MiniAppSubscriptionSettingsDeviceOption,
|
||||
MiniAppSubscriptionSettingsRequest,
|
||||
MiniAppSubscriptionSettingsResponse,
|
||||
MiniAppSubscriptionSettingsServer,
|
||||
MiniAppSubscriptionSettingsServers,
|
||||
MiniAppSubscriptionSettingsTraffic,
|
||||
MiniAppSubscriptionSettingsTrafficOption,
|
||||
MiniAppSubscriptionServersUpdateRequest,
|
||||
MiniAppSubscriptionTrafficUpdateRequest,
|
||||
MiniAppSubscriptionDevicesUpdateRequest,
|
||||
MiniAppTransaction,
|
||||
)
|
||||
|
||||
@@ -198,6 +223,327 @@ def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]:
|
||||
return stars_amount, normalized_amount_kopeks
|
||||
|
||||
|
||||
def _get_period_hint_from_subscription(
|
||||
subscription: Optional[Subscription],
|
||||
) -> Optional[int]:
|
||||
if not subscription or not getattr(subscription, "end_date", None):
|
||||
return None
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
if months_remaining <= 0:
|
||||
return None
|
||||
|
||||
return months_remaining * 30
|
||||
|
||||
|
||||
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:
|
||||
return int(user.get_promo_discount(category, period_days_hint) or 0)
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
|
||||
def _format_total_price_label(amount_kopeks: int, months: int) -> Optional[str]:
|
||||
if amount_kopeks <= 0:
|
||||
return None
|
||||
|
||||
base_label = settings.format_price(amount_kopeks)
|
||||
if months > 1:
|
||||
return f"{base_label} (за {months} мес)"
|
||||
return base_label
|
||||
|
||||
|
||||
async def _authorize_subscription_user(
|
||||
db: AsyncSession,
|
||||
init_data: str,
|
||||
subscription_id: Optional[int] = None,
|
||||
) -> Tuple[User, Subscription]:
|
||||
if not init_data:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"message": "Authorization required"},
|
||||
)
|
||||
|
||||
try:
|
||||
webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN)
|
||||
except TelegramWebAppAuthError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"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={"message": "Invalid Telegram user payload"},
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(telegram_user["id"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Invalid Telegram user identifier"},
|
||||
) from None
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user or not user.subscription:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail={"message": "Subscription not found"},
|
||||
)
|
||||
|
||||
subscription = user.subscription
|
||||
if subscription_id and subscription.id != subscription_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail={"message": "Subscription not found"},
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={"message": "Настройки доступны только для платных подписок"},
|
||||
)
|
||||
|
||||
return user, subscription
|
||||
|
||||
|
||||
async def _build_subscription_settings_payload(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
) -> MiniAppSubscriptionSettingsData:
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
"traffic",
|
||||
period_hint_days,
|
||||
)
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
"devices",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
connected_squads: List[str] = list(subscription.connected_squads or [])
|
||||
available_servers = await get_available_server_squads(
|
||||
db,
|
||||
promo_group_id=getattr(user, "promo_group_id", None),
|
||||
)
|
||||
available_servers_map = {server.squad_uuid: server for server in available_servers}
|
||||
|
||||
connected_servers: List[MiniAppConnectedServer] = []
|
||||
for squad_uuid in connected_squads:
|
||||
server = available_servers_map.get(squad_uuid)
|
||||
if not server:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
name = getattr(server, "display_name", None) or squad_uuid
|
||||
connected_servers.append(
|
||||
MiniAppConnectedServer(
|
||||
uuid=squad_uuid,
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
months_to_charge = get_remaining_months(subscription.end_date)
|
||||
available_payload: List[MiniAppSubscriptionSettingsServer] = []
|
||||
seen_server_ids: set[str] = set()
|
||||
|
||||
for server in available_servers:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server.price_kopeks,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
price_label = _format_total_price_label(total_price, charged_months)
|
||||
|
||||
is_available = server.is_available and not server.is_full
|
||||
disabled_reason = None
|
||||
if not server.is_available:
|
||||
disabled_reason = "Server is unavailable"
|
||||
elif server.is_full:
|
||||
disabled_reason = "Server capacity reached"
|
||||
|
||||
available_payload.append(
|
||||
MiniAppSubscriptionSettingsServer(
|
||||
uuid=server.squad_uuid,
|
||||
name=server.display_name,
|
||||
price_kopeks=total_price,
|
||||
price_label=price_label,
|
||||
discount_percent=max(0, servers_discount_percent),
|
||||
is_connected=server.squad_uuid in connected_squads,
|
||||
is_available=is_available,
|
||||
disabled_reason=disabled_reason,
|
||||
)
|
||||
)
|
||||
seen_server_ids.add(server.squad_uuid)
|
||||
|
||||
for squad_uuid in connected_squads:
|
||||
if squad_uuid in seen_server_ids:
|
||||
continue
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
name = getattr(server, "display_name", None) or squad_uuid
|
||||
available_payload.append(
|
||||
MiniAppSubscriptionSettingsServer(
|
||||
uuid=squad_uuid,
|
||||
name=name,
|
||||
price_kopeks=0,
|
||||
discount_percent=max(0, servers_discount_percent),
|
||||
is_connected=True,
|
||||
is_available=False,
|
||||
disabled_reason="Server is not available",
|
||||
)
|
||||
)
|
||||
|
||||
traffic_packages = [
|
||||
package
|
||||
for package in settings.get_traffic_packages()
|
||||
if package.get("enabled", True)
|
||||
]
|
||||
current_traffic = subscription.traffic_limit_gb or 0
|
||||
current_traffic_price = settings.get_traffic_price(current_traffic)
|
||||
discounted_current_per_month, _ = apply_percentage_discount(
|
||||
current_traffic_price,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
traffic_options: List[MiniAppSubscriptionSettingsTrafficOption] = []
|
||||
for package in traffic_packages:
|
||||
gb = int(package.get("gb", 0) or 0)
|
||||
price_per_month = int(package.get("price", 0) or 0)
|
||||
discounted_per_month, _ = apply_percentage_discount(
|
||||
price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
price_difference_per_month = discounted_per_month - discounted_current_per_month
|
||||
total_price_difference = price_difference_per_month * months_to_charge
|
||||
description = None
|
||||
|
||||
if price_difference_per_month < 0:
|
||||
total_price_difference = 0
|
||||
description = "Без возврата средств"
|
||||
|
||||
price_label = _format_total_price_label(
|
||||
total_price_difference,
|
||||
months_to_charge,
|
||||
)
|
||||
|
||||
traffic_options.append(
|
||||
MiniAppSubscriptionSettingsTrafficOption(
|
||||
value=gb,
|
||||
label="",
|
||||
price_kopeks=max(0, total_price_difference),
|
||||
price_label=price_label,
|
||||
is_current=gb == current_traffic,
|
||||
is_available=True,
|
||||
description=description,
|
||||
)
|
||||
)
|
||||
|
||||
current_devices = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 0
|
||||
device_options: List[MiniAppSubscriptionSettingsDeviceOption] = []
|
||||
|
||||
range_max = max_devices or max(current_devices + 5, settings.DEFAULT_DEVICE_LIMIT + 5)
|
||||
for devices_count in range(1, range_max + 1):
|
||||
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
|
||||
chargeable_devices = new_chargeable - current_chargeable
|
||||
|
||||
if chargeable_devices > 0:
|
||||
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
total_price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
price_label = _format_total_price_label(total_price, charged_months)
|
||||
else:
|
||||
total_price = 0
|
||||
price_label = None
|
||||
|
||||
device_options.append(
|
||||
MiniAppSubscriptionSettingsDeviceOption(
|
||||
value=devices_count,
|
||||
label=str(devices_count),
|
||||
price_kopeks=max(0, total_price),
|
||||
price_label=price_label,
|
||||
)
|
||||
)
|
||||
|
||||
balance_currency = getattr(user, "balance_currency", None)
|
||||
if isinstance(balance_currency, str) and balance_currency.strip():
|
||||
currency = balance_currency.strip().upper()
|
||||
else:
|
||||
currency = "RUB"
|
||||
|
||||
current_payload = MiniAppSubscriptionSettingsCurrent(
|
||||
servers=connected_servers,
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_limit_label=_format_limit_label(subscription.traffic_limit_gb),
|
||||
device_limit=subscription.device_limit,
|
||||
)
|
||||
|
||||
servers_payload = MiniAppSubscriptionSettingsServers(
|
||||
available=available_payload,
|
||||
min=1,
|
||||
max=0,
|
||||
can_update=True,
|
||||
hint=None,
|
||||
)
|
||||
|
||||
traffic_payload = MiniAppSubscriptionSettingsTraffic(
|
||||
options=traffic_options,
|
||||
can_update=not settings.is_traffic_fixed(),
|
||||
current_value=current_traffic,
|
||||
)
|
||||
|
||||
devices_payload = MiniAppSubscriptionSettingsDevices(
|
||||
options=device_options,
|
||||
can_update=True,
|
||||
min=1,
|
||||
max=max_devices,
|
||||
step=1,
|
||||
current=current_devices,
|
||||
price_kopeks=settings.PRICE_PER_DEVICE * months_to_charge,
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionSettingsData(
|
||||
subscription_id=subscription.id,
|
||||
currency=currency,
|
||||
current=current_payload,
|
||||
servers=servers_payload,
|
||||
traffic=traffic_payload,
|
||||
devices=devices_payload,
|
||||
)
|
||||
|
||||
def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str:
|
||||
suffix = uuid4().hex[:8]
|
||||
return f"balance_{user_id}_{amount_kopeks}_{suffix}"
|
||||
@@ -1958,6 +2304,546 @@ async def _build_referral_info(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription/settings",
|
||||
response_model=MiniAppSubscriptionSettingsResponse,
|
||||
)
|
||||
async def get_subscription_settings(
|
||||
payload: MiniAppSubscriptionSettingsRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionSettingsResponse:
|
||||
user, subscription = await _authorize_subscription_user(
|
||||
db,
|
||||
payload.init_data,
|
||||
payload.subscription_id,
|
||||
)
|
||||
|
||||
settings_payload = await _build_subscription_settings_payload(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionSettingsResponse(settings=settings_payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription/servers",
|
||||
response_model=MiniAppSubscriptionSettingsActionResponse,
|
||||
)
|
||||
async def update_subscription_servers(
|
||||
payload: MiniAppSubscriptionServersUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionSettingsActionResponse:
|
||||
user, subscription = await _authorize_subscription_user(
|
||||
db,
|
||||
payload.init_data,
|
||||
payload.subscription_id,
|
||||
)
|
||||
|
||||
selection_sources = [
|
||||
payload.squads,
|
||||
payload.servers,
|
||||
payload.squad_uuids,
|
||||
payload.server_uuids,
|
||||
]
|
||||
|
||||
selected_order: List[str] = []
|
||||
for source in selection_sources:
|
||||
for value in source or []:
|
||||
normalized = (value or "").strip()
|
||||
if normalized and normalized not in selected_order:
|
||||
selected_order.append(normalized)
|
||||
|
||||
if not selected_order:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Необходимо выбрать хотя бы один сервер"},
|
||||
)
|
||||
|
||||
current_set = set(subscription.connected_squads or [])
|
||||
if len(selected_order) < 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Нужно выбрать хотя бы один сервер"},
|
||||
)
|
||||
|
||||
available_servers = await get_available_server_squads(
|
||||
db,
|
||||
promo_group_id=getattr(user, "promo_group_id", None),
|
||||
)
|
||||
available_map = {server.squad_uuid: server for server in available_servers}
|
||||
allowed_uuids = set(available_map.keys()) | current_set
|
||||
|
||||
for squad_uuid in selected_order:
|
||||
if squad_uuid not in allowed_uuids:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Выбран недоступный сервер"},
|
||||
)
|
||||
if squad_uuid not in current_set:
|
||||
server = available_map.get(squad_uuid)
|
||||
if not server:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Выбран недоступный сервер"},
|
||||
)
|
||||
available_map[squad_uuid] = server
|
||||
if not (server.is_available and not server.is_full):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Сервер временно недоступен"},
|
||||
)
|
||||
|
||||
new_servers = [uuid for uuid in selected_order if uuid not in current_set]
|
||||
removed_servers = [uuid for uuid in current_set if uuid not in selected_order]
|
||||
|
||||
if not new_servers and not removed_servers:
|
||||
return MiniAppSubscriptionSettingsActionResponse(
|
||||
success=True,
|
||||
message="Изменений не обнаружено",
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
charged_kopeks=0,
|
||||
)
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
total_price = 0
|
||||
total_discount = 0
|
||||
new_server_names: List[str] = []
|
||||
|
||||
for squad_uuid in new_servers:
|
||||
server = available_map.get(squad_uuid)
|
||||
if not server:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Сервер недоступен"},
|
||||
)
|
||||
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server.price_kopeks,
|
||||
servers_discount_percent,
|
||||
)
|
||||
charged_price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_price += charged_price
|
||||
total_discount += discount_per_month * charged_months
|
||||
new_server_names.append(server.display_name or squad_uuid)
|
||||
|
||||
if total_price > 0 and user.balance_kopeks < total_price:
|
||||
missing = total_price - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Недостаточно средств на балансе",
|
||||
"required_amount": total_price,
|
||||
"missing_amount": missing,
|
||||
"balance": user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
charged_amount = total_price
|
||||
if total_price > 0:
|
||||
description = "Добавление серверов к подписке"
|
||||
if new_server_names:
|
||||
description = (
|
||||
"Добавление серверов к подписке: "
|
||||
+ ", ".join(new_server_names)
|
||||
)
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
total_price,
|
||||
description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"message": "Не удалось списать средства"},
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_price,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
subscription.connected_squads = selected_order
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to update RemnaWave user after servers update: %s", error)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
removed_names: List[str] = []
|
||||
for squad_uuid in removed_servers:
|
||||
server = available_map.get(squad_uuid)
|
||||
if not server:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
removed_names.append(getattr(server, "display_name", None) or squad_uuid)
|
||||
|
||||
message_parts: List[str] = []
|
||||
if new_server_names:
|
||||
message_parts.append(
|
||||
"Добавлены серверы: " + ", ".join(new_server_names)
|
||||
)
|
||||
if total_discount > 0 and servers_discount_percent > 0:
|
||||
message_parts.append(
|
||||
(
|
||||
"Применена скидка {percent}%: -{amount}".format(
|
||||
percent=servers_discount_percent,
|
||||
amount=settings.format_price(total_discount),
|
||||
)
|
||||
)
|
||||
)
|
||||
if removed_names:
|
||||
message_parts.append(
|
||||
"Отключены серверы: " + ", ".join(removed_names)
|
||||
)
|
||||
|
||||
message = "\n".join(message_parts) if message_parts else "Настройки серверов обновлены"
|
||||
|
||||
return MiniAppSubscriptionSettingsActionResponse(
|
||||
success=True,
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
charged_kopeks=charged_amount,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription/traffic",
|
||||
response_model=MiniAppSubscriptionSettingsActionResponse,
|
||||
)
|
||||
async def update_subscription_traffic(
|
||||
payload: MiniAppSubscriptionTrafficUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionSettingsActionResponse:
|
||||
if settings.is_traffic_fixed():
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Изменение лимита трафика недоступно"},
|
||||
)
|
||||
|
||||
user, subscription = await _authorize_subscription_user(
|
||||
db,
|
||||
payload.init_data,
|
||||
payload.subscription_id,
|
||||
)
|
||||
|
||||
new_traffic = (
|
||||
payload.traffic
|
||||
if payload.traffic is not None
|
||||
else payload.traffic_gb
|
||||
)
|
||||
|
||||
if new_traffic is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Не указан новый лимит трафика"},
|
||||
)
|
||||
|
||||
try:
|
||||
new_traffic_gb = int(new_traffic)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Некорректный лимит трафика"},
|
||||
) from None
|
||||
|
||||
if new_traffic_gb < 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Некорректный лимит трафика"},
|
||||
)
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb or 0
|
||||
if new_traffic_gb == current_traffic:
|
||||
return MiniAppSubscriptionSettingsActionResponse(
|
||||
success=True,
|
||||
message="Лимит трафика не изменился",
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
charged_kopeks=0,
|
||||
)
|
||||
|
||||
old_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
"traffic",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
discounted_old_per_month, _ = apply_percentage_discount(
|
||||
old_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
discounted_new_per_month, _ = apply_percentage_discount(
|
||||
new_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
price_difference_per_month = discounted_new_per_month - discounted_old_per_month
|
||||
total_price_difference = (
|
||||
price_difference_per_month * months_remaining
|
||||
if price_difference_per_month > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
if total_price_difference > 0 and user.balance_kopeks < total_price_difference:
|
||||
missing = total_price_difference - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Недостаточно средств на балансе",
|
||||
"required_amount": total_price_difference,
|
||||
"missing_amount": missing,
|
||||
"balance": user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
charged_amount = total_price_difference
|
||||
if total_price_difference > 0:
|
||||
description = (
|
||||
f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
|
||||
)
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
total_price_difference,
|
||||
description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"message": "Не удалось списать средства"},
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_price_difference,
|
||||
description=(
|
||||
f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
|
||||
),
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
subscription.traffic_limit_gb = new_traffic_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to update RemnaWave user after traffic update: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
message = (
|
||||
f"Лимит трафика обновлен: {current_traffic} → {new_traffic_gb}"
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionSettingsActionResponse(
|
||||
success=True,
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
charged_kopeks=charged_amount,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subscription/devices",
|
||||
response_model=MiniAppSubscriptionSettingsActionResponse,
|
||||
)
|
||||
async def update_subscription_devices(
|
||||
payload: MiniAppSubscriptionDevicesUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionSettingsActionResponse:
|
||||
user, subscription = await _authorize_subscription_user(
|
||||
db,
|
||||
payload.init_data,
|
||||
payload.subscription_id,
|
||||
)
|
||||
|
||||
if payload.devices is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Не указано новое количество устройств"},
|
||||
)
|
||||
|
||||
try:
|
||||
new_devices_count = int(payload.devices)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Некорректное количество устройств"},
|
||||
) from None
|
||||
|
||||
if new_devices_count < 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"message": "Количество устройств должно быть не меньше 1"},
|
||||
)
|
||||
|
||||
max_devices = settings.MAX_DEVICES_LIMIT
|
||||
if max_devices > 0 and new_devices_count > max_devices:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": (
|
||||
f"Превышен максимальный лимит устройств ({max_devices})"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
current_devices = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
if new_devices_count == current_devices:
|
||||
return MiniAppSubscriptionSettingsActionResponse(
|
||||
success=True,
|
||||
message="Количество устройств не изменилось",
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
charged_kopeks=0,
|
||||
)
|
||||
|
||||
devices_difference = new_devices_count - current_devices
|
||||
price = 0
|
||||
charged_months = 0
|
||||
|
||||
if devices_difference > 0:
|
||||
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
new_chargeable = max(0, new_devices_count - settings.DEFAULT_DEVICE_LIMIT)
|
||||
chargeable_devices = new_chargeable - current_chargeable
|
||||
|
||||
if chargeable_devices > 0:
|
||||
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
"devices",
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
else:
|
||||
price = 0
|
||||
else:
|
||||
price = 0
|
||||
|
||||
if price > 0 and user.balance_kopeks < price:
|
||||
missing = price - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Недостаточно средств на балансе",
|
||||
"required_amount": price,
|
||||
"missing_amount": missing,
|
||||
"balance": user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
charged_amount = price
|
||||
if price > 0:
|
||||
description = (
|
||||
f"Изменение устройств с {current_devices} до {new_devices_count}"
|
||||
)
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price,
|
||||
description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"message": "Не удалось списать средства"},
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=(
|
||||
f"Изменение устройств с {current_devices} до {new_devices_count}"
|
||||
),
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
subscription.device_limit = new_devices_count
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to update RemnaWave user after devices update: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
message = (
|
||||
f"Количество устройств обновлено: {current_devices} → {new_devices_count}"
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionSettingsActionResponse(
|
||||
success=True,
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
charged_kopeks=charged_amount,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscription", response_model=MiniAppSubscriptionResponse)
|
||||
async def get_subscription_details(
|
||||
payload: MiniAppSubscriptionRequest,
|
||||
|
||||
@@ -15,6 +15,33 @@ class MiniAppSubscriptionRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
|
||||
class MiniAppSubscriptionServersUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
squads: List[str] = Field(default_factory=list)
|
||||
servers: List[str] = Field(default_factory=list)
|
||||
squad_uuids: List[str] = Field(default_factory=list)
|
||||
server_uuids: List[str] = Field(default_factory=list)
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
|
||||
class MiniAppSubscriptionTrafficUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
traffic: Optional[int] = None
|
||||
traffic_gb: Optional[int] = Field(default=None, alias="trafficGb")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
|
||||
class MiniAppSubscriptionDevicesUpdateRequest(BaseModel):
|
||||
init_data: str = Field(..., alias="initData")
|
||||
devices: Optional[int] = None
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
|
||||
|
||||
class MiniAppSubscriptionUser(BaseModel):
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
@@ -88,6 +115,85 @@ class MiniAppDeviceRemovalResponse(BaseModel):
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsServer(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
discount_percent: int = 0
|
||||
is_connected: bool = False
|
||||
is_available: bool = True
|
||||
disabled_reason: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsTrafficOption(BaseModel):
|
||||
value: Optional[int] = None
|
||||
label: str = ""
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
is_current: bool = False
|
||||
is_available: bool = True
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsDeviceOption(BaseModel):
|
||||
value: int
|
||||
label: str
|
||||
price_kopeks: Optional[int] = None
|
||||
price_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsCurrent(BaseModel):
|
||||
servers: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
traffic_limit_label: Optional[str] = None
|
||||
device_limit: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsServers(BaseModel):
|
||||
available: List[MiniAppSubscriptionSettingsServer] = Field(default_factory=list)
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
can_update: bool = True
|
||||
hint: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsTraffic(BaseModel):
|
||||
options: List[MiniAppSubscriptionSettingsTrafficOption] = Field(default_factory=list)
|
||||
can_update: bool = True
|
||||
current_value: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsDevices(BaseModel):
|
||||
options: List[MiniAppSubscriptionSettingsDeviceOption] = Field(default_factory=list)
|
||||
can_update: bool = True
|
||||
min: int = 0
|
||||
max: int = 0
|
||||
step: int = 1
|
||||
current: Optional[int] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsData(BaseModel):
|
||||
subscription_id: Optional[int] = None
|
||||
currency: str = "RUB"
|
||||
current: MiniAppSubscriptionSettingsCurrent
|
||||
servers: MiniAppSubscriptionSettingsServers
|
||||
traffic: MiniAppSubscriptionSettingsTraffic
|
||||
devices: MiniAppSubscriptionSettingsDevices
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsResponse(BaseModel):
|
||||
settings: MiniAppSubscriptionSettingsData
|
||||
|
||||
|
||||
class MiniAppSubscriptionSettingsActionResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
balance_kopeks: Optional[int] = None
|
||||
charged_kopeks: int = 0
|
||||
|
||||
|
||||
class MiniAppTransaction(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
|
||||
Reference in New Issue
Block a user