fix: multi-tariff Stage 4 critical fixes — keyboards, guest purchase, monitoring, tariff deletion

- inline.py: open_subscription_link/subscription_connect callbacks now include
  :{subscription_id} suffix in multi-tariff mode. Main menu uses subscription_connect
  (picker) instead of bare open_subscription_link.
- guest_purchase_service: activate_purchase non-tariff path uses proper ordering
  (non-daily, max days_left) instead of arbitrary _active[0]
- monitoring_service: _send_expired_day1_notification and discount notification
  keyboards use se:{subscription.id} in multi-tariff (2 more hardcoded callbacks fixed)
- admin/tariffs: delete_tariff_confirmed now checks active subscription count
  before deletion (RESTRICT FK). Prompt shows blocking message when active subs exist.
  New CRUD function get_active_subscriptions_count_by_tariff_id.
This commit is contained in:
Fringg
2026-03-26 08:46:00 +03:00
parent 6dc5879ffa
commit a49e52cc92
5 changed files with 100 additions and 15 deletions
+16 -3
View File
@@ -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,
+34 -1
View File
@@ -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'🗑️ <b>Удаление тарифа</b>\n\n'
f'Невозможно удалить тариф <b>{html.escape(tariff.name)}</b>.\n\n'
f'⚠️ <b>Активных подписок:</b> {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⚠️ <b>Внимание!</b> На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
warning = (
f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} неактивных подписок.\n'
f'Они потеряют привязку к тарифу.'
)
await callback.message.edit_text(
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{html.escape(tariff.name)}</b>?{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)
+29 -8
View File
@@ -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)],
+7 -1
View File
@@ -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:
+14 -2
View File
@@ -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,
)
],
[