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

Подписка скоро истекает

+ {tariff_line_ru}

Ваша подписка истекает через {days_left} дн.

Дата истечения: {expires_at}

@@ -372,6 +380,7 @@ class EmailNotificationTemplates: 'en': f"""

Subscription Expiring Soon

+ {tariff_line_en}

Your subscription expires in {days_left} day(s).

Expiration date: {expires_at}

@@ -381,6 +390,7 @@ class EmailNotificationTemplates: 'zh': f"""

订阅即将到期

+ {tariff_line_zh}

您的订阅将在 {days_left} 天后到期。

到期日期: {expires_at}

@@ -390,6 +400,7 @@ class EmailNotificationTemplates: 'ua': f"""

Підписка скоро закінчується

+ {tariff_line_ua}

Ваша підписка закінчується через {days_left} дн.

Дата закінчення: {expires_at}

@@ -405,17 +416,26 @@ class EmailNotificationTemplates: def _subscription_expired_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription expired notification.""" + 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': 'Подписка истекла', - '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"""

Подписка истекла

+ {tariff_line_ru}

Ваша подписка истекла. Доступ к VPN отключён.

Оформите новую подписку, чтобы продолжить использование сервиса.

@@ -424,6 +444,7 @@ class EmailNotificationTemplates: 'en': f"""

Subscription Expired

+ {tariff_line_en}

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

订阅已到期

+ {tariff_line_zh}

您的订阅已到期。VPN访问已被禁用。

请购买新订阅以继续使用我们的服务。

@@ -440,6 +462,7 @@ class EmailNotificationTemplates: 'ua': f"""

Підписка закінчилась

+ {tariff_line_ua}

Ваша підписка закінчилась. Доступ до 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"""

Подписка успешно продлена!

+ {tariff_line_ru}

Ваша подписка была успешно продлена.

Новая дата истечения: {new_expires_at}

@@ -476,6 +505,7 @@ class EmailNotificationTemplates: 'en': f"""

Subscription Successfully Renewed!

+ {tariff_line_en}

Your subscription has been successfully renewed.

New expiration date: {new_expires_at}

@@ -492,18 +522,24 @@ class EmailNotificationTemplates: def _subscription_activated_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription activated notification.""" 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 '' 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"""

Подписка активирована!

+ {tariff_line_ru}

Ваша VPN подписка успешно активирована.

Действует до: {expires_at}

@@ -513,6 +549,7 @@ class EmailNotificationTemplates: 'en': f"""

Subscription Activated!

+ {tariff_line_en}

Your VPN subscription has been successfully activated.

Valid until: {expires_at}

diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 4d6ebdcb..f47fa302 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -3,7 +3,7 @@ from collections.abc import Iterable from datetime import UTC, datetime, timedelta import structlog -from sqlalchemy import and_, delete, func, select +from sqlalchemy import and_, case, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy.orm.exc import StaleDataError @@ -80,7 +80,14 @@ def is_active_paid_subscription(subscription: Subscription | None) -> bool: async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None: - """Deprecated: returns single subscription. Use get_active_subscriptions_by_user_id for multi-tariff.""" + """Get primary subscription for user. + + Returns the first active/trial subscription, or the most recently created one. + Multi-tariff compatible: prioritizes active subscriptions. + For multi-tariff operations on a specific subscription, use get_subscription_by_id_for_user(). + """ + from app.database.models import SubscriptionStatus + result = await db.execute( select(Subscription) .options( @@ -88,7 +95,15 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri selectinload(Subscription.tariff), ) .where(Subscription.user_id == user_id) - .order_by(Subscription.created_at.desc()) + .order_by( + # Active/trial subscriptions first, then by creation date + case( + (Subscription.status == SubscriptionStatus.ACTIVE.value, 0), + (Subscription.status == SubscriptionStatus.TRIAL.value, 1), + else_=2, + ), + Subscription.created_at.desc(), + ) .limit(1) ) subscription = result.scalar_one_or_none() @@ -149,7 +164,17 @@ async def create_trial_subscription( end_date = datetime.now(UTC) + timedelta(days=duration_days) # Check for existing PENDING trial subscription (retry after failed payment) - existing = await get_subscription_by_user_id(db, user_id) + # In multi-tariff mode, only reuse a subscription for the SAME tariff to avoid + # overwriting a paid subscription for a different tariff. + existing = None + if settings.is_multi_tariff_enabled() and tariff_id: + for sub in await get_active_subscriptions_by_user_id(db, user_id): + if sub.tariff_id == tariff_id: + existing = sub + break + else: + existing = await get_subscription_by_user_id(db, user_id) + if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value: existing.status = SubscriptionStatus.ACTIVE.value existing.start_date = datetime.now(UTC) @@ -1571,15 +1596,30 @@ async def create_pending_trial_subscription( ) -async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None: +async def activate_pending_subscription( + db: AsyncSession, + user_id: int, + period_days: int = None, + subscription_id: int | None = None, +) -> Subscription | None: """Активирует pending подписку пользователя, меняя её статус на ACTIVE.""" - logger.info('Активация pending подписки: пользователь период дней', user_id=user_id, period_days=period_days) + logger.info( + 'Активация pending подписки: пользователь период дней', + user_id=user_id, + period_days=period_days, + subscription_id=subscription_id, + ) + + # Находим pending подписку пользователя (последнюю созданную при наличии нескольких) + conditions = [ + Subscription.user_id == user_id, + Subscription.status == SubscriptionStatus.PENDING.value, + ] + if subscription_id is not None: + conditions.append(Subscription.id == subscription_id) - # Находим pending подписку пользователя result = await db.execute( - select(Subscription).where( - and_(Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value) - ) + select(Subscription).where(and_(*conditions)).order_by(Subscription.created_at.desc()).limit(1) ) pending_subscription = result.scalar_one_or_none() @@ -2030,7 +2070,10 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta async def get_all_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]: - """Get all subscriptions for a user (any status).""" + """Get all subscriptions for a user (any status). + + Ordering: active first, then trial, then everything else — newest first within each group. + """ result = await db.execute( select(Subscription) .options( @@ -2038,6 +2081,13 @@ async def get_all_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> li selectinload(Subscription.tariff), ) .where(Subscription.user_id == user_id) - .order_by(Subscription.created_at.desc()) + .order_by( + case( + (Subscription.status == SubscriptionStatus.ACTIVE.value, 0), + (Subscription.status == SubscriptionStatus.TRIAL.value, 1), + else_=2, + ), + Subscription.created_at.desc(), + ) ) return list(result.scalars().all()) diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 8d08674b..ca2d2da5 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -1468,7 +1468,51 @@ async def show_selected_user_details( ) subscription = getattr(user, 'subscription', None) - if subscription: + subscriptions_list = getattr(user, 'subscriptions', None) or [] + if settings.is_multi_tariff_enabled() and subscriptions_list: + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 Подписки')) + for sub in subscriptions_list: + tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}' + lines.append(f'{tariff_name}') + lines.append( + texts.t( + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS', + 'Статус: {status}', + ).format(status=sub.status_display) + ) + end_date_text = ( + format_datetime(sub.end_date) + if sub.end_date + else texts.t( + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN', + 'не указано', + ) + ) + lines.append( + texts.t( + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END', + 'Истекает: {date}', + ).format(date=end_date_text) + ) + lines.append( + texts.t( + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC', + 'Трафик: {used}/{limit} ГБ', + ).format( + used=sub.traffic_used_gb or 0, + limit=sub.traffic_limit_gb or 0, + ) + ) + connected = sub.connected_squads or [] + if connected: + lines.append( + texts.t( + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS', + 'Подключено сквадов: {count}', + ).format(count=len(connected)) + ) + elif subscription: lines.append('') lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 Подписка')) lines.append( @@ -1771,7 +1815,21 @@ async def show_selected_user_details( ).format(count=len(active_offers)) ) - if subscription: + if settings.is_multi_tariff_enabled() and subscriptions_list: + now = datetime.now(UTC) + sub_ids = [sub.id for sub in subscriptions_list] + result = await db.execute( + select(SubscriptionTemporaryAccess) + .options(selectinload(SubscriptionTemporaryAccess.offer)) + .where( + SubscriptionTemporaryAccess.subscription_id.in_(sub_ids), + SubscriptionTemporaryAccess.is_active == True, + SubscriptionTemporaryAccess.expires_at > now, + ) + .order_by(SubscriptionTemporaryAccess.expires_at.desc()) + ) + accesses = result.scalars().all() + elif subscription: now = datetime.now(UTC) result = await db.execute( select(SubscriptionTemporaryAccess) @@ -1930,12 +1988,19 @@ async def _send_offer_to_users( try: # Используем отдельную сессию для изоляции транзакции async with AsyncSessionLocal() as new_db: + if settings.is_multi_tariff_enabled(): + _user_subs = getattr(user, 'subscriptions', None) or [] + _active_subs = [s for s in _user_subs if s.is_active] + _offer_sub_id = ( + _active_subs[0].id if _active_subs else (_user_subs[0].id if _user_subs else None) + ) + else: + _offer_sub = getattr(user, 'subscription', None) + _offer_sub_id = _offer_sub.id if _offer_sub else None offer_record = await upsert_discount_offer( new_db, user_id=user.id, - subscription_id=getattr(user, 'subscription', None).id - if getattr(user, 'subscription', None) - else None, + subscription_id=_offer_sub_id, notification_type=f'promo_template_{template.id}', discount_percent=template.discount_percent, bonus_amount_kopeks=0, diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index e19e3ad1..fa2bb87c 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -5,6 +5,7 @@ from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.crud.promo_group import get_promo_groups_with_counts from app.database.crud.server_squad import ( delete_server_squad, @@ -362,7 +363,17 @@ async def show_server_users(callback: types.CallbackQuery, db_user: User, db: As if len(display_name) > 30: display_name = display_name[:27] + '...' - subscription_status = user.subscription.status_display if user.subscription else '❌ Нет подписки' + if settings.is_multi_tariff_enabled() and hasattr(user, 'subscriptions') and user.subscriptions: + status_parts = [] + for sub in user.subscriptions: + emoji = '🟢' if sub.is_active else '🔴' + name = sub.tariff.name if sub.tariff else f'#{sub.id}' + status_parts.append(f'{emoji}{name}') + subscription_status = ', '.join(status_parts) + elif user.subscription: + subscription_status = user.subscription.status_display + else: + subscription_status = '❌ Нет подписки' status_icon = _get_status_icon(subscription_status) if status_icon: diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index dd929eff..a83764f2 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -437,7 +437,7 @@ async def show_users_ready_to_renew( current_time = datetime.now(UTC) for user in users_data['users']: - subscription = user.subscription + subscription = user.subscription # Uses primary subscription (multi-tariff compatible via property) status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫' subscription_emoji = '❌' expired_days = '?' @@ -564,7 +564,7 @@ async def show_potential_customers( keyboard = [] for user in users_data['users']: - subscription = user.subscription + subscription = user.subscription # Uses primary subscription (multi-tariff compatible via property) status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫' subscription_emoji = '❌' @@ -3964,16 +3964,23 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, return False -async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool: +async def _extend_subscription_by_days( + db: AsyncSession, user_id: int, days: int, admin_id: int, subscription_id: int | None = None +) -> bool: try: from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService if settings.is_multi_tariff_enabled(): - from app.database.crud.subscription import get_active_subscriptions_by_user_id + if subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user - active_subs = await get_active_subscriptions_by_user_id(db, user_id) - subscription = active_subs[0] if active_subs else None + subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id) + else: + from app.database.crud.subscription import get_active_subscriptions_by_user_id + + active_subs = await get_active_subscriptions_by_user_id(db, user_id) + subscription = active_subs[0] if active_subs else None else: subscription = await get_subscription_by_user_id(db, user_id) if not subscription: @@ -3998,7 +4005,9 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int return False -async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, admin_id: int) -> bool: +async def _add_subscription_traffic( + db: AsyncSession, user_id: int, gb: int, admin_id: int, subscription_id: int | None = None +) -> bool: try: from app.database.crud.subscription import ( add_subscription_traffic, @@ -4008,10 +4017,15 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm from app.services.subscription_service import SubscriptionService if settings.is_multi_tariff_enabled(): - from app.database.crud.subscription import get_active_subscriptions_by_user_id + if subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user - active_subs = await get_active_subscriptions_by_user_id(db, user_id) - subscription = active_subs[0] if active_subs else None + subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id) + else: + from app.database.crud.subscription import get_active_subscriptions_by_user_id + + active_subs = await get_active_subscriptions_by_user_id(db, user_id) + subscription = active_subs[0] if active_subs else None else: subscription = await get_subscription_by_user_id(db, user_id) if not subscription: @@ -4047,7 +4061,9 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm return False -async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool: +async def _deactivate_user_subscription( + db: AsyncSession, user_id: int, admin_id: int, subscription_id: int | None = None +) -> bool: try: from app.database.crud.subscription import ( deactivate_subscription, @@ -4056,10 +4072,15 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id from app.services.subscription_service import SubscriptionService if settings.is_multi_tariff_enabled(): - from app.database.crud.subscription import get_active_subscriptions_by_user_id + if subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user - active_subs = await get_active_subscriptions_by_user_id(db, user_id) - subscription = active_subs[0] if active_subs else None + subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id) + else: + from app.database.crud.subscription import get_active_subscriptions_by_user_id + + active_subs = await get_active_subscriptions_by_user_id(db, user_id) + subscription = active_subs[0] if active_subs else None else: subscription = await get_subscription_by_user_id(db, user_id) if not subscription: @@ -4081,17 +4102,24 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id return False -async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool: +async def _activate_user_subscription( + db: AsyncSession, user_id: int, admin_id: int, subscription_id: int | None = None +) -> bool: try: from app.database.crud.subscription import get_subscription_by_user_id from app.database.models import SubscriptionStatus from app.services.subscription_service import SubscriptionService if settings.is_multi_tariff_enabled(): - from app.database.crud.subscription import get_active_subscriptions_by_user_id + if subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user - active_subs = await get_active_subscriptions_by_user_id(db, user_id) - subscription = active_subs[0] if active_subs else None + subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id) + else: + from app.database.crud.subscription import get_active_subscriptions_by_user_id + + active_subs = await get_active_subscriptions_by_user_id(db, user_id) + subscription = active_subs[0] if active_subs else None else: subscription = await get_subscription_by_user_id(db, user_id) if not subscription: @@ -4116,16 +4144,23 @@ async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: return False -async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool: +async def _grant_trial_subscription( + db: AsyncSession, user_id: int, admin_id: int, subscription_id: int | None = None +) -> bool: try: from app.database.crud.subscription import create_trial_subscription, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService if settings.is_multi_tariff_enabled(): - from app.database.crud.subscription import get_active_subscriptions_by_user_id + if subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user - active_subs = await get_active_subscriptions_by_user_id(db, user_id) - existing_subscription = active_subs[0] if active_subs else None + existing_subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id) + else: + from app.database.crud.subscription import get_active_subscriptions_by_user_id + + active_subs = await get_active_subscriptions_by_user_id(db, user_id) + existing_subscription = active_subs[0] if active_subs else None else: existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: @@ -4153,17 +4188,24 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in return False -async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool: +async def _grant_paid_subscription( + db: AsyncSession, user_id: int, days: int, admin_id: int, subscription_id: int | None = None +) -> bool: try: from app.config import settings from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService if settings.is_multi_tariff_enabled(): - from app.database.crud.subscription import get_active_subscriptions_by_user_id + if subscription_id: + from app.database.crud.subscription import get_subscription_by_id_for_user - active_subs = await get_active_subscriptions_by_user_id(db, user_id) - existing_subscription = active_subs[0] if active_subs else None + existing_subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id) + else: + from app.database.crud.subscription import get_active_subscriptions_by_user_id + + active_subs = await get_active_subscriptions_by_user_id(db, user_id) + existing_subscription = active_subs[0] if active_subs else None else: existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 37946c9f..60793671 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -206,7 +206,7 @@ async def show_main_menu( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription, + subscription=db_user.subscription, # Uses primary subscription (multi-tariff compatible via property) show_resume_checkout=show_resume_checkout, has_saved_cart=has_saved_cart, custom_buttons=custom_buttons, @@ -1050,7 +1050,7 @@ async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=db_user.balance_kopeks, - subscription=db_user.subscription, + subscription=db_user.subscription, # Uses primary subscription (multi-tariff compatible via property) show_resume_checkout=show_resume_checkout, has_saved_cart=has_saved_cart, custom_buttons=custom_buttons, diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 0bff4c33..c21ce508 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -945,6 +945,7 @@ async def handle_simple_subscription_payment_method( 'user_telegram_id': str(db_user.telegram_id), 'user_username': db_user.username or '', 'order_id': str(order.id), + 'subscription_id': str(order.id), 'subscription_period': str(subscription_params['period_days']), 'payment_purpose': 'simple_subscription_purchase', }, @@ -961,6 +962,7 @@ async def handle_simple_subscription_payment_method( 'user_telegram_id': str(db_user.telegram_id), 'user_username': db_user.username or '', 'order_id': str(order.id), + 'subscription_id': str(order.id), 'subscription_period': str(subscription_params['period_days']), 'payment_purpose': 'simple_subscription_purchase', }, diff --git a/app/handlers/start.py b/app/handlers/start.py index d05fe26d..fd27ee03 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -2413,6 +2413,7 @@ async def required_sub_channel_check( logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки') if user and user.status != UserStatus.DELETED.value: + # Uses primary subscription (multi-tariff compatible via property) has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) menu_text = await get_main_menu_text(user, texts, db) @@ -2436,7 +2437,7 @@ async def required_sub_channel_check( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=user.balance_kopeks, - subscription=user.subscription, + subscription=user.subscription, # Uses primary subscription (multi-tariff compatible via property) is_moderator=is_moderator, custom_buttons=custom_buttons, ) @@ -2581,6 +2582,7 @@ async def required_sub_channel_check( logger.error('Ошибка отправки сообщения о бонусе кампании', error=e) # Показываем главное меню после создания пользователя + # Uses primary subscription (multi-tariff compatible via property) has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) menu_text = await get_main_menu_text(user, texts, db) @@ -2604,7 +2606,7 @@ async def required_sub_channel_check( has_active_subscription=has_active_subscription, subscription_is_active=subscription_is_active, balance_kopeks=user.balance_kopeks, - subscription=user.subscription, + subscription=user.subscription, # Uses primary subscription (multi-tariff compatible via property) is_moderator=is_moderator, custom_buttons=custom_buttons, ) diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py index 38df6d62..a5582716 100644 --- a/app/handlers/subscription/autopay.py +++ b/app/handlers/subscription/autopay.py @@ -105,7 +105,7 @@ async def handle_autopay_menu(callback: types.CallbackQuery, db_user: User, db: await callback.message.edit_text( text, - reply_markup=get_autopay_keyboard(db_user.language), + reply_markup=get_autopay_keyboard(db_user.language, sub_id=sub_id), parse_mode='HTML', ) await callback.answer() diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 585ab454..d3dc7465 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -132,6 +132,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db: db_user.language, subscription.end_date, servers_discount_percent, + sub_id=sub_id, ), parse_mode='HTML', ) @@ -239,6 +240,7 @@ async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db db_user.language, subscription.end_date, servers_discount_percent, + sub_id=sub_id, ) ) logger.info('✅ Клавиатура обновлена') @@ -288,10 +290,10 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, # TOCTOU protection: lock user row before reading discount and charging balance 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 servers_discount_percent = PricingEngine.get_addon_discount_percent( db_user, @@ -764,6 +766,7 @@ async def handle_add_country_to_subscription( db_user.language, subscription.end_date, servers_discount_percent, + sub_id=sub_id, ) ) logger.info('✅ Клавиатура обновлена') @@ -833,10 +836,10 @@ async def confirm_add_countries_to_subscription( # TOCTOU protection: lock user row before reading discount and charging balance 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 total_price = 0 new_countries_names = [] diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index baf218d8..6b062f9e 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -531,20 +531,8 @@ async def execute_change_devices(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(): - 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 not subscription: await callback.answer( texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'), @@ -1567,6 +1555,7 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: device_type, db_user.language, has_other_apps=bool(other_apps), + sub_id=sub_id, ), parse_mode='HTML', ) @@ -1672,6 +1661,7 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User app, device_type, db_user.language, + sub_id=sub_id, ), parse_mode='HTML', ) diff --git a/app/handlers/subscription/links.py b/app/handlers/subscription/links.py index d8342e18..fd92b9d6 100644 --- a/app/handlers/subscription/links.py +++ b/app/handlers/subscription/links.py @@ -226,7 +226,7 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us await callback.message.edit_text( device_text, - reply_markup=get_device_selection_keyboard(db_user.language, platforms=platforms), + reply_markup=get_device_selection_keyboard(db_user.language, platforms=platforms, sub_id=sub_id), parse_mode='HTML', ) diff --git a/app/handlers/subscription/my_subscriptions.py b/app/handlers/subscription/my_subscriptions.py index 637f775f..d21b8255 100644 --- a/app/handlers/subscription/my_subscriptions.py +++ b/app/handlers/subscription/my_subscriptions.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.subscription import ( - get_active_subscriptions_by_user_id, + get_all_subscriptions_by_user_id, get_subscription_by_id_for_user, ) from app.database.models import User @@ -26,10 +26,33 @@ logger = structlog.get_logger(__name__) router = Router() +def _status_emoji(sub) -> str: + """Return status emoji based on subscription's actual status.""" + actual = sub.actual_status + if actual in ('active', 'trial'): + return '🟢' + if actual == 'limited': + return '🟡' + return '🔴' + + +def _status_label(sub) -> str: + """Return a short human-readable status label for non-active subscriptions.""" + actual = sub.actual_status + if actual == 'expired': + return ' (Истекла)' + if actual == 'disabled': + return ' (Отключена)' + if actual == 'limited': + return ' (Лимит)' + return '' + + def _format_subscription_line(sub, idx: int) -> str: """Format a single subscription for the list view.""" tariff_name = sub.tariff.name if sub.tariff else 'Подписка' - status_emoji = '🟢' if sub.is_active else '🔴' + emoji = _status_emoji(sub) + label = _status_label(sub) # Traffic info if sub.traffic_limit_gb == 0: @@ -44,7 +67,7 @@ def _format_subscription_line(sub, idx: int) -> str: # End date end_date = sub.end_date.strftime('%d.%m.%Y') if sub.end_date else '—' - parts = [f'{status_emoji} {idx}. {tariff_name}'] + parts = [f'{emoji} {idx}. {tariff_name}{label}'] parts.append(f' 📊 Трафик: {traffic}') if devices: parts.append(f' 📱 Устройства: {devices}') @@ -85,17 +108,27 @@ def _build_subscriptions_keyboard(subscriptions: list, language: str) -> types.I return types.InlineKeyboardMarkup(inline_keyboard=buttons) -def _build_subscription_detail_keyboard( - sub_id: int, -) -> types.InlineKeyboardMarkup: - """Build keyboard for single subscription management.""" - buttons = [ - [types.InlineKeyboardButton(text='🔗 Ссылка подключения', callback_data=f'sl:{sub_id}')], - [types.InlineKeyboardButton(text='🔄 Продлить', callback_data=f'se:{sub_id}')], - [types.InlineKeyboardButton(text='📊 Трафик', callback_data=f'st:{sub_id}')], - [types.InlineKeyboardButton(text='📱 Устройства', callback_data=f'sd:{sub_id}')], - [types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')], - ] +def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKeyboardMarkup: + """Build keyboard for single subscription management. + + For expired/disabled subscriptions, only 'Renew' and 'Back' are shown — + connection link and traffic/device management are irrelevant. + """ + is_inactive = sub is not None and sub.actual_status in ('expired', 'disabled') + + buttons = [] + + if not is_inactive: + buttons.append([types.InlineKeyboardButton(text='🔗 Ссылка подключения', callback_data=f'sl:{sub_id}')]) + + buttons.append([types.InlineKeyboardButton(text='🔄 Продлить', callback_data=f'se:{sub_id}')]) + + if not is_inactive: + buttons.append([types.InlineKeyboardButton(text='📊 Трафик', callback_data=f'st:{sub_id}')]) + buttons.append([types.InlineKeyboardButton(text='📱 Устройства', callback_data=f'sd:{sub_id}')]) + + buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')]) + return types.InlineKeyboardMarkup(inline_keyboard=buttons) @@ -110,10 +143,10 @@ async def show_my_subscriptions( # Fallback to legacy single subscription view return - subscriptions = await get_active_subscriptions_by_user_id(db, db_user.id) + subscriptions = await get_all_subscriptions_by_user_id(db, db_user.id) if not subscriptions: - text = '📋 Мои подписки\n\nУ вас нет активных подписок.' + text = '📋 Мои подписки\n\nУ вас нет подписок.' keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [types.InlineKeyboardButton(text='🛒 Купить подписку', callback_data='menu_buy')], @@ -175,7 +208,7 @@ async def show_subscription_detail( if subscription.subscription_url: text += f'\n🔗 {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):