830e64afe0
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах - subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs - monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч - logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове - logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв * fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id Трейс показал: subscription.user падает на lazy-load → pool._checkout → do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не поддерживает sync-lazy-load для relationships. Причина рассинхрона: lock_user_for_pricing делает populate_existing=True + selectinload( User.subscriptions).selectinload(Subscription.tariff), что разгружает Subscription.user backref для сестринских подписок того же user. Последующее обращение sub.user у другой подписки падает. Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию делаем fresh refetch через async select с eager load user+tariff+ promo_group. Никаких lazy access в горячем пути. В except используем локально захваченные id вместо getattr(subscription, ...), чтобы логирование не падало каскадом на expired объекте. * fix: grant all available squads for unrestricted trials (#2897) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook * style: ruff format main.py --------- Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not update first_name/last_name from OIDC claims (#2892) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal - balance/platega: re-set FSM state after min/max validation errors, set state before pending_amount path, use balance_topup callback for back button - balance/main: set FSM state and payment_method in handle_topup_amount_callback for all providers before routing, use balance_topup callback in validation errors - payment/paypear: fix confirmation_url key (was 'url'), add fallback, store charged amount with commission for correct webhook amount comparison - tariff_purchase: redirect to active tariff list when current tariff is inactive (hidden trial after promo code activation) - cabinet/renewal: check tariff.is_active in both GET and POST endpoints to prevent hidden trial tariff periods from appearing * fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard - pricing_engine: use shortest period for daily rate comparison instead of period closest to remaining_days — fixes incorrect free/zero cost for upgrades when tariffs have different period sets - pricing_engine: remove unused target_days parameter from get_tariff_daily_rate_fraction - admin_users: add duplicate subscription check before create, change_tariff and activate actions to prevent UniqueViolationError on uq_subscriptions_user_tariff_active constraint - admin_users: add IntegrityError fallback on create as TOCTOU safety net * feat: tariff switch direction control, fix device pricing within tariff limit Tariff switch direction: - Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED settings to control allowed switch directions - Guard all 10 entry points: instant switch (list, preview, confirm), legacy switch (list, select, confirm, daily confirm), cabinet (preview, execute), purchase-options API - Filter tariff lists by allowed direction, show "unavailable" when both directions disabled - Expose settings in cabinet purchase-options response for frontend Device pricing fix: - Devices within tariff.device_limit are now free when restoring (was charging for all devices regardless of tariff inclusion) - Fix max(100, price) minimum enforcing 1 RUB even when chargeable_devices is 0 - Apply fix across all endpoints: bot handlers (confirm_change, execute_change, confirm_add), cabinet API (legacy purchase, modern purchase, get-price, save-cart), inline keyboard display * fix: classic mode renewal resets device_limit to 1 via cart key mismatch - Fix cart key mismatch: extend cart saved 'device_limit' but confirm_purchase read 'devices' key, falling back to DEFAULT=1. Now both keys are saved in both cart-save paths - Fix confirm_purchase device resolution: use explicit is None checks instead of or-chain to avoid falsy-zero trap - Fix return_to_saved_cart display: fall back to 'device_limit' and 'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent - Fix second cart-save path in _extend_existing_subscription with same dual-key pattern - Fix RemnaWaveService import path in renewal service - Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices (hwid) via RemnaWave API on each subscription renewal * fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports - Increase icon max_length from 10 to 100 in all three schemas (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest) to support Telegram Custom Emoji IDs - Add traffic_topup_enabled condition to ButtonConditions schema - Remove shadowing local imports of MenuLayoutService in routes/menu_layout.py (top-level import already provides access) * feat(tickets): multi-media message gallery (media_items JSONB) - Add media_items JSONB column to TicketMessage model for multi-media gallery support (photos/videos/documents in one bubble) - Add TicketMediaItem schema with type validation and shared _validate_media_bundle helper (max 10 items, legacy field compat) - Update admin and user ticket handlers to store media_items and back-fill legacy media_type/media_file_id/media_caption from first item for backward compatibility - Update _message_to_response in both admin and user routes to include media_items in API responses - Allow empty message text when media is attached (message field now defaults to empty string with model validator ensuring text or media) - Add migration 0061 with idempotent column check Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: ticket media_items review fixes - Add if has_media else None guards in user-side ticket handlers (create_ticket, add_message) matching admin handler pattern - Fix Telegram notification using resolved primary_file_id/primary_type instead of raw request fields for gallery messages - Narrow except Exception to (TypeError, KeyError, ValueError) in _message_to_response with warning log for debugging - Add media_items parameter to TicketCRUD.create_ticket and TicketCRUD.add_message for CRUD layer parity - Add TicketMediaItemResponse and media_items field to webapi TicketMessageResponse to prevent data loss on read * feat: landing page analytics goals and sticky pay button - Add sticky_pay_button, analytics_view_enabled, analytics_view_goal, analytics_click_enabled, analytics_click_goal columns to LandingPage - Add fields to CRUD updatable fields, admin create/update/detail schemas, create_landing() kwargs, _landing_to_detail() response - Expose sticky_pay_button and analytics fields in public landing config response for frontend Yandex Metrika integration - Add migration 0062 with idempotent column checks Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: validate analytics goal is set when analytics is enabled on landing Prevent enabling analytics_view/click without providing the corresponding goal identifier, which would result in empty Yandex Metrika calls on the frontend. * feat: Yandex Metrika offline conversions + S2S postbacks - Add YandexClientIdMap model for user → yandex_cid mapping with upsert-safe CRUD (ON CONFLICT DO UPDATE) - Add yandex_cid, subid, referrer columns to GuestPurchase - Add yandex_offline_conv_service: Measurement Protocol integration with mc.yandex.ru/collect (registration, trial, purchase events), background task management, CID parsing from /start params - Add s2s_postback_service: server-to-server affiliate postbacks with URL template placeholders and URL-safe encoding - Add analytics offline conversion info to branding API (masked secret) - Add POST /analytics/yandex-cid endpoint for cabinet CID capture - Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*) - Add migration 0063 (yandex_client_id_map table + guest_purchases cols) - Fix: mask measurement secret aggressively (show only last 4 chars) - Fix: always replace {user_id} placeholder in S2S postback URLs - Fix: use structlog kwargs instead of f-strings with LOG_PREFIX Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded --------- Co-authored-by: c0mrade <killmy666@gmail.com> Co-authored-by: Danila Yudin <danyayudin2012@gmail.com> Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com> Co-authored-by: Dmitry Lunin <br@slack.ru>
389 lines
16 KiB
Python
389 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.config import settings
|
|
from app.database.crud.server_squad import get_effective_tariff_squad_uuids
|
|
from app.database.crud.subscription import (
|
|
add_subscription_devices,
|
|
add_subscription_squad,
|
|
add_subscription_traffic,
|
|
create_paid_subscription,
|
|
create_trial_subscription,
|
|
deactivate_subscription,
|
|
extend_subscription,
|
|
get_subscription_by_user_id,
|
|
reactivate_subscription,
|
|
remove_subscription_squad,
|
|
replace_subscription,
|
|
)
|
|
from app.database.crud.user import get_user_by_id
|
|
from app.database.models import Subscription, SubscriptionStatus
|
|
from app.services.subscription_service import SubscriptionService
|
|
|
|
from ..dependencies import get_db_session, require_api_token
|
|
from ..schemas.subscriptions import (
|
|
SubscriptionCreateRequest,
|
|
SubscriptionDevicesRequest,
|
|
SubscriptionExtendRequest,
|
|
SubscriptionResponse,
|
|
SubscriptionSquadRequest,
|
|
SubscriptionTrafficRequest,
|
|
)
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
|
return SubscriptionResponse(
|
|
id=subscription.id,
|
|
user_id=subscription.user_id,
|
|
status=subscription.status,
|
|
actual_status=subscription.actual_status,
|
|
is_trial=subscription.is_trial,
|
|
start_date=subscription.start_date,
|
|
end_date=subscription.end_date,
|
|
traffic_limit_gb=subscription.traffic_limit_gb,
|
|
traffic_used_gb=subscription.traffic_used_gb,
|
|
device_limit=subscription.device_limit,
|
|
autopay_enabled=subscription.autopay_enabled,
|
|
autopay_days_before=subscription.autopay_days_before,
|
|
subscription_url=subscription.subscription_url,
|
|
subscription_crypto_link=subscription.subscription_crypto_link,
|
|
connected_squads=list(subscription.connected_squads or []),
|
|
created_at=subscription.created_at,
|
|
updated_at=subscription.updated_at,
|
|
)
|
|
|
|
|
|
async def _choose_trial_squads(
|
|
db: AsyncSession, requested_squad_uuid: str | None, fallback_squads: list[str]
|
|
) -> list[str]:
|
|
if requested_squad_uuid:
|
|
return [requested_squad_uuid]
|
|
|
|
if fallback_squads:
|
|
return fallback_squads
|
|
|
|
try:
|
|
default_squads = await get_effective_tariff_squad_uuids(db, None)
|
|
except Exception as error:
|
|
logger.error('Failed to resolve default trial squads', error=error)
|
|
default_squads = []
|
|
|
|
if not default_squads:
|
|
return []
|
|
|
|
logger.debug('Selected default trial squads for subscription replacement', squad_uuids=default_squads)
|
|
return default_squads
|
|
|
|
|
|
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
|
result = await db.execute(
|
|
select(Subscription).options(selectinload(Subscription.user)).where(Subscription.id == subscription_id)
|
|
)
|
|
subscription = result.scalar_one_or_none()
|
|
if not subscription:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Subscription not found')
|
|
return subscription
|
|
|
|
|
|
@router.get('', response_model=list[SubscriptionResponse])
|
|
async def list_subscriptions(
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
status_filter: SubscriptionStatus | None = Query(default=None, alias='status'),
|
|
user_id: int | None = Query(default=None),
|
|
is_trial: bool | None = Query(default=None),
|
|
) -> list[SubscriptionResponse]:
|
|
query = select(Subscription).options(selectinload(Subscription.user))
|
|
|
|
if status_filter:
|
|
query = query.where(Subscription.status == status_filter.value)
|
|
if user_id:
|
|
query = query.where(Subscription.user_id == user_id)
|
|
if is_trial is not None:
|
|
query = query.where(Subscription.is_trial.is_(is_trial))
|
|
|
|
query = query.order_by(Subscription.created_at.desc()).offset(offset).limit(limit)
|
|
result = await db.execute(query)
|
|
subscriptions = result.scalars().all()
|
|
return [_serialize_subscription(sub) for sub in subscriptions]
|
|
|
|
|
|
@router.get('/{subscription_id}', response_model=SubscriptionResponse)
|
|
async def get_subscription(
|
|
subscription_id: int,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.post('', response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_subscription(
|
|
payload: SubscriptionCreateRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
if settings.is_multi_tariff_enabled():
|
|
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
|
|
|
active_subs = await get_active_subscriptions_by_user_id(db, payload.user_id)
|
|
if payload.replace_existing and payload.subscription_id:
|
|
from app.database.crud.subscription import get_subscription_by_id
|
|
|
|
existing = await get_subscription_by_id(db, payload.subscription_id)
|
|
if existing and existing.user_id != payload.user_id:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Subscription does not belong to this user')
|
|
elif payload.replace_existing and active_subs:
|
|
if len(active_subs) == 1:
|
|
existing = active_subs[0]
|
|
else:
|
|
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
|
_pool = _non_daily or active_subs
|
|
existing = max(_pool, key=lambda s: s.days_left)
|
|
else:
|
|
existing = None
|
|
if active_subs and not payload.replace_existing:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User already has a subscription')
|
|
else:
|
|
existing = await get_subscription_by_user_id(db, payload.user_id)
|
|
if existing and not payload.replace_existing:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User already has a subscription')
|
|
|
|
forced_devices = None
|
|
if not settings.is_devices_selection_enabled():
|
|
forced_devices = settings.get_disabled_mode_device_limit()
|
|
|
|
subscription = None
|
|
try:
|
|
if payload.is_trial:
|
|
trial_device_limit = payload.device_limit
|
|
if trial_device_limit is None:
|
|
trial_device_limit = forced_devices
|
|
duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS
|
|
traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
|
|
|
if existing:
|
|
connected_squads = await _choose_trial_squads(
|
|
db, payload.squad_uuid, list(existing.connected_squads or [])
|
|
)
|
|
subscription = await replace_subscription(
|
|
db,
|
|
existing,
|
|
duration_days=duration_days,
|
|
traffic_limit_gb=traffic_limit_gb,
|
|
device_limit=(
|
|
trial_device_limit if trial_device_limit is not None else settings.TRIAL_DEVICE_LIMIT
|
|
),
|
|
connected_squads=connected_squads,
|
|
is_trial=True,
|
|
update_server_counters=True,
|
|
)
|
|
else:
|
|
subscription = await create_trial_subscription(
|
|
db,
|
|
user_id=payload.user_id,
|
|
duration_days=duration_days,
|
|
traffic_limit_gb=traffic_limit_gb,
|
|
device_limit=trial_device_limit,
|
|
squad_uuid=payload.squad_uuid,
|
|
)
|
|
else:
|
|
if payload.duration_days is None:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'duration_days is required for paid subscriptions')
|
|
device_limit = payload.device_limit
|
|
if device_limit is None:
|
|
if forced_devices is not None:
|
|
device_limit = forced_devices
|
|
else:
|
|
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
|
if existing:
|
|
subscription = await replace_subscription(
|
|
db,
|
|
existing,
|
|
duration_days=payload.duration_days,
|
|
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
|
device_limit=device_limit,
|
|
connected_squads=payload.connected_squads or [],
|
|
is_trial=False,
|
|
update_server_counters=True,
|
|
)
|
|
else:
|
|
subscription = await create_paid_subscription(
|
|
db,
|
|
user_id=payload.user_id,
|
|
duration_days=payload.duration_days,
|
|
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
|
device_limit=device_limit,
|
|
connected_squads=payload.connected_squads or [],
|
|
update_server_counters=True,
|
|
)
|
|
|
|
subscription_service = SubscriptionService()
|
|
rem_user = await subscription_service.create_remnawave_user(db, subscription, reset_traffic=False)
|
|
if not rem_user:
|
|
raise ValueError('Failed to create/update user in Remnawave')
|
|
|
|
await db.refresh(subscription)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
try:
|
|
await db.rollback()
|
|
except Exception:
|
|
logger.exception('Rollback failed after error', e=e)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Failed to sync with Remnawave: {e!s}'
|
|
)
|
|
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.post('/{subscription_id}/extend', response_model=SubscriptionResponse)
|
|
async def extend_subscription_endpoint(
|
|
subscription_id: int,
|
|
payload: SubscriptionExtendRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
subscription = await extend_subscription(db, subscription, payload.days)
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.post('/{subscription_id}/traffic', response_model=SubscriptionResponse)
|
|
async def add_subscription_traffic_endpoint(
|
|
subscription_id: int,
|
|
payload: SubscriptionTrafficRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
subscription = await add_subscription_traffic(db, subscription, payload.gb)
|
|
|
|
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
|
await reactivate_subscription(db, subscription)
|
|
|
|
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
|
|
service = SubscriptionService()
|
|
await service.update_remnawave_user(db, subscription)
|
|
|
|
user = await get_user_by_id(db, subscription.user_id)
|
|
_enable_uuid = (
|
|
subscription.remnawave_uuid
|
|
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
|
else (user.remnawave_uuid if user else None)
|
|
)
|
|
if user and _enable_uuid and subscription.status == 'active':
|
|
await service.enable_remnawave_user(_enable_uuid)
|
|
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.post('/{subscription_id}/devices', response_model=SubscriptionResponse)
|
|
async def add_subscription_devices_endpoint(
|
|
subscription_id: int,
|
|
payload: SubscriptionDevicesRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
subscription = await add_subscription_devices(db, subscription, payload.devices)
|
|
|
|
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
|
await reactivate_subscription(db, subscription)
|
|
|
|
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
|
|
service = SubscriptionService()
|
|
await service.update_remnawave_user(db, subscription)
|
|
|
|
user = await get_user_by_id(db, subscription.user_id)
|
|
_enable_uuid = (
|
|
subscription.remnawave_uuid
|
|
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
|
else (user.remnawave_uuid if user else None)
|
|
)
|
|
if user and _enable_uuid and subscription.status == 'active':
|
|
await service.enable_remnawave_user(_enable_uuid)
|
|
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.post('/{subscription_id}/squads', response_model=SubscriptionResponse)
|
|
async def add_subscription_squad_endpoint(
|
|
subscription_id: int,
|
|
payload: SubscriptionSquadRequest,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
if not payload.squad_uuid:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'squad_uuid is required')
|
|
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
subscription = await add_subscription_squad(db, subscription, payload.squad_uuid)
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.delete('/{subscription_id}/squads/{squad_uuid}', response_model=SubscriptionResponse)
|
|
async def remove_subscription_squad_endpoint(
|
|
subscription_id: int,
|
|
squad_uuid: str,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|
|
|
|
|
|
@router.delete('/{subscription_id}', response_model=SubscriptionResponse)
|
|
async def delete_subscription(
|
|
subscription_id: int,
|
|
_: Any = Security(require_api_token),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> SubscriptionResponse:
|
|
"""
|
|
Деактивировать подписку.
|
|
Подписка не удаляется физически, а помечается как DISABLED.
|
|
Также деактивируется пользователь в RemnaWave, если есть UUID.
|
|
"""
|
|
subscription = await _get_subscription(db, subscription_id)
|
|
|
|
await deactivate_subscription(db, subscription)
|
|
|
|
# Деактивируем пользователя в RemnaWave (per-subscription UUID в мульти-тарифе)
|
|
from app.config import settings
|
|
|
|
disable_uuid = (
|
|
subscription.remnawave_uuid
|
|
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
|
else (subscription.user.remnawave_uuid if subscription.user else None)
|
|
)
|
|
if disable_uuid:
|
|
subscription_service = SubscriptionService()
|
|
await subscription_service.disable_remnawave_user(disable_uuid)
|
|
|
|
subscription = await _get_subscription(db, subscription.id)
|
|
return _serialize_subscription(subscription)
|