diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py
index f1b3ee92..f591b0cb 100644
--- a/app/database/crud/tariff.py
+++ b/app/database/crud/tariff.py
@@ -3,7 +3,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.database.models import PromoGroup, Subscription, Tariff
+from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff
logger = structlog.get_logger(__name__)
@@ -393,7 +393,8 @@ async def update_tariff(
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
"""
Удаляет тариф.
- Подписки с этим тарифом получат tariff_id = NULL.
+ FK с ondelete=RESTRICT — удаление невозможно, если есть привязанные подписки.
+ Вызывающий код должен проверить отсутствие активных подписок до вызова.
"""
tariff_id = tariff.id
tariff_name = tariff.name
@@ -404,7 +405,7 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
)
affected_subscriptions = subscriptions_count.scalar_one()
- # Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
+ # Удаляем тариф (FK RESTRICT — подписок с tariff_id быть не должно)
await db.delete(tariff)
await db.commit()
@@ -424,6 +425,18 @@ async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> in
return int(result.scalar_one())
+async def get_active_subscriptions_count_by_tariff_id(db: AsyncSession, tariff_id: int) -> int:
+ """Подсчитывает количество активных (active/trial) подписок на тарифе."""
+ active_statuses = [SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]
+ result = await db.execute(
+ select(func.count(Subscription.id)).where(
+ Subscription.tariff_id == tariff_id,
+ Subscription.status.in_(active_statuses),
+ )
+ )
+ return int(result.scalar_one())
+
+
async def set_tariff_promo_groups(
db: AsyncSession,
tariff: Tariff,
diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py
index 866bf096..de5fd77a 100644
--- a/app/handlers/admin/tariffs.py
+++ b/app/handlers/admin/tariffs.py
@@ -15,6 +15,7 @@ from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
create_tariff,
delete_tariff,
+ get_active_subscriptions_count_by_tariff_id,
get_tariff_by_id,
get_tariff_subscriptions_count,
get_tariffs_with_subscriptions_count,
@@ -2158,11 +2159,34 @@ async def confirm_delete_tariff(
await callback.answer('Тариф не найден', show_alert=True)
return
+ active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff_id)
+
+ if active_count > 0:
+ total_count = await get_tariff_subscriptions_count(db, tariff_id)
+ await callback.message.edit_text(
+ f'🗑️ Удаление тарифа\n\n'
+ f'Невозможно удалить тариф {html.escape(tariff.name)}.\n\n'
+ f'⚠️ Активных подписок: {active_count} (всего: {total_count})\n'
+ f'Сначала деактивируйте тариф и дождитесь окончания всех активных подписок, '
+ f'либо переведите подписки на другой тариф.',
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text='◀️ Назад к тарифу', callback_data=f'admin_tariff_view:{tariff_id}')],
+ ]
+ ),
+ parse_mode='HTML',
+ )
+ await callback.answer()
+ return
+
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
warning = ''
if subs_count > 0:
- warning = f'\n\n⚠️ Внимание! На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
+ warning = (
+ f'\n\n⚠️ Внимание! На этом тарифе {subs_count} неактивных подписок.\n'
+ f'Они потеряют привязку к тарифу.'
+ )
await callback.message.edit_text(
f'🗑️ Удаление тарифа\n\nВы действительно хотите удалить тариф {html.escape(tariff.name)}?{warning}',
@@ -2197,6 +2221,15 @@ async def delete_tariff_confirmed(
await callback.answer('Тариф не найден', show_alert=True)
return
+ # Защита от удаления тарифа с активными подписками (FK RESTRICT)
+ active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff.id)
+ if active_count > 0:
+ await callback.answer(
+ f'Невозможно удалить тариф: {active_count} активных подписок. Сначала деактивируйте тариф.',
+ show_alert=True,
+ )
+ return
+
tariff_name = tariff.name
await delete_tariff(db, tariff)
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index ea293e68..26f990ed 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -638,7 +638,11 @@ def get_main_menu_keyboard(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
- callback_data='open_subscription_link',
+ callback_data=(
+ 'subscription_connect'
+ if settings.is_multi_tariff_enabled()
+ else 'open_subscription_link'
+ ),
)
]
)
@@ -1066,6 +1070,13 @@ def get_subscription_keyboard(
texts = get_texts(language)
keyboard = []
+ # Sub ID suffix for multi-tariff callback routing
+ _sub_suffix = (
+ f':{subscription.id}'
+ if settings.is_multi_tariff_enabled() and subscription and hasattr(subscription, 'id')
+ else ''
+ )
+
if has_subscription:
subscription_link = get_display_subscription_link(subscription) if subscription else None
if subscription_link:
@@ -1094,7 +1105,8 @@ def get_subscription_keyboard(
keyboard.append(
[
InlineKeyboardButton(
- text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect'
+ text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
+ callback_data=f'subscription_connect{_sub_suffix}',
)
]
)
@@ -1107,7 +1119,7 @@ def get_subscription_keyboard(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
- callback_data='open_subscription_link',
+ callback_data=f'open_subscription_link{_sub_suffix}',
)
]
)
@@ -1115,7 +1127,8 @@ def get_subscription_keyboard(
keyboard.append(
[
InlineKeyboardButton(
- text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect'
+ text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
+ callback_data=f'subscription_connect{_sub_suffix}',
)
]
)
@@ -1132,7 +1145,8 @@ def get_subscription_keyboard(
keyboard.append(
[
InlineKeyboardButton(
- text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect'
+ text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
+ callback_data=f'subscription_connect{_sub_suffix}',
)
]
)
@@ -2539,11 +2553,12 @@ def get_device_selection_keyboard(
keyboard.append(row)
if settings.CONNECT_BUTTON_MODE == 'guide':
+ _osl_cb = f'open_subscription_link:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'open_subscription_link'
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('SHOW_SUBSCRIPTION_LINK', '📋 Показать ссылку подписки'),
- callback_data='open_subscription_link',
+ callback_data=_osl_cb,
)
]
)
@@ -2610,11 +2625,16 @@ def get_connection_guide_keyboard(
]
)
elif settings.is_happ_cryptolink_mode():
+ _osl_cb = (
+ f'open_subscription_link:{sub_id}'
+ if sub_id and settings.is_multi_tariff_enabled()
+ else 'open_subscription_link'
+ )
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
- callback_data='open_subscription_link',
+ callback_data=_osl_cb,
style='success',
)
]
@@ -2651,12 +2671,13 @@ def get_connection_guide_keyboard(
]
)
+ _sc_cb = f'subscription_connect:{sub_id}' if sub_id and settings.is_multi_tariff_enabled() else 'subscription_connect'
keyboard.extend(
[
[
InlineKeyboardButton(
text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'),
- callback_data='subscription_connect',
+ callback_data=_sc_cb,
)
],
[InlineKeyboardButton(text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data=back_cb)],
diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py
index c9fd5b4d..dea76400 100644
--- a/app/services/guest_purchase_service.py
+++ b/app/services/guest_purchase_service.py
@@ -1060,7 +1060,13 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
from app.database.crud.subscription import get_active_subscriptions_by_user_id
_active = await get_active_subscriptions_by_user_id(db, user.id)
- existing_subscription = _active[0] if _active else None
+ if _active:
+ # Prefer non-daily tariffs; among those pick the one with most days_left
+ _non_daily = [s for s in _active if not getattr(s, 'is_daily_tariff', False)]
+ _pool = _non_daily or _active
+ existing_subscription = max(_pool, key=lambda s: s.days_left)
+ else:
+ existing_subscription = None
else:
existing_subscription = await get_subscription_by_user_id(db, user.id)
if existing_subscription is not None:
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index c3f96541..d37ba3e5 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -1606,12 +1606,18 @@ class MonitoringService:
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+ extend_callback = (
+ f'se:{subscription.id}'
+ if settings.is_multi_tariff_enabled()
+ else 'subscription_extend'
+ )
+
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
- callback_data='subscription_extend',
+ callback_data=extend_callback,
)
],
[
@@ -1696,6 +1702,12 @@ class MonitoringService:
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+ extend_callback = (
+ f'se:{subscription.id}'
+ if settings.is_multi_tariff_enabled()
+ else 'subscription_extend'
+ )
+
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
@@ -1706,7 +1718,7 @@ class MonitoringService:
[
build_miniapp_or_callback_button(
text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'),
- callback_data='subscription_extend',
+ callback_data=extend_callback,
)
],
[