diff --git a/app/cabinet/routes/admin_traffic.py b/app/cabinet/routes/admin_traffic.py index 037b0a93..9b1382ca 100644 --- a/app/cabinet/routes/admin_traffic.py +++ b/app/cabinet/routes/admin_traffic.py @@ -21,6 +21,8 @@ from ..dependencies import get_cabinet_db, require_permission from ..schemas.traffic import ( ExportCsvRequest, ExportCsvResponse, + SubscriptionEnrichmentInfo, + SubscriptionTrafficInfo, TrafficEnrichmentResponse, TrafficNodeInfo, TrafficUsageResponse, @@ -203,19 +205,22 @@ def _build_traffic_items( continue subs = getattr(user, 'subscriptions', None) or [] - sub = next((s for s in subs if s.is_active), subs[0] if subs else None) + + # Primary subscription for backward-compat top-level fields + primary_sub = next((s for s in subs if s.is_active), subs[0] if subs else None) tariff_name = None subscription_status = None traffic_limit_gb = 0.0 device_limit = 1 - if sub: - subscription_status = _get_status(sub) - traffic_limit_gb = float(sub.traffic_limit_gb or 0) - device_limit = sub.device_limit or 1 - if sub.tariff: - tariff_name = sub.tariff.name + if primary_sub: + subscription_status = _get_status(primary_sub) + traffic_limit_gb = float(primary_sub.traffic_limit_gb or 0) + device_limit = primary_sub.device_limit or 1 + if primary_sub.tariff: + tariff_name = primary_sub.tariff.name + # Filtering uses primary sub values (keeps existing filter semantics) if tariff_filter is not None: if (tariff_name or '') not in tariff_filter: continue @@ -230,6 +235,18 @@ def _build_traffic_items( total_bytes = sum(traffic.values()) + # Build per-subscription detail list for multi-subscription display + subscriptions_traffic = [ + SubscriptionTrafficInfo( + subscription_id=sub.id, + tariff_name=sub.tariff.name if sub.tariff else None, + status=_get_status(sub), + traffic_limit_gb=float(sub.traffic_limit_gb or 0), + device_limit=sub.device_limit or 1, + ) + for sub in subs + ] + items.append( UserTrafficItem( user_id=user.id, @@ -243,6 +260,7 @@ def _build_traffic_items( device_limit=device_limit, node_traffic=traffic, total_bytes=total_bytes, + subscriptions=subscriptions_traffic, ) ) @@ -474,27 +492,41 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict for uuid, user in user_map.items(): uid = user.id subs_list = getattr(user, 'subscriptions', None) or [] - sub = next((s for s in subs_list if s.is_active), subs_list[0] if subs_list else None) + + # Primary subscription for backward-compat top-level date fields + primary_sub = next((s for s in subs_list if s.is_active), subs_list[0] if subs_list else None) start_date = None end_date = None - if sub: - if sub.start_date: - start_date = sub.start_date.isoformat() - if sub.end_date: - end_date = sub.end_date.isoformat() + if primary_sub: + if primary_sub.start_date: + start_date = primary_sub.start_date.isoformat() + if primary_sub.end_date: + end_date = primary_sub.end_date.isoformat() last_node_name = None last_uuid = last_node_uuid_by_user.get(uid) if last_uuid: last_node_name = node_uuid_to_name.get(last_uuid) + # Build per-subscription enrichment list for multi-subscription display + subscriptions_enrichment = [ + SubscriptionEnrichmentInfo( + subscription_id=sub.id, + tariff_name=sub.tariff.name if sub.tariff else None, + start_date=sub.start_date.isoformat() if sub.start_date else None, + end_date=sub.end_date.isoformat() if sub.end_date else None, + ) + for sub in subs_list + ] + enrichment[uid] = UserTrafficEnrichment( devices_connected=devices_by_user.get(uid, 0), total_spent_kopeks=spending_map.get(uid, 0), subscription_start_date=start_date, subscription_end_date=end_date, last_node_name=last_node_name, + subscriptions=subscriptions_enrichment, ) return enrichment diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 063e228c..467602fa 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -573,12 +573,17 @@ async def get_user_detail( spending_stats = await get_users_spending_stats(db, [user.id]) user_stats = spending_stats.get(user.id, {'total_spent': 0, 'purchase_count': 0}) - # Build subscription info - subscription_info = None + # Build subscription info (all subscriptions + legacy single) subs = getattr(user, 'subscriptions', None) or [] - subscription = next((s for s in subs if s.is_active), subs[0] if subs else None) - if subscription: - subscription_info = await _build_subscription_info_async(db, subscription) + all_subscriptions_info = [] + for sub in subs: + all_subscriptions_info.append(await _build_subscription_info_async(db, sub)) + + # Legacy: pick first active or most recent for backward compat + subscription_info = None + primary_sub = next((s for s in subs if s.is_active), subs[0] if subs else None) + if primary_sub: + subscription_info = await _build_subscription_info_async(db, primary_sub) # Build promo group info promo_group_info = None @@ -670,6 +675,7 @@ async def get_user_detail( last_activity=user.last_activity, cabinet_last_login=user.cabinet_last_login, subscription=subscription_info, + subscriptions=all_subscriptions_info, promo_group=promo_group_info, referral=referral_info, total_spent_kopeks=user_stats.get('total_spent', 0), @@ -981,14 +987,25 @@ async def update_user_subscription( ) subs = getattr(user, 'subscriptions', None) or [] - subscription = next((s for s in subs if s.is_active), subs[0] if subs else None) + is_multi_tariff = settings.is_multi_tariff_enabled() + + # Select target subscription + if request.subscription_id: + subscription = next((s for s in subs if s.id == request.subscription_id), None) + if not subscription and request.action != 'create': + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f'Subscription {request.subscription_id} not found for this user', + ) + else: + subscription = next((s for s in subs if s.is_active), subs[0] if subs else None) if request.action == 'create': - # Create new subscription - if subscription: + # In multi-tariff mode, allow creating additional subscriptions + if subscription and not is_multi_tariff: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail='User already has a subscription', + detail='User already has a subscription. Enable multi-tariff mode to add more.', ) from app.database.crud.subscription import create_paid_subscription diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index e09c567c..56e7c6d6 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -319,20 +319,40 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) - logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid) # Check if another user already owns this remnawave_uuid - from app.database.crud.user import get_user_by_remnawave_uuid + if settings.is_multi_tariff_enabled(): + # In multi-tariff mode UUIDs live on subscriptions, not users + from sqlalchemy import select as _select - existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid) - if existing_owner and existing_owner.id != user.id: - logger.warning( - 'Panel UUID already belongs to another user, skipping sync', - email=user.email, - panel_uuid=panel_user.uuid, - existing_owner_id=existing_owner.id, + from app.database.models import Subscription as _Subscription + + _sub_result = await db.execute( + _select(_Subscription).where(_Subscription.remnawave_uuid == panel_user.uuid) ) - return + _existing_sub = _sub_result.scalar_one_or_none() + if _existing_sub and _existing_sub.user_id != user.id: + logger.warning( + 'Panel UUID already owned by another user subscription, skipping sync', + email=user.email, + panel_uuid=panel_user.uuid, + existing_owner_id=_existing_sub.user_id, + ) + return + else: + from app.database.crud.user import get_user_by_remnawave_uuid - # Link user to panel - user.remnawave_uuid = panel_user.uuid + existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid) + if existing_owner and existing_owner.id != user.id: + logger.warning( + 'Panel UUID already belongs to another user, skipping sync', + email=user.email, + panel_uuid=panel_user.uuid, + existing_owner_id=existing_owner.id, + ) + return + + # Link user to panel (only in single-tariff mode; multi-tariff uses per-subscription UUIDs) + if not settings.is_multi_tariff_enabled(): + user.remnawave_uuid = panel_user.uuid # Create or update subscription from app.database.crud.subscription import get_subscription_by_user_id diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index ebf6d5d2..c9fe20f2 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -29,6 +29,7 @@ from .subscription_modules.status import get_subscription as _get_subscription_h router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription']) + # Root endpoint: GET /subscription (empty path — must be on this router directly) @router.get('', response_model=SubscriptionStatusResponse) async def get_subscription( diff --git a/app/cabinet/routes/subscription_modules/daily.py b/app/cabinet/routes/subscription_modules/daily.py index 1dfd548e..642d5e08 100644 --- a/app/cabinet/routes/subscription_modules/daily.py +++ b/app/cabinet/routes/subscription_modules/daily.py @@ -9,7 +9,7 @@ from datetime import UTC, datetime, timedelta from typing import Any import structlog -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -18,6 +18,7 @@ from app.database.models import User from app.services.subscription_service import SubscriptionService from ...dependencies import get_cabinet_db, get_current_cabinet_user +from .helpers import resolve_subscription logger = structlog.get_logger(__name__) @@ -29,17 +30,18 @@ router = APIRouter() async def toggle_subscription_pause( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, Any]: """Toggle pause/resume for daily subscription.""" - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', ) - tariff_id = getattr(user.subscription, 'tariff_id', None) + tariff_id = getattr(subscription, 'tariff_id', None) if not tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -56,8 +58,8 @@ async def toggle_subscription_pause( # Determine current state from app.database.models import SubscriptionStatus - is_currently_paused = getattr(user.subscription, 'is_daily_paused', False) - was_disabled = user.subscription.status in ( + is_currently_paused = getattr(subscription, 'is_daily_paused', False) + was_disabled = subscription.status in ( SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value, SubscriptionStatus.LIMITED.value, @@ -69,7 +71,7 @@ async def toggle_subscription_pause( new_paused_state = False # Force resume path else: new_paused_state = not is_currently_paused - user.subscription.is_daily_paused = new_paused_state + subscription.is_daily_paused = new_paused_state raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0) @@ -138,12 +140,12 @@ async def toggle_subscription_pause( logger.warning('Failed to create resume transaction', error=exc) # Balance deducted successfully — now activate - user.subscription.status = SubscriptionStatus.ACTIVE.value - user.subscription.last_daily_charge_at = datetime.now(UTC) - user.subscription.end_date = datetime.now(UTC) + timedelta(days=1) + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.last_daily_charge_at = datetime.now(UTC) + subscription.end_date = datetime.now(UTC) + timedelta(days=1) await db.commit() - await db.refresh(user.subscription) + await db.refresh(subscription) await db.refresh(user) # Sync with RemnaWave only when resuming from DISABLED state @@ -152,7 +154,7 @@ async def toggle_subscription_pause( subscription_service = SubscriptionService() await subscription_service.create_remnawave_user( db, - user.subscription, + subscription, reset_traffic=False, reset_reason=None, ) diff --git a/app/cabinet/routes/subscription_modules/devices.py b/app/cabinet/routes/subscription_modules/devices.py index 757bb16d..de686392 100644 --- a/app/cabinet/routes/subscription_modules/devices.py +++ b/app/cabinet/routes/subscription_modules/devices.py @@ -17,7 +17,7 @@ from datetime import UTC, datetime from typing import Any import structlog -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,7 +29,7 @@ from app.services.user_cart_service import user_cart_service from ...dependencies import get_cabinet_db, get_current_cabinet_user from ...schemas.subscription import DevicePurchaseRequest -from .helpers import _apply_addon_discount +from .helpers import _apply_addon_discount, resolve_subscription logger = structlog.get_logger(__name__) @@ -40,6 +40,7 @@ router = APIRouter() @router.post('/devices') async def purchase_devices_legacy( request: DevicePurchaseRequest, + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -55,11 +56,13 @@ async def purchase_devices_legacy( ) # Lock subscription row to prevent concurrent device purchases exceeding the limit + _sub_filter = ( + Subscription.id == subscription_id + if subscription_id and settings.is_multi_tariff_enabled() + else Subscription.user_id == user.id + ) result = await db.execute( - select(Subscription) - .where(Subscription.user_id == user.id) - .with_for_update() - .execution_options(populate_existing=True) + select(Subscription).where(_sub_filter).with_for_update().execution_options(populate_existing=True) ) subscription = result.scalar_one_or_none() @@ -265,6 +268,7 @@ async def purchase_devices_legacy( @router.post('/devices/purchase') async def purchase_devices( request: DevicePurchaseRequest, + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -277,11 +281,13 @@ async def purchase_devices( try: # Lock subscription row to prevent concurrent device purchases exceeding the limit + _sub_filter = ( + Subscription.id == subscription_id + if subscription_id and settings.is_multi_tariff_enabled() + else Subscription.user_id == user.id + ) result = await db.execute( - select(Subscription) - .where(Subscription.user_id == user.id) - .with_for_update() - .execution_options(populate_existing=True) + select(Subscription).where(_sub_filter).with_for_update().execution_options(populate_existing=True) ) subscription = result.scalar_one_or_none() @@ -530,12 +536,12 @@ async def purchase_devices( @router.post('/devices/save-cart') async def save_devices_cart( request: DevicePurchaseRequest, + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, bool]: """Save cart for device purchase (for insufficient balance flow).""" - await db.refresh(user, ['subscriptions']) - subscription = user.subscription + subscription = await resolve_subscription(db, user, subscription_id) if not subscription: raise HTTPException( @@ -619,12 +625,12 @@ async def save_devices_cart( @router.get('/devices/price') async def get_device_price( devices: int = 1, + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Get price for additional devices.""" - await db.refresh(user, ['subscriptions']) - subscription = user.subscription + subscription = await resolve_subscription(db, user, subscription_id) if not subscription or subscription.status not in ['active', 'trial']: return { @@ -727,15 +733,16 @@ async def get_device_price( @router.get('/devices') async def get_devices( + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, Any]: """Get list of connected devices.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', @@ -745,7 +752,7 @@ async def get_devices( return { 'devices': [], 'total': 0, - 'device_limit': user.subscription.device_limit or 0, + 'device_limit': subscription.device_limit or 0, } try: @@ -773,7 +780,7 @@ async def get_devices( return { 'devices': formatted_devices, 'total': response.get('total', len(formatted_devices)), - 'device_limit': user.subscription.device_limit or 0, + 'device_limit': subscription.device_limit or 0, } except Exception as e: @@ -781,22 +788,23 @@ async def get_devices( return { 'devices': [], 'total': 0, - 'device_limit': user.subscription.device_limit or 0, + 'device_limit': subscription.device_limit or 0, } @router.delete('/devices/{hwid}') async def delete_device( hwid: str, + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, Any]: """Delete a specific device by HWID.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', @@ -830,15 +838,16 @@ async def delete_device( @router.delete('/devices') async def delete_all_devices( + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, Any]: """Delete all connected devices.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', @@ -901,15 +910,16 @@ async def delete_all_devices( @router.get('/devices/reduction-info') async def get_device_reduction_info( + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, Any]: """Get info about device limit reduction availability.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: return { 'available': False, 'reason': 'No subscription found', @@ -919,8 +929,6 @@ async def get_device_reduction_info( 'connected_devices_count': 0, } - subscription = user.subscription - # Check if it's a trial subscription if subscription.is_trial: return { @@ -979,6 +987,7 @@ async def get_device_reduction_info( @router.post('/devices/reduce') async def reduce_devices( request: dict[str, int], + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> dict[str, Any]: @@ -993,11 +1002,13 @@ async def reduce_devices( ) # Lock subscription to prevent concurrent device modifications + _sub_filter = ( + Subscription.id == subscription_id + if subscription_id and settings.is_multi_tariff_enabled() + else Subscription.user_id == user.id + ) result = await db.execute( - select(Subscription) - .where(Subscription.user_id == user.id) - .with_for_update() - .execution_options(populate_existing=True) + select(Subscription).where(_sub_filter).with_for_update().execution_options(populate_existing=True) ) subscription = result.scalar_one_or_none() diff --git a/app/cabinet/routes/subscription_modules/helpers.py b/app/cabinet/routes/subscription_modules/helpers.py index fe56b91f..0156b699 100644 --- a/app/cabinet/routes/subscription_modules/helpers.py +++ b/app/cabinet/routes/subscription_modules/helpers.py @@ -19,6 +19,38 @@ from ...schemas.subscription import ( logger = structlog.get_logger(__name__) +async def resolve_subscription( + db: AsyncSession, + user: User, + subscription_id: int | None, +) -> Subscription | None: + """Resolve target subscription: by ID in multi-tariff mode, or legacy fallback. + + Args: + db: Database session. + user: Current user. + subscription_id: Optional subscription ID (from query param). + + Returns: + Target Subscription or None if not found. + + Raises: + HTTPException: If subscription_id provided but not found for this user. + """ + from fastapi import HTTPException + + from app.database.crud.subscription import get_subscription_by_id_for_user + + if subscription_id and settings.is_multi_tariff_enabled(): + subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id) + if not subscription: + raise HTTPException(status_code=404, detail='Subscription not found') + return subscription + + await db.refresh(user, ['subscriptions']) + return user.subscription + + def _get_addon_discount_percent( user: User | None, category: str, diff --git a/app/cabinet/routes/subscription_modules/purchase.py b/app/cabinet/routes/subscription_modules/purchase.py index cf45f8c2..f6ff2697 100644 --- a/app/cabinet/routes/subscription_modules/purchase.py +++ b/app/cabinet/routes/subscription_modules/purchase.py @@ -660,6 +660,7 @@ async def purchase_tariff( 'allowed_squads': tariff.allowed_squads or [], 'consume_promo_offer': promo_offer_discount_value > 0, 'source': 'cabinet', + 'subscription_id': existing_subscription.id if existing_subscription else None, } else: cart_data = { @@ -678,6 +679,7 @@ async def purchase_tariff( 'discount_percent': discount_percent, 'consume_promo_offer': promo_offer_discount_value > 0, 'source': 'cabinet', + 'subscription_id': existing_subscription.id if existing_subscription else None, } try: diff --git a/app/cabinet/routes/subscription_modules/servers.py b/app/cabinet/routes/subscription_modules/servers.py index a31623db..179a3ba7 100644 --- a/app/cabinet/routes/subscription_modules/servers.py +++ b/app/cabinet/routes/subscription_modules/servers.py @@ -10,13 +10,14 @@ from datetime import UTC, datetime from typing import Any import structlog -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User from app.services.subscription_service import SubscriptionService from ...dependencies import get_cabinet_db, get_current_cabinet_user +from .helpers import resolve_subscription logger = structlog.get_logger(__name__) @@ -28,23 +29,23 @@ router = APIRouter() async def get_available_countries( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, Any]: """Get available countries/servers for the user.""" from app.database.crud.server_squad import get_available_server_squads from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) promo_group_id = user.promo_group_id available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) connected_squads = [] days_left = 0 - if user.subscription: - connected_squads = user.subscription.connected_squads or [] - # Calculate days left for prorated pricing - if user.subscription.end_date: - delta = user.subscription.end_date - datetime.now(UTC) + if subscription: + connected_squads = subscription.connected_squads or [] + if subscription.end_date: + delta = subscription.end_date - datetime.now(UTC) days_left = max(0, delta.days) # Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag) @@ -64,10 +65,10 @@ async def get_available_countries( # Calculate prorated price if subscription exists prorated_price = discounted_price - if user.subscription and user.subscription.end_date: + if subscription and subscription.end_date: prorated_price, _ = calculate_prorated_price( discounted_price, - user.subscription.end_date, + subscription.end_date, ) countries.append( @@ -89,7 +90,7 @@ async def get_available_countries( return { 'countries': countries, 'connected_count': len(connected_squads), - 'has_subscription': user.subscription is not None, + 'has_subscription': subscription is not None, 'days_left': days_left, 'discount_percent': servers_discount_percent, } @@ -100,6 +101,7 @@ async def update_countries( request: dict[str, Any], user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, Any]: """Update subscription countries/servers.""" from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids @@ -109,15 +111,15 @@ async def update_countries( from app.database.models import TransactionType from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', ) - if user.subscription.is_trial: + if subscription.is_trial: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Country management is not available for trial subscriptions', @@ -130,7 +132,7 @@ async def update_countries( detail='At least one country must be selected', ) - current_countries = user.subscription.connected_squads or [] + current_countries = subscription.connected_squads or [] promo_group_id = user.promo_group_id available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) @@ -182,7 +184,7 @@ async def update_countries( charged_price, charged_days = calculate_prorated_price( discounted_per_month, - user.subscription.end_date, + subscription.end_date, ) total_cost += charged_price @@ -220,33 +222,33 @@ async def update_countries( if added: added_server_ids = await get_server_ids_by_uuids(db, added) if added_server_ids: - await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices) + await add_subscription_servers(db, subscription, added_server_ids, added_server_prices) try: await add_user_to_servers(db, added_server_ids) except Exception as e: logger.error('Ошибка обновления счётчика серверов', error=e) # Update connected squads - user.subscription.connected_squads = selected_countries - user.subscription.updated_at = datetime.now(UTC) + subscription.connected_squads = selected_countries + subscription.updated_at = datetime.now(UTC) await db.commit() # Sync with RemnaWave try: subscription_service = SubscriptionService() if getattr(user, 'remnawave_uuid', None): - await subscription_service.update_remnawave_user(db, user.subscription, sync_squads=True) + await subscription_service.update_remnawave_user(db, subscription, sync_squads=True) else: - await subscription_service.create_remnawave_user(db, user.subscription) + await subscription_service.create_remnawave_user(db, subscription) except Exception as e: logger.error('Failed to sync countries with RemnaWave', error=e) - await db.refresh(user.subscription) + await db.refresh(subscription) return { 'message': 'Countries updated successfully', 'added': added_names, 'removed': removed_names, 'amount_paid_kopeks': total_cost, - 'connected_squads': user.subscription.connected_squads, + 'connected_squads': subscription.connected_squads, } diff --git a/app/cabinet/routes/subscription_modules/tariff_switch.py b/app/cabinet/routes/subscription_modules/tariff_switch.py index 81dca760..56784b97 100644 --- a/app/cabinet/routes/subscription_modules/tariff_switch.py +++ b/app/cabinet/routes/subscription_modules/tariff_switch.py @@ -10,7 +10,7 @@ from datetime import UTC, datetime, timedelta from typing import Any import structlog -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -25,7 +25,7 @@ from app.services.subscription_service import SubscriptionService from ...dependencies import get_cabinet_db, get_current_cabinet_user from ...schemas.subscription import TariffPurchaseRequest -from .helpers import _subscription_to_response +from .helpers import _subscription_to_response, resolve_subscription logger = structlog.get_logger(__name__) @@ -38,6 +38,7 @@ async def preview_tariff_switch( request: TariffPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, Any]: """Preview tariff switch - shows cost calculation.""" if not settings.is_tariffs_mode(): @@ -46,16 +47,16 @@ async def preview_tariff_switch( detail='Tariffs mode is not enabled', ) - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription or not user.subscription.tariff_id: + if not subscription or not subscription.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='No active subscription with tariff', ) # Use actual_status for correct status check (handles time-based expiration) - actual_status = user.subscription.actual_status + actual_status = subscription.actual_status if actual_status == 'expired': # For expired subscriptions, user should purchase a new tariff, not switch raise HTTPException( @@ -76,7 +77,7 @@ async def preview_tariff_switch( }, ) - current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) new_tariff = await get_tariff_by_id(db, request.tariff_id) if not new_tariff or not new_tariff.is_active: @@ -85,7 +86,7 @@ async def preview_tariff_switch( detail='Tariff not found or inactive', ) - if user.subscription.tariff_id == request.tariff_id: + if subscription.tariff_id == request.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Already on this tariff', @@ -105,8 +106,8 @@ async def preview_tariff_switch( # Calculate remaining days remaining_days = 0 - if user.subscription.end_date and user.subscription.end_date > datetime.now(UTC): - delta = user.subscription.end_date - datetime.now(UTC) + if subscription.end_date and subscription.end_date > datetime.now(UTC): + delta = subscription.end_date - datetime.now(UTC) remaining_days = max(0, delta.days) # Calculate switch cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily) @@ -157,6 +158,7 @@ async def switch_tariff( request: TariffPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, Any]: """Switch to a different tariff without changing end date.""" if not settings.is_tariffs_mode(): @@ -165,9 +167,9 @@ async def switch_tariff( detail='Tariffs mode is not enabled', ) - await db.refresh(user, ['subscriptions']) + resolved = await resolve_subscription(db, user, subscription_id) - if not user.subscription or not user.subscription.tariff_id: + if not resolved or not resolved.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='No active subscription with tariff', @@ -176,7 +178,7 @@ async def switch_tariff( # Lock subscription row to prevent concurrent tariff switches locked_result = await db.execute( select(Subscription) - .where(Subscription.id == user.subscription.id) + .where(Subscription.id == resolved.id) .with_for_update() .execution_options(populate_existing=True) ) @@ -204,7 +206,7 @@ async def switch_tariff( }, ) - current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) new_tariff = await get_tariff_by_id(db, request.tariff_id) if not new_tariff or not new_tariff.is_active: @@ -213,7 +215,7 @@ async def switch_tariff( detail='Tariff not found or inactive', ) - if user.subscription.tariff_id == request.tariff_id: + if subscription.tariff_id == request.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Already on this tariff', @@ -337,8 +339,7 @@ async def switch_tariff( # Re-load subscription to avoid MissingGreenlet from expired lazy relationship # (subtract_user_balance re-selects User with populate_existing=True which expires relationships) - await db.refresh(user, ['subscriptions']) - subscription = user.subscription + await db.refresh(subscription) subscription.tariff_id = new_tariff.id subscription.traffic_limit_gb = new_tariff.traffic_limit_gb diff --git a/app/cabinet/routes/subscription_modules/traffic.py b/app/cabinet/routes/subscription_modules/traffic.py index 7b656c20..e64377c1 100644 --- a/app/cabinet/routes/subscription_modules/traffic.py +++ b/app/cabinet/routes/subscription_modules/traffic.py @@ -13,7 +13,7 @@ from datetime import UTC, datetime from typing import Any import structlog -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -32,7 +32,7 @@ from ...schemas.subscription import ( TrafficPackageResponse, TrafficPurchaseRequest, ) -from .helpers import _apply_addon_discount +from .helpers import _apply_addon_discount, resolve_subscription logger = structlog.get_logger(__name__) @@ -44,18 +44,18 @@ router = APIRouter() async def get_traffic_packages( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ): """Get available traffic packages.""" from app.database.crud.tariff import get_tariff_by_id - from app.database.crud.user import get_user_by_id - fresh_user = await get_user_by_id(db, user.id) - if not fresh_user or not fresh_user.subscription: + subscription = await resolve_subscription(db, user, subscription_id) + if not subscription: return [] # Режим тарифов - берём пакеты из тарифа - if settings.is_tariffs_mode() and fresh_user.subscription.tariff_id: - tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if settings.is_tariffs_mode() and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) if not tariff: return [] @@ -89,8 +89,8 @@ async def get_traffic_packages( return [] # Проверяем настройку тарифа пользователя (allow_traffic_topup) - if fresh_user.subscription.tariff_id: - tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff and not tariff.allow_traffic_topup: return [] @@ -120,6 +120,7 @@ async def purchase_traffic( request: TrafficPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ): """Purchase additional traffic.""" if getattr(user, 'restriction_subscription', False): @@ -132,15 +133,13 @@ async def purchase_traffic( from app.database.crud.tariff import get_tariff_by_id from app.utils.pricing_utils import calculate_prorated_price - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', ) - - subscription = user.subscription tariff = None base_price_kopeks = 0 is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id @@ -386,11 +385,11 @@ async def save_traffic_cart( request: TrafficPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, bool]: """Save cart for traffic purchase (for insufficient balance flow).""" - await db.refresh(user, ['subscriptions']) - subscription = user.subscription + subscription = await resolve_subscription(db, user, subscription_id) if not subscription: raise HTTPException( @@ -496,25 +495,26 @@ async def switch_traffic_package( request: TrafficPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ) -> dict[str, Any]: """Switch to a different traffic package (change limit).""" from app.utils.pricing_utils import calculate_prorated_price - await db.refresh(user, ['subscriptions']) + subscription = await resolve_subscription(db, user, subscription_id) - if not user.subscription: + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No subscription found', ) - if user.subscription.is_trial: + if subscription.is_trial: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='Traffic management is only available for paid subscriptions', ) - current_traffic = user.subscription.traffic_limit_gb or 0 + current_traffic = subscription.traffic_limit_gb or 0 new_traffic = request.gb if current_traffic == new_traffic: @@ -554,7 +554,7 @@ async def switch_traffic_package( ) # Prorated calculation - final_price, days_charged = calculate_prorated_price(price_diff, user.subscription.end_date) + final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date) if user.balance_kopeks < final_price: raise HTTPException( @@ -590,25 +590,25 @@ async def switch_traffic_package( from app.database.models import TrafficPurchase - await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id)) - user.subscription.traffic_limit_gb = new_traffic - user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch - user.subscription.traffic_reset_at = None # Reset traffic reset date - user.subscription.updated_at = datetime.now(UTC) + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + subscription.traffic_limit_gb = new_traffic + subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch + subscription.traffic_reset_at = None # Reset traffic reset date + subscription.updated_at = datetime.now(UTC) await db.commit() # Sync with RemnaWave try: subscription_service = SubscriptionService() if getattr(user, 'remnawave_uuid', None): - await subscription_service.update_remnawave_user(db, user.subscription) + await subscription_service.update_remnawave_user(db, subscription) else: - await subscription_service.create_remnawave_user(db, user.subscription) + await subscription_service.create_remnawave_user(db, subscription) except Exception as e: logger.error('Failed to sync traffic switch with RemnaWave', error=e) await db.refresh(user) - await db.refresh(user.subscription) + await db.refresh(subscription) return { 'success': True, @@ -633,23 +633,26 @@ TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds async def refresh_traffic( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), + subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'), ): """ Refresh traffic usage from RemnaWave panel. Rate limited to 1 request per 60 seconds. """ - if not user.subscription: + subscription = await resolve_subscription(db, user, subscription_id) + if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail='No active subscription', ) - # Используем user.id для rate limit и кеша (работает и для email-пользователей) - user_cache_id = user.id + # Use per-subscription key when subscription_id is available so that refreshing + # Sub B is not blocked by a previous refresh of Sub A (multi-tariff mode). + cache_suffix = f'{user.id}_{subscription_id}' if subscription_id is not None else str(user.id) # Check rate limit is_limited = await RateLimitCache.is_rate_limited( - user_cache_id, + cache_suffix, 'traffic_refresh', TRAFFIC_REFRESH_RATE_LIMIT, TRAFFIC_REFRESH_RATE_WINDOW, @@ -657,7 +660,7 @@ async def refresh_traffic( if is_limited: # Check if we have cached data - traffic_cache_key = cache_key('traffic', user_cache_id) + traffic_cache_key = cache_key('traffic', cache_suffix) cached_data = await cache.get(traffic_cache_key) if cached_data: @@ -690,17 +693,17 @@ async def refresh_traffic( if not traffic_stats: # Return current database values if RemnaWave unavailable traffic_data = { - 'traffic_used_bytes': int((user.subscription.traffic_used_gb or 0) * (1024**3)), - 'traffic_used_gb': round(user.subscription.traffic_used_gb or 0, 2), - 'traffic_limit_bytes': int((user.subscription.traffic_limit_gb or 0) * (1024**3)), - 'traffic_limit_gb': user.subscription.traffic_limit_gb or 0, + 'traffic_used_bytes': int((subscription.traffic_used_gb or 0) * (1024**3)), + 'traffic_used_gb': round(subscription.traffic_used_gb or 0, 2), + 'traffic_limit_bytes': int((subscription.traffic_limit_gb or 0) * (1024**3)), + 'traffic_limit_gb': subscription.traffic_limit_gb or 0, 'traffic_used_percent': round( - ((user.subscription.traffic_used_gb or 0) / (user.subscription.traffic_limit_gb or 1)) * 100 - if user.subscription.traffic_limit_gb + ((subscription.traffic_used_gb or 0) / (subscription.traffic_limit_gb or 1)) * 100 + if subscription.traffic_limit_gb else 0, 1, ), - 'is_unlimited': (user.subscription.traffic_limit_gb or 0) == 0, + 'is_unlimited': (subscription.traffic_limit_gb or 0) == 0, } return { 'success': True, @@ -711,13 +714,13 @@ async def refresh_traffic( # Update subscription with fresh data used_gb = traffic_stats.get('used_traffic_gb', 0) - if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01: - user.subscription.traffic_used_gb = used_gb - user.subscription.updated_at = datetime.now(UTC) + if abs((subscription.traffic_used_gb or 0) - used_gb) > 0.01: + subscription.traffic_used_gb = used_gb + subscription.updated_at = datetime.now(UTC) await db.commit() # Calculate percentage - limit_gb = user.subscription.traffic_limit_gb or 0 + limit_gb = subscription.traffic_limit_gb or 0 if limit_gb > 0: percent = min(100, (used_gb / limit_gb) * 100) else: @@ -735,7 +738,7 @@ async def refresh_traffic( } # Cache the result - traffic_cache_key = cache_key('traffic', user_cache_id) + traffic_cache_key = cache_key('traffic', cache_suffix) await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL) return { diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py index 5066b2b3..db29b037 100644 --- a/app/cabinet/routes/websocket.py +++ b/app/cabinet/routes/websocket.py @@ -317,7 +317,8 @@ async def notify_user_balance_change( async def notify_user_subscription_activated( user_id: int, - expires_at: str, + subscription_id: int | None = None, + expires_at: str = '', tariff_name: str = '', ) -> None: """Уведомить пользователя об активации подписки.""" @@ -325,6 +326,7 @@ async def notify_user_subscription_activated( user_id, { 'type': 'subscription.activated', + 'subscription_id': subscription_id, 'expires_at': expires_at, 'tariff_name': tariff_name, }, @@ -359,7 +361,8 @@ async def notify_user_subscription_expired(user_id: int) -> None: async def notify_user_subscription_renewed( user_id: int, - new_expires_at: str, + subscription_id: int | None = None, + new_expires_at: str = '', amount_kopeks: int = 0, ) -> None: """Уведомить пользователя о продлении подписки.""" @@ -367,6 +370,7 @@ async def notify_user_subscription_renewed( user_id, { 'type': 'subscription.renewed', + 'subscription_id': subscription_id, 'new_expires_at': new_expires_at, 'amount_kopeks': amount_kopeks, 'amount_rubles': amount_kopeks / 100, diff --git a/app/cabinet/schemas/traffic.py b/app/cabinet/schemas/traffic.py index dbcabe8d..54647ee5 100644 --- a/app/cabinet/schemas/traffic.py +++ b/app/cabinet/schemas/traffic.py @@ -9,18 +9,31 @@ class TrafficNodeInfo(BaseModel): country_code: str +class SubscriptionTrafficInfo(BaseModel): + """Per-subscription traffic metadata for multi-subscription display.""" + + subscription_id: int + tariff_name: str | None + status: str | None + traffic_limit_gb: float + device_limit: int + + class UserTrafficItem(BaseModel): user_id: int telegram_id: int | None username: str | None email: str | None full_name: str + # Primary subscription fields (backward compat — reflect the active/first sub) tariff_name: str | None subscription_status: str | None traffic_limit_gb: float device_limit: int node_traffic: dict[str, int] # {node_uuid: total_bytes} total_bytes: int + # All subscriptions for multi-subscription display + subscriptions: list[SubscriptionTrafficInfo] = Field(default_factory=list) class TrafficUsageResponse(BaseModel): @@ -34,12 +47,24 @@ class TrafficUsageResponse(BaseModel): available_statuses: list[str] +class SubscriptionEnrichmentInfo(BaseModel): + """Per-subscription enrichment (dates) for multi-subscription display.""" + + subscription_id: int + tariff_name: str | None + start_date: str | None + end_date: str | None + + class UserTrafficEnrichment(BaseModel): devices_connected: int = 0 total_spent_kopeks: int = 0 + # Primary subscription dates (backward compat — reflect the active/first sub) subscription_start_date: str | None = None subscription_end_date: str | None = None last_node_name: str | None = None + # All subscriptions for multi-subscription display + subscriptions: list[SubscriptionEnrichmentInfo] = Field(default_factory=list) class TrafficEnrichmentResponse(BaseModel): diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 7c1f0a8d..321558d8 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -177,9 +177,12 @@ class UserDetailResponse(BaseModel): last_activity: datetime | None = None cabinet_last_login: datetime | None = None - # Subscription + # Subscription (legacy single, kept for backward compat) subscription: UserSubscriptionInfo | None = None + # All subscriptions (multi-tariff) + subscriptions: list[UserSubscriptionInfo] = [] + # Promo group promo_group: UserPromoGroupInfo | None = None @@ -285,6 +288,9 @@ class UpdateSubscriptionRequest(BaseModel): ..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel' ) + # Target subscription (required in multi-tariff mode for non-create actions) + subscription_id: int | None = Field(None, description='Subscription ID to target (multi-tariff)') + # For extend action days: int | None = Field(None, ge=1, le=3650, description='Days to extend') diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index 59f07a20..24513297 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -351,18 +351,26 @@ class EmailNotificationTemplates: """Template for subscription expiring notification.""" days_left = context.get('days_left', 0) expires_at = context.get('expires_at', '') + tariff_name = html.escape(context.get('tariff_name', '')) + tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else '' + tariff_suffix_en = f' "{tariff_name}"' if tariff_name else '' + tariff_line_ru = f'
Тариф: {tariff_name}
' if tariff_name else '' + tariff_line_en = f'Plan: {tariff_name}
' if tariff_name else '' + tariff_line_zh = f'套餐: {tariff_name}
' if tariff_name else '' + tariff_line_ua = f'Тариф: {tariff_name}
' if tariff_name else '' subjects = { - 'ru': f'Подписка истекает через {days_left} дн.', - 'en': f'Subscription expires in {days_left} day(s)', + 'ru': f'Подписка{tariff_suffix_ru} истекает через {days_left} дн.', + 'en': f'Subscription{tariff_suffix_en} expires in {days_left} day(s)', 'zh': f'订阅将在 {days_left} 天后到期', - 'ua': f'Підписка закінчується через {days_left} дн.', + 'ua': f'Підписка{tariff_suffix_ru} закінчується через {days_left} дн.', } bodies = { 'ru': f"""Ваша подписка истекает через {days_left} дн.
Дата истечения: {expires_at}
Your subscription expires in {days_left} day(s).
Expiration date: {expires_at}
您的订阅将在 {days_left} 天后到期。
到期日期: {expires_at}
Ваша підписка закінчується через {days_left} дн.
Дата закінчення: {expires_at}
Тариф: {tariff_name}
' if tariff_name else '' + tariff_line_en = f'Plan: {tariff_name}
' if tariff_name else '' + tariff_line_zh = f'套餐: {tariff_name}
' if tariff_name else '' + tariff_line_ua = f'Тариф: {tariff_name}
' if tariff_name else '' + subjects = { - 'ru': 'Подписка истекла', - 'en': 'Subscription Expired', + 'ru': f'Подписка{tariff_suffix_ru} истекла', + 'en': f'Subscription{tariff_suffix_en} Expired', 'zh': '订阅已到期', - 'ua': 'Підписка закінчилась', + 'ua': f'Підписка{tariff_suffix_ru} закінчилась', } bodies = { 'ru': f"""Ваша подписка истекла. Доступ к VPN отключён.
Оформите новую подписку, чтобы продолжить использование сервиса.
@@ -424,6 +444,7 @@ class EmailNotificationTemplates: 'en': f"""Your subscription has expired. VPN access has been disabled.
Purchase a new subscription to continue using our service.
@@ -432,6 +453,7 @@ class EmailNotificationTemplates: 'zh': f"""您的订阅已到期。VPN访问已被禁用。
请购买新订阅以继续使用我们的服务。
@@ -440,6 +462,7 @@ class EmailNotificationTemplates: 'ua': f"""Ваша підписка закінчилась. Доступ до VPN вимкнено.
Оформіть нову підписку, щоб продовжити використання сервісу.
@@ -455,18 +478,24 @@ class EmailNotificationTemplates: def _subscription_renewed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription renewed notification.""" new_expires_at = context.get('new_expires_at', '') + tariff_name = html.escape(context.get('tariff_name', '')) + tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else '' + tariff_suffix_en = f' "{tariff_name}"' if tariff_name else '' + tariff_line_ru = f'Тариф: {tariff_name}
' if tariff_name else '' + tariff_line_en = f'Plan: {tariff_name}
' if tariff_name else '' subjects = { - 'ru': 'Подписка продлена', - 'en': 'Subscription Renewed', + 'ru': f'Подписка{tariff_suffix_ru} продлена', + 'en': f'Subscription{tariff_suffix_en} Renewed', 'zh': '订阅已续订', - 'ua': 'Підписку продовжено', + 'ua': f'Підписку{tariff_suffix_ru} продовжено', } bodies = { 'ru': f"""Ваша подписка была успешно продлена.
Новая дата истечения: {new_expires_at}
Your subscription has been successfully renewed.
New expiration date: {new_expires_at}
Тариф: {tariff_name}
' if tariff_name else '' + tariff_line_en = f'Plan: {tariff_name}
' if tariff_name else '' subjects = { - 'ru': 'Подписка активирована', - 'en': 'Subscription Activated', + 'ru': f'Подписка{tariff_suffix_ru} активирована', + 'en': f'Subscription{tariff_suffix_en} Activated', 'zh': '订阅已激活', - 'ua': 'Підписку активовано', + 'ua': f'Підписку{tariff_suffix_ru} активовано', } bodies = { 'ru': f"""Ваша VPN подписка успешно активирована.
Действует до: {expires_at}
Your VPN subscription has been successfully activated.
Valid until: {expires_at}
{subscription.subscription_url}'
- keyboard = _build_subscription_detail_keyboard(sub_id)
+ keyboard = _build_subscription_detail_keyboard(sub_id, sub=subscription)
if callback.message:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML')
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 772564ff..7124254e 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -189,6 +189,9 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
await db.refresh(db_user)
texts = get_texts(db_user.language)
+ # Multi-tariff: this branch is only reached in single-tariff mode (multi-tariff
+ # is redirected to show_my_subscriptions above). db_user.subscription returns
+ # the first active or most recent subscription, which is correct here.
subscription = db_user.subscription
if not subscription:
@@ -595,6 +598,9 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
# Проверяем, использовал ли пользователь триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
+ # Multi-tariff note: db_user.subscription returns the first active/most recent
+ # subscription. In multi-tariff mode a user can have multiple subscriptions, but
+ # trial eligibility is still "has any subscription" so this check is correct.
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
@@ -794,6 +800,8 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
# Проверяем, использовал ли пользователь триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
+ # Multi-tariff note: db_user.subscription returns the first active/most recent
+ # subscription. Trial eligibility is "has any subscription" so this check is correct.
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
@@ -1313,6 +1321,8 @@ async def start_subscription_purchase(
keyboard,
)
+ # Multi-tariff note: this path is only reached in classic (non-tariff) mode.
+ # Tariff mode redirects to show_tariffs_list above. db_user.subscription is safe.
subscription = getattr(db_user, 'subscription', None)
if settings.is_devices_selection_enabled():
@@ -1580,7 +1590,26 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
return
texts = get_texts(db_user.language)
- subscription = db_user.subscription
+
+ if settings.is_multi_tariff_enabled():
+ parts = (callback.data or '').split(':')
+ sub_id = None
+ if len(parts) >= 2:
+ try:
+ sub_id = int(parts[-1])
+ except (ValueError, TypeError):
+ pass
+ if sub_id:
+ from app.database.crud.subscription import get_subscription_by_id_for_user
+
+ subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
+ if not subscription:
+ await callback.answer('Подписка не найдена', show_alert=True)
+ return
+ else:
+ subscription = db_user.subscription
+ else:
+ subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.message.edit_text(
@@ -1770,6 +1799,10 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
)
return
+ # Multi-tariff note: this handler is registered for 'extend_period_' callbacks
+ # which are only shown in the classic (non-tariff) renewal flow. In multi-tariff
+ # mode, tariff-based renewal uses a different callback path. db_user.subscription
+ # is safe here as it only runs in single-subscription context.
subscription = db_user.subscription
if not subscription:
@@ -2314,6 +2347,9 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
await callback.answer()
return
+ # Multi-tariff note: confirm_purchase runs in classic (non-tariff) mode only.
+ # In tariff mode, start_subscription_purchase redirects to show_tariffs_list.
+ # db_user.subscription is the correct single subscription for trial conversion.
existing_subscription = db_user.subscription
if devices_selection_enabled:
selected_devices = devices_selected
@@ -2787,6 +2823,10 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
return
texts = get_texts(db_user.language)
+ # Multi-tariff note: this handler is reached via 'subscription_settings' callback
+ # which is shown in the single-subscription info keyboard. In multi-tariff mode,
+ # show_subscription_info redirects to show_my_subscriptions, so per-subscription
+ # settings are handled from the my_subscriptions flow. db_user.subscription is safe.
subscription = db_user.subscription
# Получаем тариф подписки если есть
@@ -2874,6 +2914,10 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
from app.database.crud.tariff import get_tariff_by_id
texts = get_texts(db_user.language)
+ # Multi-tariff note: 'toggle_daily_subscription_pause' callback is shown inside
+ # the subscription info view which redirects to show_my_subscriptions in multi-tariff
+ # mode. Per-subscription pause is therefore routed correctly before reaching here.
+ # db_user.subscription is safe as a fallback for single-tariff daily subscriptions.
subscription = db_user.subscription
if not subscription:
@@ -3066,6 +3110,8 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
# Проверяем права на триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
+ # Multi-tariff note: trial eligibility is "has any subscription", so checking
+ # db_user.subscription (first active/most recent) is correct in all modes.
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
@@ -3461,6 +3507,8 @@ async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: Us
# Проверяем права на триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
+ # Multi-tariff note: trial eligibility is "has any subscription", so checking
+ # db_user.subscription (first active/most recent) is correct in all modes.
trial_blocked = False
if db_user.has_had_paid_subscription:
trial_blocked = True
diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py
index 4669f397..f5779e03 100644
--- a/app/handlers/subscription/tariff_purchase.py
+++ b/app/handlers/subscription/tariff_purchase.py
@@ -15,6 +15,7 @@ from app.database.crud.subscription import (
create_paid_subscription,
extend_subscription,
get_active_subscriptions_by_user_id,
+ get_subscription_by_id_for_user,
get_subscription_by_user_id,
)
from app.database.crud.tariff import get_tariff_by_id, get_tariffs_for_user
@@ -576,6 +577,14 @@ async def select_tariff(
else:
missing = daily_price - user_balance
+ # Ищем существующую подписку для передачи subscription_id в корзину
+ if settings.is_multi_tariff_enabled():
+ from app.database.crud.subscription import get_subscription_by_user_and_tariff
+
+ _daily_existing_sub = await get_subscription_by_user_and_tariff(db, db_user.id, tariff_id)
+ else:
+ _daily_existing_sub = await get_subscription_by_user_id(db, db_user.id)
+
# Сохраняем данные корзины для автопокупки суточного тарифа
cart_data = {
'cart_mode': 'daily_tariff_purchase',
@@ -591,6 +600,7 @@ async def select_tariff(
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
+ 'subscription_id': _daily_existing_sub.id if _daily_existing_sub else None,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -1143,6 +1153,14 @@ async def select_tariff_period(
# Недостаточно средств - сохраняем корзину для автопокупки
missing = final_price - user_balance
+ # Ищем существующую подписку для передачи subscription_id в корзину
+ if settings.is_multi_tariff_enabled():
+ from app.database.crud.subscription import get_subscription_by_user_and_tariff
+
+ _existing_sub = await get_subscription_by_user_and_tariff(db, db_user.id, tariff_id)
+ else:
+ _existing_sub = await get_subscription_by_user_id(db, db_user.id)
+
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'tariff_purchase',
@@ -1158,6 +1176,7 @@ async def select_tariff_period(
'device_limit': tariff.device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
+ 'subscription_id': _existing_sub.id if _existing_sub else None,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
@@ -1783,8 +1802,18 @@ async def show_tariff_extend(
get_texts(db_user.language)
if settings.is_multi_tariff_enabled():
- active_subs = await get_active_subscriptions_by_user_id(db, db_user.id)
- subscription = active_subs[0] if active_subs else None
+ sub_id = None
+ parts = (callback.data or '').split(':')
+ if len(parts) >= 2:
+ try:
+ sub_id = int(parts[-1])
+ except (ValueError, TypeError):
+ pass
+ if sub_id:
+ subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
+ else:
+ active_subs = await get_active_subscriptions_by_user_id(db, db_user.id)
+ subscription = active_subs[0] if active_subs else None
else:
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription or not subscription.tariff_id:
diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py
index 6144857b..1c0f7a75 100644
--- a/app/handlers/subscription/traffic.py
+++ b/app/handlers/subscription/traffic.py
@@ -135,6 +135,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A
packages,
subscription.end_date,
traffic_discount_percent,
+ sub_id=sub_id,
),
parse_mode='HTML',
)
@@ -182,6 +183,7 @@ async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: A
db_user.language,
subscription.end_date,
traffic_discount_percent,
+ sub_id=sub_id,
),
parse_mode='HTML',
)
@@ -297,22 +299,10 @@ async def confirm_reset_traffic(callback: types.CallbackQuery, db_user: User, db
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language)
- if settings.is_multi_tariff_enabled():
- # Re-resolve after lock since db_user was refreshed
- # Get sub_id from callback data
- parts = (callback.data or '').split(':')
- sub_id = None
- if len(parts) >= 2:
- try:
- sub_id = int(parts[-1])
- except (ValueError, TypeError):
- pass
- if sub_id:
- subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
- else:
- subscription = db_user.subscription
- else:
- subscription = db_user.subscription
+ # Re-resolve after lock since db_user was refreshed
+ subscription, _ = await _resolve_subscription(callback, db_user, db)
+ if subscription is None:
+ return
reset_price = _calculate_traffic_reset_price(subscription)
@@ -528,10 +518,10 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
from app.database.crud.user import lock_user_for_pricing
db_user = await lock_user_for_pricing(db, db_user.id)
- if settings.is_multi_tariff_enabled() and sub_id:
- subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
- else:
- subscription = db_user.subscription
+ # Re-resolve after lock since db_user was refreshed
+ subscription, _ = await _resolve_subscription(callback, db_user, db)
+ if subscription is None:
+ return
period_hint_days = _get_period_hint_from_subscription(subscription)
discounted_per_month, discount_per_month, traffic_discount_pct = PricingEngine.calculate_traffic_discount(
@@ -868,22 +858,10 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
db_user = await lock_user_for_pricing(db, db_user.id)
texts = get_texts(db_user.language)
- if settings.is_multi_tariff_enabled():
- # Re-resolve after lock since db_user was refreshed
- # Get sub_id from callback data
- parts = (callback.data or '').split(':')
- sub_id = None
- if len(parts) >= 2:
- try:
- sub_id = int(parts[-1])
- except (ValueError, TypeError):
- pass
- if sub_id:
- subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
- else:
- subscription = db_user.subscription
- else:
- subscription = db_user.subscription
+ # Re-resolve after lock since db_user was refreshed
+ subscription, _ = await _resolve_subscription(callback, db_user, db)
+ if subscription is None:
+ return
current_traffic = subscription.traffic_limit_gb
# Recompute price under lock (callback-baked value may be stale)
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index c5eaa64a..5b7faddc 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -473,7 +473,12 @@ def _build_cabinet_main_menu_keyboard(
case 'subscription':
if not section_cfg.get('enabled', True):
continue
- sub_text = section_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
+ default_sub_text = (
+ texts.t('MY_SUBSCRIPTIONS_BUTTON', '📱 Мои подписки')
+ if settings.is_multi_tariff_enabled()
+ else texts.MENU_SUBSCRIPTION
+ )
+ sub_text = section_cfg.get('labels', {}).get(language, '') or default_sub_text
row_buttons.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
case 'balance':
@@ -645,7 +650,12 @@ def get_main_menu_keyboard(
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
- paired_buttons.append(InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data='menu_subscription'))
+ sub_btn_text = (
+ texts.t('MY_SUBSCRIPTIONS_BUTTON', '📱 Мои подписки')
+ if settings.is_multi_tariff_enabled()
+ else texts.MENU_SUBSCRIPTION
+ )
+ paired_buttons.append(InlineKeyboardButton(text=sub_btn_text, callback_data='menu_subscription'))
# Добавляем кнопку докупки трафика для лимитированных подписок
# В режиме тарифов проверяем tariff_id (детальная проверка в хендлере)
@@ -1824,6 +1834,11 @@ def get_yookassa_payment_keyboard(
def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
+ sub_btn_text = (
+ texts.t('MY_SUBSCRIPTIONS_BUTTON', '📱 Мои подписки')
+ if settings.is_multi_tariff_enabled()
+ else texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка')
+ )
return InlineKeyboardMarkup(
inline_keyboard=[
@@ -1832,17 +1847,18 @@ def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFA
text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'), callback_data='balance_topup'
)
],
- [
- build_miniapp_or_callback_button(
- text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), callback_data='menu_subscription'
- )
- ],
+ [build_miniapp_or_callback_button(text=sub_btn_text, callback_data='menu_subscription')],
]
)
def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
+ sub_btn_text = (
+ texts.t('MY_SUBSCRIPTIONS_BUTTON', '📱 Мои подписки')
+ if settings.is_multi_tariff_enabled()
+ else texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка')
+ )
return InlineKeyboardMarkup(
inline_keyboard=[
@@ -1856,11 +1872,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEF
text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'), callback_data='balance_topup'
)
],
- [
- build_miniapp_or_callback_button(
- text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), callback_data='menu_subscription'
- )
- ],
+ [build_miniapp_or_callback_button(text=sub_btn_text, callback_data='menu_subscription')],
]
)
@@ -1984,8 +1996,9 @@ def get_confirmation_keyboard(
)
-def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
+def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE, sub_id: int | None = None) -> InlineKeyboardMarkup:
texts = get_texts(language)
+ back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
return InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -1997,7 +2010,7 @@ def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
text=texts.t('AUTOPAY_SET_DAYS_BUTTON', '⚙️ Настроить дни'), callback_data='autopay_set_days'
)
],
- [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
+ [InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)],
]
)
@@ -2094,12 +2107,14 @@ def get_add_traffic_keyboard(
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None,
discount_percent: int = 0,
+ sub_id: int | None = None,
) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
+ back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
@@ -2123,7 +2138,7 @@ def get_add_traffic_keyboard(
callback_data='no_traffic_packages',
)
],
- [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
+ [InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)],
]
)
@@ -2158,7 +2173,7 @@ def get_add_traffic_keyboard(
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
- buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
+ buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)])
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -2168,6 +2183,7 @@ def get_add_traffic_keyboard_from_tariff(
packages: dict, # {gb: price_kopeks}
subscription_end_date: datetime = None,
discount_percent: int = 0,
+ sub_id: int | None = None,
) -> InlineKeyboardMarkup:
"""
Клавиатура для докупки трафика из настроек тарифа.
@@ -2177,10 +2193,12 @@ def get_add_traffic_keyboard_from_tariff(
packages: Словарь {ГБ: цена_в_копейках} из тарифа
subscription_end_date: Дата окончания подписки для расчета цены
discount_percent: Процент скидки
+ sub_id: ID подписки для формирования обратной ссылки в multi-tariff режиме
"""
texts = get_texts(language)
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
+ back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
if not packages:
return InlineKeyboardMarkup(
@@ -2191,7 +2209,7 @@ def get_add_traffic_keyboard_from_tariff(
callback_data='no_traffic_packages',
)
],
- [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
+ [InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)],
]
)
@@ -2223,7 +2241,7 @@ def get_add_traffic_keyboard_from_tariff(
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
- buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
+ buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)])
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -2395,8 +2413,10 @@ def get_manage_countries_keyboard(
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None,
discount_percent: int = 0,
+ sub_id: int | None = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
+ back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
# Считаем по дням (как в кабинете и подтверждении)
if subscription_end_date:
@@ -2471,7 +2491,7 @@ def get_manage_countries_keyboard(
buttons.append([InlineKeyboardButton(text=apply_text, callback_data='countries_apply')])
- buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
+ buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)])
return InlineKeyboardMarkup(inline_keyboard=buttons)
@@ -2479,11 +2499,13 @@ def get_manage_countries_keyboard(
def get_device_selection_keyboard(
language: str = DEFAULT_LANGUAGE,
platforms: list[dict] | None = None,
+ sub_id: int | None = None,
) -> InlineKeyboardMarkup:
from app.config import settings
from app.handlers.subscription.common import get_localized_value
texts = get_texts(language)
+ back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
keyboard: list[list[InlineKeyboardButton]] = []
@@ -2516,7 +2538,7 @@ def get_device_selection_keyboard(
]
)
- keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
+ keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_cb)])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -2527,10 +2549,12 @@ def get_connection_guide_keyboard(
device_type: str,
language: str = DEFAULT_LANGUAGE,
has_other_apps: bool = False,
+ sub_id: int | None = None,
) -> InlineKeyboardMarkup:
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
texts = get_texts(language)
+ back_cb = f'sm:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'menu_subscription'
keyboard: list[list[InlineKeyboardButton]] = []
@@ -2625,11 +2649,7 @@ def get_connection_guide_keyboard(
callback_data='subscription_connect',
)
],
- [
- InlineKeyboardButton(
- text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription'
- )
- ],
+ [InlineKeyboardButton(text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data=back_cb)],
]
)
@@ -2671,6 +2691,7 @@ def get_specific_app_keyboard(
app: dict,
device_type: str,
language: str = DEFAULT_LANGUAGE,
+ sub_id: int | None = None,
) -> InlineKeyboardMarkup:
# Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps"
return get_connection_guide_keyboard(
@@ -2679,6 +2700,7 @@ def get_specific_app_keyboard(
device_type,
language,
has_other_apps=True,
+ sub_id=sub_id,
)
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index a35ddfe9..387dca10 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1125,6 +1125,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 {mulenpay_name_html} payment\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure {mulenpay_name} platform.",
"MY_BALANCE_BUTTON": "💰 My balance",
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
+ "MY_SUBSCRIPTIONS_BUTTON": "📱 My subscriptions",
"MY_TICKETS_BUTTON": "📋 My tickets",
"MY_TICKETS_TITLE": "📋 Your tickets:",
"NO": "❌ No",
diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json
index cbbd6920..2eab3b7a 100644
--- a/app/localization/locales/fa.json
+++ b/app/localization/locales/fa.json
@@ -1146,6 +1146,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 شارژ MulenPay\n\nمبلغ وارد کنید.\nحداقل: {min_amount}، حداکثر: {max_amount}",
"MY_BALANCE_BUTTON": "💰 موجودی من",
"MY_SUBSCRIPTION_BUTTON": "📱 اشتراک من",
+ "MY_SUBSCRIPTIONS_BUTTON": "📱 اشتراکهای من",
"MY_TICKETS_BUTTON": "📋 تیکتهای من",
"MY_TICKETS_TITLE": "📋 تیکتهای من",
"NO": "❌ خیر",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 70e5d38e..5e9e423d 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1146,6 +1146,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 Оплата через {mulenpay_name_html}\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу {mulenpay_name}.",
"MY_BALANCE_BUTTON": "💰 Мой баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
+ "MY_SUBSCRIPTIONS_BUTTON": "📱 Мои подписки",
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
"NO": "❌ Нет",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index 2bcb7562..9b24c1f3 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -1064,6 +1064,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 Оплата через {mulenpay_name_html}\n\nВведіть суму для поповнення від 100 до 100 000 ₽.\nОплата відбувається через захищену платформу {mulenpay_name}.",
"MY_BALANCE_BUTTON": "💰 Мій баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя підписка",
+ "MY_SUBSCRIPTIONS_BUTTON": "📱 Мої підписки",
"MY_TICKETS_BUTTON": "📋 Мої тікети",
"MY_TICKETS_TITLE": "📋 Ваші тікети:",
"NO": "❌ Ні",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 68654fec..936bd0a3 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -1062,6 +1062,7 @@
"MULENPAY_TOPUP_PROMPT": "💳通过{mulenpay_name_html}付款\n\n请输入充值金额,范围100至100000₽。\n付款通过{mulenpay_name}安全平台进行。",
"MY_BALANCE_BUTTON": "💰我的余额",
"MY_SUBSCRIPTION_BUTTON": "📱我的订阅",
+"MY_SUBSCRIPTIONS_BUTTON": "📱 我的订阅",
"MY_TICKETS_BUTTON": "📋我的工单",
"MY_TICKETS_TITLE": "📋您的工单:",
"NO": "❌否",
diff --git a/app/services/account_merge_service.py b/app/services/account_merge_service.py
index 466006e9..82a8e64a 100644
--- a/app/services/account_merge_service.py
+++ b/app/services/account_merge_service.py
@@ -263,9 +263,27 @@ async def _handle_subscription_merge(
# Multi-tariff mode: transfer ALL subscriptions from secondary to primary
if settings.is_multi_tariff_enabled():
secondary_subs = list(getattr(secondary, 'subscriptions', None) or [])
+ secondary_legacy_uuid = secondary.remnawave_uuid
if secondary_subs:
for sub in secondary_subs:
sub.user_id = primary.id
+ sub_remnawave_uuid = getattr(sub, 'remnawave_uuid', None)
+ logger.info(
+ 'Transferred subscription during account merge',
+ subscription_id=sub.id,
+ tariff_id=getattr(sub, 'tariff_id', None),
+ from_user=secondary.id,
+ to_user=primary.id,
+ remnawave_uuid=sub_remnawave_uuid,
+ )
+ if sub_remnawave_uuid and secondary_legacy_uuid and sub_remnawave_uuid == secondary_legacy_uuid:
+ logger.warning(
+ 'Transferred subscription remnawave_uuid matches secondary legacy uuid — manual panel review required',
+ subscription_id=sub.id,
+ remnawave_uuid=sub_remnawave_uuid,
+ secondary_user_id=secondary.id,
+ primary_user_id=primary.id,
+ )
await db.flush()
logger.info(
'Мерж подписок (multi-tariff): перенесено подписок secondary на primary',
diff --git a/app/services/broadcast_service.py b/app/services/broadcast_service.py
index 67908635..8e9005dd 100644
--- a/app/services/broadcast_service.py
+++ b/app/services/broadcast_service.py
@@ -579,7 +579,14 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
await session.commit()
# Отключаем в Remnawave панели (вне транзакции)
- if user.remnawave_uuid:
+ from app.config import settings
+
+ if settings.is_multi_tariff_enabled():
+ await session.refresh(user, ['subscriptions'])
+ for sub in user.subscriptions or []:
+ if sub.remnawave_uuid:
+ await subscription_service.disable_remnawave_user(sub.remnawave_uuid)
+ elif user.remnawave_uuid:
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index 62b5105b..82856b7d 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -327,7 +327,14 @@ class MonitoringService:
return None
user = await get_user_by_id(db, subscription.user_id)
- if not user or not user.remnawave_uuid:
+ remnawave_uuid = (
+ subscription.remnawave_uuid
+ if settings.is_multi_tariff_enabled() and getattr(subscription, 'remnawave_uuid', None)
+ else user.remnawave_uuid
+ if user
+ else None
+ )
+ if not user or not remnawave_uuid:
logger.error('RemnaWave UUID не найден для пользователя', user_id=subscription.user_id)
return None
@@ -378,7 +385,7 @@ class MonitoringService:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
- uuid=user.remnawave_uuid,
+ uuid=remnawave_uuid,
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.DISABLED,
expire_at=subscription.end_date
if is_active
@@ -408,7 +415,7 @@ class MonitoringService:
status_text = 'активным' if is_active else 'истёкшим'
logger.info(
'✅ Обновлен RemnaWave пользователь со статусом',
- remnawave_uuid=user.remnawave_uuid,
+ remnawave_uuid=remnawave_uuid,
status_text=status_text,
)
return updated_user
diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py
index e29c70ca..d045bf1a 100644
--- a/app/services/payment/stars.py
+++ b/app/services/payment/stars.py
@@ -249,6 +249,7 @@ class TelegramStarsMixin:
db=db,
user_id=user.id,
period_days=period_days,
+ subscription_id=payload_data.subscription_id,
)
except Exception as error:
logger.error(
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
index b1ea6b64..82719e40 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -919,8 +919,12 @@ class YooKassaPaymentMixin:
# Активируем pending подписку пользователя
from app.database.crud.subscription import activate_pending_subscription
+ order_subscription_id = int(order_id) if order_id is not None else None
subscription = await activate_pending_subscription(
- db=db, user_id=user.id, period_days=subscription_period
+ db=db,
+ user_id=user.id,
+ period_days=subscription_period,
+ subscription_id=order_subscription_id,
)
if subscription:
diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py
index 1c75f2dd..ce4153c6 100644
--- a/app/services/promocode_service.py
+++ b/app/services/promocode_service.py
@@ -270,29 +270,29 @@ class PromoCodeService:
if not active_subs:
raise ValueError('no_subscription_for_days')
- # Extend ALL active subscriptions
- for subscription in active_subs:
- # Конвертация триала в платную подписку при активации промокода на дни
- if subscription.is_trial:
- subscription.is_trial = False
- if subscription.status == SubscriptionStatus.TRIAL.value:
- subscription.status = SubscriptionStatus.ACTIVE.value
- subscription.updated_at = datetime.now(UTC)
- logger.info(
- '🎓 Промокод: конвертация триала в платную подписку',
- subscription_id=subscription.id,
- code=promocode.code,
- )
+ # In multi-tariff mode extend only the first active subscription, not all
+ target_sub = active_subs[0]
+ # Конвертация триала в платную подписку при активации промокода на дни
+ if target_sub.is_trial:
+ target_sub.is_trial = False
+ if target_sub.status == SubscriptionStatus.TRIAL.value:
+ target_sub.status = SubscriptionStatus.ACTIVE.value
+ target_sub.updated_at = datetime.now(UTC)
+ logger.info(
+ '🎓 Промокод: конвертация триала в платную подписку',
+ subscription_id=target_sub.id,
+ code=promocode.code,
+ )
- await extend_subscription(db, subscription, promocode.subscription_days)
- await self.subscription_service.update_remnawave_user(db, subscription)
+ await extend_subscription(db, target_sub, promocode.subscription_days)
+ await self.subscription_service.update_remnawave_user(db, target_sub)
effects.append(f'⏰ Подписка продлена на {promocode.subscription_days} дней')
logger.info(
- '✅ Подписки пользователя продлены на дней в RemnaWave',
+ '✅ Подписка пользователя продлена на дней в RemnaWave',
_format_user_log=self._format_user_log(user),
subscription_days=promocode.subscription_days,
- subscriptions_count=len(active_subs),
+ subscription_id=target_sub.id,
)
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
diff --git a/app/services/remnawave_webhook_service.py b/app/services/remnawave_webhook_service.py
index 466872fe..05514580 100644
--- a/app/services/remnawave_webhook_service.py
+++ b/app/services/remnawave_webhook_service.py
@@ -825,12 +825,28 @@ class RemnaWaveWebhookService:
subscription.connected_squads = []
subscription.updated_at = datetime.now(UTC)
+ # In multi-tariff mode clear per-subscription UUID here
+ if settings.is_multi_tariff_enabled():
+ subscription.remnawave_uuid = None
+
# Remove SubscriptionServer link rows (panel user no longer exists)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
- # Clear remnawave linkage
- if user.remnawave_uuid:
- user.remnawave_uuid = None
+ # Clear remnawave linkage — only in single-tariff mode (multi-tariff uses per-subscription UUIDs)
+ if not settings.is_multi_tariff_enabled():
+ if user.remnawave_uuid:
+ user.remnawave_uuid = None
+ # In multi-tariff mode, subscription.remnawave_uuid was cleared above.
+ # If subscription was None (fallback path), extract panel UUID from data and
+ # clear it from the matching subscription manually.
+ elif subscription is None:
+ panel_uuid = data.get('uuid') or data.get('userUuid')
+ if panel_uuid:
+ for sub in getattr(user, 'subscriptions', None) or []:
+ if getattr(sub, 'remnawave_uuid', None) == panel_uuid:
+ sub.remnawave_uuid = None
+ sub.remnawave_short_uuid = None
+ break
await db.commit()
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 094698d8..23da27e7 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -160,7 +160,17 @@ async def _prepare_auto_extend_context(
subscription = await get_subscription_by_id_for_user(db, parsed_sub_id, user.id) if parsed_sub_id else None
else:
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
- subscription = active_subs[0] if active_subs else None
+ if len(active_subs) == 1:
+ subscription = active_subs[0]
+ elif len(active_subs) > 1:
+ logger.warning(
+ 'Multi-tariff: multiple active subscriptions found, skipping auto-extend without explicit subscription_id',
+ user_id=user.id,
+ count=len(active_subs),
+ )
+ return None
+ else:
+ subscription = None
else:
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is not None and saved_subscription_id is not None:
@@ -607,6 +617,7 @@ async def _auto_extend_subscription(
try:
await notify_user_subscription_renewed(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
new_expires_at=new_end_date.isoformat() if new_end_date else '',
amount_kopeks=prepared.price_kopeks,
)
@@ -956,6 +967,7 @@ async def _auto_purchase_tariff(
# Renewal of existing subscription
await notify_user_subscription_renewed(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=final_price,
)
@@ -963,6 +975,7 @@ async def _auto_purchase_tariff(
# New subscription activation
await notify_user_subscription_activated(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
tariff_name=tariff.name,
)
@@ -1290,6 +1303,7 @@ async def _auto_purchase_daily_tariff(
# Renewal/upgrade of existing subscription
await notify_user_subscription_renewed(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=final_price,
)
@@ -1297,6 +1311,7 @@ async def _auto_purchase_daily_tariff(
# New subscription activation
await notify_user_subscription_activated(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
tariff_name=tariff.name,
)
@@ -1653,7 +1668,17 @@ async def _auto_add_traffic(
subscription = await get_subscription_by_id_for_user(db, parsed_sub_id, user.id) if parsed_sub_id else None
else:
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
- subscription = active_subs[0] if active_subs else None
+ if len(active_subs) == 1:
+ subscription = active_subs[0]
+ elif len(active_subs) > 1:
+ logger.warning(
+ 'Multi-tariff: multiple active subscriptions found, skipping auto-add-traffic without explicit subscription_id',
+ user_id=user.id,
+ count=len(active_subs),
+ )
+ return False
+ else:
+ subscription = None
else:
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
@@ -2271,6 +2296,7 @@ async def try_auto_extend_expired_after_topup(
try:
await notify_user_subscription_renewed(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
new_expires_at=new_end_date.isoformat() if new_end_date else '',
amount_kopeks=renewal_cost,
)
@@ -2628,6 +2654,7 @@ async def try_resume_disabled_daily_after_topup(
try:
await notify_user_subscription_renewed(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=daily_price,
)
@@ -2900,6 +2927,7 @@ async def auto_purchase_saved_cart_after_topup(
# Trial conversion = activation
await notify_user_subscription_activated(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '',
tariff_name='',
)
@@ -2907,6 +2935,7 @@ async def auto_purchase_saved_cart_after_topup(
# Regular purchase = renewal or new activation
await notify_user_subscription_renewed(
user_id=user.id,
+ subscription_id=subscription.id if subscription else None,
new_expires_at=subscription.end_date.isoformat() if subscription and subscription.end_date else '',
amount_kopeks=pricing.final_total,
)
diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py
index 1c4115fd..344116a9 100644
--- a/app/services/subscription_purchase_service.py
+++ b/app/services/subscription_purchase_service.py
@@ -1025,12 +1025,21 @@ class MiniAppSubscriptionPurchaseService:
refresh_error=refresh_error,
)
else:
- result = await db.execute(
- select(Subscription)
- .where(Subscription.user_id == user.id)
- .order_by(Subscription.created_at.desc())
- .limit(1)
- )
+ context_subscription_id: int | None = context.payload.get('subscription_id')
+ if settings.is_multi_tariff_enabled() and context_subscription_id is not None:
+ result = await db.execute(
+ select(Subscription).where(
+ Subscription.user_id == user.id,
+ Subscription.id == context_subscription_id,
+ )
+ )
+ else:
+ result = await db.execute(
+ select(Subscription)
+ .where(Subscription.user_id == user.id)
+ .order_by(Subscription.created_at.desc())
+ .limit(1)
+ )
subscription = result.scalar_one_or_none()
if subscription is not None:
context.subscription = subscription
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index 902c3a9d..53a40e74 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -489,9 +489,14 @@ class SubscriptionService:
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
+ reset_uuid = (
+ subscription.remnawave_uuid
+ if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
+ else user.remnawave_uuid
+ )
await self._reset_user_traffic(
api,
- user.remnawave_uuid,
+ reset_uuid,
user,
reset_reason,
)
@@ -604,11 +609,16 @@ class SubscriptionService:
async def revoke_subscription(self, db: AsyncSession, subscription: Subscription) -> str | None:
try:
user = await get_user_by_id(db, subscription.user_id)
- if not user or not user.remnawave_uuid:
+ revoke_uuid = (
+ subscription.remnawave_uuid
+ if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
+ else user.remnawave_uuid
+ )
+ if not user or not revoke_uuid:
return None
async with self.get_api_client() as api:
- updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
+ updated_user = await api.revoke_user_subscription(revoke_uuid)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
@@ -635,11 +645,16 @@ class SubscriptionService:
async def sync_subscription_usage(self, db: AsyncSession, subscription: Subscription) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
- if not user or not user.remnawave_uuid:
+ sync_uuid = (
+ subscription.remnawave_uuid
+ if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
+ else user.remnawave_uuid
+ )
+ if not user or not sync_uuid:
return False
async with self.get_api_client() as api:
- remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
+ remnawave_user = await api.get_user_by_uuid(sync_uuid)
if not remnawave_user:
return False
@@ -676,7 +691,8 @@ class SubscriptionService:
return False, 'user_not_found'
# Проверяем, нужна ли синхронизация
- needs_sync = not subscription.subscription_url or not user.remnawave_uuid
+ sub_uuid = subscription.remnawave_uuid if settings.is_multi_tariff_enabled() else user.remnawave_uuid
+ needs_sync = not subscription.subscription_url or not sub_uuid
if not needs_sync:
# Проверяем, существует ли пользователь в RemnaWave
diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py
index 72ae8708..4d4e6fce 100644
--- a/app/webapi/routes/subscriptions.py
+++ b/app/webapi/routes/subscriptions.py
@@ -357,10 +357,17 @@ async def delete_subscription(
await deactivate_subscription(db, subscription)
- # Деактивируем пользователя в RemnaWave, если есть UUID
- if subscription.user and subscription.user.remnawave_uuid:
+ # Деактивируем пользователя в 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(subscription.user.remnawave_uuid)
+ await subscription_service.disable_remnawave_user(disable_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py
index bcb5067c..85250f3e 100644
--- a/app/webapi/routes/users.py
+++ b/app/webapi/routes/users.py
@@ -61,6 +61,7 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
if not subscription:
return None
+ tariff = getattr(subscription, 'tariff', None)
return SubscriptionSummary(
id=subscription.id,
status=subscription.status,
@@ -76,12 +77,15 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
subscription_url=subscription.subscription_url,
subscription_crypto_link=subscription.subscription_crypto_link,
connected_squads=list(subscription.connected_squads or []),
+ tariff_id=subscription.tariff_id,
+ tariff_name=tariff.name if tariff is not None else None,
)
def _serialize_user(user: User) -> UserResponse:
subscription = getattr(user, 'subscription', None)
promo_group = getattr(user, 'promo_group', None)
+ all_subscriptions = getattr(user, 'subscriptions', None) or []
return UserResponse(
id=user.id,
@@ -102,6 +106,7 @@ def _serialize_user(user: User) -> UserResponse:
last_activity=user.last_activity,
promo_group=_serialize_promo_group(promo_group),
subscription=_serialize_subscription(subscription),
+ subscriptions=[_serialize_subscription(s) for s in all_subscriptions if s is not None],
)
diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py
index 1f149ad2..b3d62e50 100644
--- a/app/webapi/schemas/users.py
+++ b/app/webapi/schemas/users.py
@@ -29,6 +29,8 @@ class SubscriptionSummary(BaseModel):
subscription_url: str | None = None
subscription_crypto_link: str | None = None
connected_squads: list[str] = Field(default_factory=list)
+ tariff_id: int | None = None
+ tariff_name: str | None = None
class UserResponse(BaseModel):
@@ -50,6 +52,7 @@ class UserResponse(BaseModel):
last_activity: datetime | None = None
promo_group: PromoGroupSummary | None = None
subscription: SubscriptionSummary | None = None
+ subscriptions: list[SubscriptionSummary] = Field(default_factory=list)
class UserListResponse(BaseModel):