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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "❌ خیر",
|
||||
|
||||
@@ -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": "❌ Нет",
|
||||
|
||||
@@ -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": "❌ Ні",
|
||||
|
||||
@@ -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": "❌否",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user