fix: comprehensive multi-subscription audit fixes across routes, handlers, and services

- Fix UUID resolution in monitoring and webhook services for multi-tariff mode
- Update cabinet routes to properly resolve per-subscription UUIDs
- Fix account merge service for multi-tariff subscription transfers
- Update admin handlers (users, promo_offers, servers) for multi-subscription
- Fix traffic, devices, servers, daily subscription modules
- Update payment handlers (stars, yookassa) and purchase services
- Fix broadcast, promocode, and subscription auto-purchase services
- Add multi-subscription support to keyboards and localization
- Fix CRUD operations for subscription queries
- Resolve code quality issues (ruff linting)
This commit is contained in:
c0mrade
2026-03-23 11:32:02 +03:00
parent 18f31c565c
commit d071269b8c
49 changed files with 966 additions and 369 deletions
+45 -13
View File
@@ -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
+26 -9
View File
@@ -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
+31 -11
View File
@@ -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
+1
View File
@@ -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(
@@ -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,
)
@@ -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()
@@ -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,
@@ -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:
@@ -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,
}
@@ -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
@@ -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 {
+6 -2
View File
@@ -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,
+25
View File
@@ -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):
+7 -1
View File
@@ -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')
+49 -12
View File
@@ -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'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_zh = f'<p>套餐: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_ua = f'<p>Тариф: <strong>{tariff_name}</strong></p>' 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"""
<h2>Подписка скоро истекает</h2>
<div class="highlight warning">
{tariff_line_ru}
<p>Ваша подписка истекает через <strong>{days_left}</strong> дн.</p>
<p>Дата истечения: <strong>{expires_at}</strong></p>
</div>
@@ -372,6 +380,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Expiring Soon</h2>
<div class="highlight warning">
{tariff_line_en}
<p>Your subscription expires in <strong>{days_left}</strong> day(s).</p>
<p>Expiration date: <strong>{expires_at}</strong></p>
</div>
@@ -381,6 +390,7 @@ class EmailNotificationTemplates:
'zh': f"""
<h2>订阅即将到期</h2>
<div class="highlight warning">
{tariff_line_zh}
<p>您的订阅将在 <strong>{days_left}</strong> 天后到期</p>
<p>到期日期: <strong>{expires_at}</strong></p>
</div>
@@ -390,6 +400,7 @@ class EmailNotificationTemplates:
'ua': f"""
<h2>Підписка скоро закінчується</h2>
<div class="highlight warning">
{tariff_line_ua}
<p>Ваша підписка закінчується через <strong>{days_left}</strong> дн.</p>
<p>Дата закінчення: <strong>{expires_at}</strong></p>
</div>
@@ -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'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_zh = f'<p>套餐: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_ua = f'<p>Тариф: <strong>{tariff_name}</strong></p>' 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"""
<h2>Подписка истекла</h2>
<div class="highlight danger">
{tariff_line_ru}
<p>Ваша подписка истекла. Доступ к VPN отключён.</p>
</div>
<p>Оформите новую подписку, чтобы продолжить использование сервиса.</p>
@@ -424,6 +444,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Expired</h2>
<div class="highlight danger">
{tariff_line_en}
<p>Your subscription has expired. VPN access has been disabled.</p>
</div>
<p>Purchase a new subscription to continue using our service.</p>
@@ -432,6 +453,7 @@ class EmailNotificationTemplates:
'zh': f"""
<h2>订阅已到期</h2>
<div class="highlight danger">
{tariff_line_zh}
<p>您的订阅已到期VPN访问已被禁用</p>
</div>
<p>请购买新订阅以继续使用我们的服务</p>
@@ -440,6 +462,7 @@ class EmailNotificationTemplates:
'ua': f"""
<h2>Підписка закінчилась</h2>
<div class="highlight danger">
{tariff_line_ua}
<p>Ваша підписка закінчилась. Доступ до VPN вимкнено.</p>
</div>
<p>Оформіть нову підписку, щоб продовжити використання сервісу.</p>
@@ -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'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' 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"""
<h2>Подписка успешно продлена!</h2>
<div class="highlight success">
{tariff_line_ru}
<p>Ваша подписка была успешно продлена.</p>
<p>Новая дата истечения: <strong>{new_expires_at}</strong></p>
</div>
@@ -476,6 +505,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Successfully Renewed!</h2>
<div class="highlight success">
{tariff_line_en}
<p>Your subscription has been successfully renewed.</p>
<p>New expiration date: <strong>{new_expires_at}</strong></p>
</div>
@@ -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'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' 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"""
<h2>Подписка активирована!</h2>
<div class="highlight success">
{tariff_line_ru}
<p>Ваша VPN подписка успешно активирована.</p>
<p>Действует до: <strong>{expires_at}</strong></p>
</div>
@@ -513,6 +549,7 @@ class EmailNotificationTemplates:
'en': f"""
<h2>Subscription Activated!</h2>
<div class="highlight success">
{tariff_line_en}
<p>Your VPN subscription has been successfully activated.</p>
<p>Valid until: <strong>{expires_at}</strong></p>
</div>
+62 -12
View File
@@ -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())
+70 -5
View File
@@ -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', '💳 <b>Подписки</b>'))
for sub in subscriptions_list:
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
lines.append(f'<b>{tariff_name}</b>')
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', '💳 <b>Подписка</b>'))
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,
+12 -1
View File
@@ -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:
+68 -26
View File
@@ -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:
+2 -2
View File
@@ -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,
+2
View File
@@ -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',
},
+4 -2
View File
@@ -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,
)
+1 -1
View File
@@ -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()
+11 -8
View File
@@ -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 = []
+4 -14
View File
@@ -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',
)
+1 -1
View File
@@ -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',
)
+50 -17
View File
@@ -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} <b>{idx}. {tariff_name}</b>']
parts = [f'{emoji} <b>{idx}. {tariff_name}</b>{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 = '📋 <b>Мои подписки</b>\n\nУ вас нет активных подписок.'
text = '📋 <b>Мои подписки</b>\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🔗 <code>{subscription.subscription_url}</code>'
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')
+49 -1
View File
@@ -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
+31 -2
View File
@@ -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:
+14 -36
View File
@@ -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)
+47 -25
View File
@@ -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,
)
+1
View File
@@ -1125,6 +1125,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 <b>{mulenpay_name_html} payment</b>\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",
+1
View File
@@ -1146,6 +1146,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 <b>شارژ MulenPay</b>\n\nمبلغ وارد کنید.\nحداقل: {min_amount}، حداکثر: {max_amount}",
"MY_BALANCE_BUTTON": "💰 موجودی من",
"MY_SUBSCRIPTION_BUTTON": "📱 اشتراک من",
"MY_SUBSCRIPTIONS_BUTTON": "📱 اشتراک‌های من",
"MY_TICKETS_BUTTON": "📋 تیکت‌های من",
"MY_TICKETS_TITLE": "📋 <b>تیکت‌های من</b>",
"NO": "❌ خیر",
+1
View File
@@ -1146,6 +1146,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через {mulenpay_name_html}</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу {mulenpay_name}.",
"MY_BALANCE_BUTTON": "💰 Мой баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
"MY_SUBSCRIPTIONS_BUTTON": "📱 Мои подписки",
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
"NO": "❌ Нет",
+1
View File
@@ -1064,6 +1064,7 @@
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через {mulenpay_name_html}</b>\n\nВведіть суму для поповнення від 100 до 100 000 ₽.\nОплата відбувається через захищену платформу {mulenpay_name}.",
"MY_BALANCE_BUTTON": "💰 Мій баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя підписка",
"MY_SUBSCRIPTIONS_BUTTON": "📱 Мої підписки",
"MY_TICKETS_BUTTON": "📋 Мої тікети",
"MY_TICKETS_TITLE": "📋 Ваші тікети:",
"NO": "❌ Ні",
+1
View File
@@ -1062,6 +1062,7 @@
"MULENPAY_TOPUP_PROMPT": "💳<b>通过{mulenpay_name_html}付款</b>\n\n请输入充值金额,范围100至100000₽。\n付款通过{mulenpay_name}安全平台进行。",
"MY_BALANCE_BUTTON": "💰我的余额",
"MY_SUBSCRIPTION_BUTTON": "📱我的订阅",
"MY_SUBSCRIPTIONS_BUTTON": "📱 我的订阅",
"MY_TICKETS_BUTTON": "📋我的工单",
"MY_TICKETS_TITLE": "📋您的工单:",
"NO": "❌否",
+18
View File
@@ -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',
+8 -1
View File
@@ -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(
+10 -3
View File
@@ -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
+1
View File
@@ -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(
+5 -1
View File
@@ -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:
+17 -17
View File
@@ -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:
+19 -3
View File
@@ -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()
@@ -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,
)
+15 -6
View File
@@ -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
+22 -6
View File
@@ -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
+10 -3
View File
@@ -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)
+5
View File
@@ -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],
)
+3
View File
@@ -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):