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, ) ], [