feat: auto-resume disabled daily subscriptions on balance topup

- Add try_resume_disabled_daily_after_topup() for instant resume when balance is topped up
- Fix all 5 resume paths to charge daily fee BEFORE activating subscription
- Remove unsafe inline auto-resume from add_user_balance() that bypassed fee charging
- Add NULL-safe is_daily_paused filter in subscription queries
- Use create_remnawave_user() instead of enable_remnawave_user() for full VPN panel sync
- Add DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP localization key (ru, en, fa, zh, ua)
This commit is contained in:
Fringg
2026-03-08 21:36:17 +03:00
parent 928e3e98f8
commit 770b31d3d0
12 changed files with 788 additions and 69 deletions
+62 -15
View File
@@ -4454,19 +4454,27 @@ async def toggle_subscription_pause(
detail='Pause is only available for daily tariffs',
)
# Toggle pause state
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
# Сохраняем статус ДО изменения для проверки RemnaWave
# Determine current state
from app.database.models import SubscriptionStatus
was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
was_disabled = user.subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
)
# If resuming, check balance
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
# even if is_daily_paused is False (it's set by the system, not the user)
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
user.subscription.is_daily_paused = new_paused_state
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# If resuming, check balance and charge
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -4478,8 +4486,44 @@ async def toggle_subscription_pause(
},
)
# Restore ACTIVE status if was DISABLED
# Charge daily fee FIRST, then restore ACTIVE status
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
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)
@@ -4489,14 +4533,17 @@ async def toggle_subscription_pause(
await db.refresh(user)
# Sync with RemnaWave only when resuming from DISABLED state
# При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
# При возобновлении включаем только если подписка была отключена (DISABLED)
if not new_paused_state and user.remnawave_uuid and was_disabled:
if not new_paused_state and was_disabled:
try:
subscription_service = SubscriptionService()
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
await subscription_service.create_remnawave_user(
db,
user.subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Error enabling RemnaWave user on resume', error=e)
logger.error('Error syncing RemnaWave user on resume', error=e)
if new_paused_state:
message = 'Daily subscription paused'
+5 -1
View File
@@ -2094,6 +2094,9 @@ async def get_disabled_daily_subscriptions_for_resume(
Subscription.status == SubscriptionStatus.DISABLED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial.is_(False),
# Не возобновляем подписки, приостановленные пользователем вручную
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
# Баланс пользователя >= суточной цены тарифа
User.balance_kopeks >= Tariff.daily_price_kopeks,
)
@@ -2135,7 +2138,8 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
Tariff.is_active.is_(True),
Subscription.status == SubscriptionStatus.EXPIRED.value,
User.status == UserStatus.ACTIVE.value,
Subscription.is_daily_paused.is_(False),
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
Subscription.is_trial.is_(False),
# Только недавно экспайренные
Subscription.updated_at >= recovery_threshold,
+4 -34
View File
@@ -449,40 +449,10 @@ async def add_user_balance(
amount_kopeks=amount_kopeks,
)
# Автоматическое возобновление приостановленной суточной подписки
try:
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import SubscriptionStatus
# Загружаем подписку явно, чтобы избежать lazy loading
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
# Проверяем что это суточный тариф
is_daily = getattr(subscription, 'is_daily_tariff', False)
if is_daily and subscription.tariff_id:
# Загружаем тариф явно
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
subscription_id=subscription.id,
user_id=user.id,
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
except Exception as resume_err:
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
# Авто-возобновление суточной подписки НЕ делаем здесь —
# это обязанность try_resume_disabled_daily_after_topup (через send_cart_notification_after_topup)
# и DailySubscriptionService.process_auto_resume (30-минутный цикл).
# Они корректно списывают суточную плату при возобновлении.
return True
+41 -1
View File
@@ -208,7 +208,11 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
current_time = datetime.now(UTC)
if subscription.status == 'expired' or subscription.end_date <= current_time:
if subscription.status == 'disabled':
actual_status = 'disabled'
status_display = texts.t('SUBSCRIPTION_STATUS_DISABLED', 'Приостановлена')
status_emoji = '⏸️'
elif subscription.status == 'expired' or subscription.end_date <= current_time:
actual_status = 'expired'
status_display = texts.t('SUBSCRIPTION_STATUS_EXPIRED', 'Истекла')
status_emoji = '🔴'
@@ -3222,6 +3226,42 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
return
if needs_resume:
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and is_inactive:
from app.database.crud.user import subtract_user_balance
deducted = await subtract_user_balance(
db,
db_user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
await callback.answer(
texts.t(
'INSUFFICIENT_BALANCE_FOR_RESUME',
f'❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}',
),
show_alert=True,
)
return
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as tx_error:
logger.warning('Не удалось создать транзакцию при возобновлении', error=tx_error)
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
+1
View File
@@ -1704,6 +1704,7 @@
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Subscription resumed!</b>\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
+1
View File
@@ -1723,6 +1723,7 @@
"DAILY_SWITCH_WARNING": "⚠️ <b>توجه!</b> {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست می‌روند!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>اشتراک از سر گرفته شد!</b>\n\nتعرفه روزانه «{tariff_name}» پس از شارژ موجودی از سر گرفته شد.\n\n💳 کسر شده: {amount}\n💰 باقی‌مانده: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>اشتراک منقضی شد</b>\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>اشتراک غیرفعال شد</b>\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
"WEBHOOK_SUB_ENABLED": "✅ <b>اشتراک فعال شد</b>\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
+1
View File
@@ -1725,6 +1725,7 @@
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
+3
View File
@@ -1592,6 +1592,9 @@
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Підписка призупинена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Підписка відновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Підписка відновлена!</b>\n\nВаш добовий тариф «{tariff_name}» відновлено після поповнення балансу.\n\n💳 Списано: {amount}\n💰 Залишок: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
+3
View File
@@ -1588,6 +1588,9 @@
"MODEM_PRICE_WITH_DISCOUNT": "费用:<s>{base_price}</s> <b>{final_price}</b>{months}个月)\n🎁 折扣{discount}%-{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "费用:{price}{months}个月)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ 订阅已暂停",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ 订阅已恢复!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>订阅已恢复!</b>\n\n您的日套餐「{tariff_name}」已在充值后恢复。\n\n💳 扣费:{amount}\n💰 余额:{balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
"WEBHOOK_SUB_DISABLED": "🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
"WEBHOOK_SUB_ENABLED": "✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
+31 -1
View File
@@ -306,7 +306,37 @@ async def send_cart_notification_after_topup(
from aiogram import types
from app.database.crud.user import get_user_by_id
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
try_auto_extend_expired_after_topup,
try_resume_disabled_daily_after_topup,
)
# Try to resume DISABLED daily subscription immediately (highest priority)
try:
daily_resumed = await try_resume_disabled_daily_after_topup(db, user, bot=bot)
if daily_resumed:
return False
except Exception as daily_error:
logger.error(
'Ошибка авто-возобновления суточной подписки после пополнения',
user_id=user.id,
error=daily_error,
exc_info=True,
)
# Try to auto-extend expired subscription (works without cart)
try:
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
if auto_extended:
return False
except Exception as extend_error:
logger.error(
'Ошибка автопродления истёкшей подписки после пополнения',
user_id=user.id,
error=extend_error,
exc_info=True,
)
cart_data = await user_cart_service.get_user_cart(user.id)
if not cart_data:
@@ -15,7 +15,7 @@ from app.config import settings
from app.database.crud.subscription import extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.models import Subscription, TransactionType, User
from app.database.models import Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
@@ -1641,6 +1641,574 @@ async def _auto_add_traffic(
return True
async def try_auto_extend_expired_after_topup(
db: AsyncSession,
user: User,
*,
bot: Bot | None = None,
) -> bool:
"""Try to auto-extend an expired subscription after balance top-up.
Unlike cart-based auto-purchase, this works without a saved cart.
It finds the user's expired subscription and attempts to extend it
with the shortest available period if the balance is sufficient.
Returns True if the subscription was successfully extended.
"""
from app.cabinet.routes.websocket import notify_user_subscription_renewed
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.transaction import get_user_transactions
if not user or not getattr(user, 'id', None):
return False
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is None:
logger.debug(
'🔄 Автопродление expired: у пользователя нет подписки',
format_user_id=_format_user_id(user),
)
return False
# Only process expired subscriptions (not trial, not disabled)
if subscription.status != SubscriptionStatus.EXPIRED.value:
return False
if subscription.is_trial:
return False
# Only process subscriptions expired within the last 30 days
if subscription.end_date is None:
return False
expired_delta = datetime.now(UTC) - subscription.end_date
if expired_delta.days > 30:
logger.info(
'🔄 Автопродление expired: подписка истекла более 30 дней назад',
format_user_id=_format_user_id(user),
expired_days=expired_delta.days,
)
return False
# Determine renewal period from tariff or default to 30 days
tariff = getattr(subscription, 'tariff', None)
if tariff:
period_days = tariff.get_shortest_period() or 30
else:
period_days = 30
# Calculate renewal price
subscription_service = SubscriptionService()
try:
renewal_cost = await subscription_service.calculate_renewal_price(
subscription, period_days, db, user=user,
)
except Exception as error:
logger.error(
'❌ Автопродление expired: ошибка расчёта стоимости',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
return False
if renewal_cost <= 0:
logger.warning(
'❌ Автопродление expired: некорректная стоимость',
format_user_id=_format_user_id(user),
renewal_cost=renewal_cost,
)
return False
# Check balance
if user.balance_kopeks < renewal_cost:
logger.info(
'🔄 Автопродление expired: недостаточно средств',
format_user_id=_format_user_id(user),
balance_kopeks=user.balance_kopeks,
renewal_cost=renewal_cost,
)
return False
# Race condition guard: skip if a subscription payment was made in the last 60 seconds
try:
recent_transactions = await get_user_transactions(db, user.id, limit=1)
if recent_transactions:
last_tx = recent_transactions[0]
if (
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
and last_tx.created_at
and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
):
logger.info(
'🔄 Автопродление expired: пропуск — подписка оплачена секунд назад',
format_user_id=_format_user_id(user),
total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
)
return False
except Exception as check_error:
logger.warning(
'🔄 Автопродление expired: ошибка проверки последней транзакции',
format_user_id=_format_user_id(user),
check_error=check_error,
)
# Determine if promo offer discount was applied (for consume flag)
from app.utils.promo_offer import get_user_active_promo_discount_percent
consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
# Deduct balance
description = f'Автопродление истёкшей подписки на {period_days} дней'
try:
deducted = await subtract_user_balance(
db,
user,
renewal_cost,
description,
consume_promo_offer=consume_promo_offer,
mark_as_paid_subscription=True,
)
except Exception as error:
logger.error(
'❌ Автопродление expired: ошибка списания средств',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
return False
if not deducted:
logger.warning(
'❌ Автопродление expired: списание средств не выполнено',
format_user_id=_format_user_id(user),
)
return False
old_end_date = subscription.end_date
was_trial = subscription.is_trial
# Extend subscription
try:
updated_subscription = await extend_subscription(db, subscription, period_days)
# Convert trial to paid if needed
if was_trial and subscription.is_trial:
subscription.is_trial = False
subscription.status = 'active'
await db.commit()
logger.info(
'✅ Триал конвертирован в платную подписку (автопродление expired)',
subscription_id=subscription.id,
format_user_id=_format_user_id(user),
)
except Exception as error:
logger.error(
'❌ Автопродление expired: не удалось продлить подписку',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
await db.rollback()
return False
# Create transaction record
transaction = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=renewal_cost,
description=description,
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось зафиксировать транзакцию',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
# Update RemnaWave
try:
await subscription_service.update_remnawave_user(
db,
updated_subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason='автопродление истёкшей подписки',
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось обновить RemnaWave',
format_user_id=_format_user_id(user),
error=error,
)
texts = get_texts(getattr(user, 'language', 'ru'))
period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
new_end_date = updated_subscription.end_date
end_date_label = format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')
# Admin notification
try:
from app.services.subscription_renewal_service import with_admin_notification_service
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
updated_subscription,
transaction,
period_days,
old_end_date,
new_end_date=new_end_date,
balance_after=user.balance_kopeks,
)
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось уведомить администраторов',
format_user_id=_format_user_id(user),
error=error,
)
# Send user notification (only for Telegram users)
if bot and user.telegram_id:
try:
auto_message = texts.t(
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED',
'✅ Subscription automatically extended for {period}.',
).format(period=period_label)
details_message = texts.t(
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS',
'New expiration date: {date}.',
).format(date=end_date_label)
hint_message = texts.t(
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
"Open the 'My subscription' section to access your link.",
)
full_message = '\n\n'.join(
part.strip() for part in [auto_message, details_message, hint_message] if part and part.strip()
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=full_message,
reply_markup=keyboard,
parse_mode='HTML',
)
except Exception as error:
logger.error(
'⚠️ Автопродление expired: не удалось уведомить пользователя',
telegram_id=user.telegram_id or user.id,
error=error,
)
logger.info(
'✅ Автопродление expired: подписка продлена для пользователя',
period_days=period_days,
renewal_cost=renewal_cost,
format_user_id=_format_user_id(user),
)
# Send WebSocket notification
try:
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=new_end_date.isoformat() if new_end_date else '',
amount_kopeks=renewal_cost,
)
except Exception as ws_error:
logger.warning(
'⚠️ Автопродление expired: не удалось отправить WS уведомление',
format_user_id=_format_user_id(user),
ws_error=ws_error,
)
return True
async def try_resume_disabled_daily_after_topup(
db: AsyncSession,
user: User,
*,
bot: Bot | None = None,
) -> bool:
"""Resume a DISABLED daily subscription immediately after balance top-up.
Daily subscriptions get DISABLED when balance is insufficient.
The DailySubscriptionService loop picks them up every 30 minutes,
but this function provides instant resumption right when the user tops up.
Returns True if the subscription was successfully resumed and charged.
"""
from app.cabinet.routes.websocket import notify_user_subscription_renewed
from app.database.crud.subscription import get_subscription_by_user_id, update_daily_charge_time
if not user or not getattr(user, 'id', None):
return False
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is None:
return False
# Only handle DISABLED (or EXPIRED) daily tariff subscriptions
if subscription.status not in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
return False
if not getattr(subscription, 'is_daily_tariff', False):
return False
if subscription.is_trial:
return False
# Skip user-paused subscriptions — they chose to pause, don't auto-resume
if getattr(subscription, 'is_daily_paused', False):
return False
tariff = getattr(subscription, 'tariff', None)
if not tariff:
return False
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price <= 0:
return False
# Check balance
if user.balance_kopeks < daily_price:
logger.info(
'🔄 Авто-возобновление daily: недостаточно средств',
format_user_id=_format_user_id(user),
balance_kopeks=user.balance_kopeks,
daily_price=daily_price,
)
return False
# Race condition guard: skip if a subscription payment was made in the last 60 seconds
from app.database.crud.transaction import get_user_transactions
try:
recent_transactions = await get_user_transactions(db, user.id, limit=1)
if recent_transactions:
last_tx = recent_transactions[0]
if (
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
and last_tx.created_at
and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
):
logger.info(
'🔄 Авто-возобновление daily: пропуск — оплата секунд назад',
format_user_id=_format_user_id(user),
)
return False
except Exception as check_error:
logger.warning(
'🔄 Авто-возобновление daily: ошибка проверки последней транзакции',
format_user_id=_format_user_id(user),
check_error=check_error,
)
# Deduct daily price FIRST (before changing status to avoid free-access window)
previous_status = subscription.status
description = f'Суточная оплата тарифа «{tariff.name}» (авто-возобновление)'
try:
deducted = await subtract_user_balance(
db,
user,
daily_price,
description,
mark_as_paid_subscription=True,
)
except Exception as error:
logger.error(
'❌ Авто-возобновление daily: ошибка списания средств',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
return False
if not deducted:
logger.warning(
'❌ Авто-возобновление daily: списание не выполнено',
format_user_id=_format_user_id(user),
)
return False
# Activate the subscription (balance already deducted)
subscription.status = SubscriptionStatus.ACTIVE.value
try:
await db.commit()
await db.refresh(subscription)
except Exception as error:
logger.error(
'❌ Авто-возобновление daily: ошибка активации подписки',
format_user_id=_format_user_id(user),
error=error,
exc_info=True,
)
await db.rollback()
return False
logger.info(
'✅ Авто-возобновление daily: подписка → ACTIVE после пополнения',
format_user_id=_format_user_id(user),
previous_status=previous_status,
subscription_id=subscription.id,
)
# Create transaction
transaction = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=description,
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось создать транзакцию',
format_user_id=_format_user_id(user),
error=error,
)
# Update charge time and end_date (+24h)
old_end_date = subscription.end_date
try:
subscription = await update_daily_charge_time(db, subscription)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось обновить время списания',
format_user_id=_format_user_id(user),
error=error,
)
# Sync with RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось обновить RemnaWave',
format_user_id=_format_user_id(user),
error=error,
)
# Admin notification
try:
from app.services.subscription_renewal_service import with_admin_notification_service
await with_admin_notification_service(
lambda svc: svc.send_subscription_extension_notification(
db,
user,
subscription,
transaction,
1,
old_end_date,
new_end_date=subscription.end_date,
balance_after=user.balance_kopeks,
)
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось уведомить администраторов',
format_user_id=_format_user_id(user),
error=error,
)
# User notification
if bot and user.telegram_id:
try:
texts = get_texts(getattr(user, 'language', 'ru'))
message = texts.t(
'DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP',
'✅ <b>Подписка возобновлена!</b>\n\n'
'Ваш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n'
'💳 Списано: {amount}\n'
'💰 Остаток: {balance}',
).format(
tariff_name=tariff.name,
amount=settings.format_price(daily_price),
balance=settings.format_price(user.balance_kopeks),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
callback_data='back_to_menu',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
reply_markup=keyboard,
parse_mode='HTML',
)
except Exception as error:
logger.error(
'⚠️ Авто-возобновление daily: не удалось уведомить пользователя',
telegram_id=user.telegram_id or user.id,
error=error,
)
logger.info(
'✅ Авто-возобновление daily: подписка возобновлена для пользователя',
format_user_id=_format_user_id(user),
daily_price=daily_price,
tariff_name=tariff.name,
)
# WebSocket notification
try:
await notify_user_subscription_renewed(
user_id=user.id,
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
amount_kopeks=daily_price,
)
except Exception as ws_error:
logger.warning(
'⚠️ Авто-возобновление daily: не удалось отправить WS уведомление',
format_user_id=_format_user_id(user),
ws_error=ws_error,
)
return True
async def auto_purchase_saved_cart_after_topup(
db: AsyncSession,
user: User,
+67 -16
View File
@@ -7337,14 +7337,26 @@ async def toggle_daily_subscription_pause_endpoint(
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
)
# Переключаем состояние паузы
# Определяем состояние
from app.database.models import SubscriptionStatus
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
new_paused_state = not is_currently_paused
was_disabled = subscription.status in (
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.EXPIRED.value,
)
# System-DISABLED subs (is_daily_paused=False) должны идти по пути resume
if was_disabled and not is_currently_paused:
new_paused_state = False # Force resume path
else:
new_paused_state = not is_currently_paused
subscription.is_daily_paused = new_paused_state
# Если снимаем с паузы, нужно проверить баланс для активации
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если снимаем с паузы, проверяем баланс и списываем оплату
if not new_paused_state:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and user.balance_kopeks < daily_price:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
@@ -7356,29 +7368,68 @@ async def toggle_daily_subscription_pause_endpoint(
},
)
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
from app.database.models import SubscriptionStatus
# Списываем суточную оплату ПЕРЕД активацией
if was_disabled:
if daily_price > 0:
from app.database.crud.user import subtract_user_balance
if subscription.status == SubscriptionStatus.DISABLED.value:
deducted = await subtract_user_balance(
db,
user,
daily_price,
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
mark_as_paid_subscription=True,
)
if not deducted:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
'code': 'insufficient_balance',
'message': 'Balance deduction failed',
'required': daily_price,
'balance': user.balance_kopeks,
},
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
try:
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=daily_price,
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
)
except Exception as exc:
logger.warning('Failed to create resume transaction in miniapp', error=exc)
# Баланс списан — теперь активируем
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.now(UTC)
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
logger.info('✅ Суточная подписка восстановлена из DISABLED в ACTIVE', subscription_id=subscription.id)
logger.info(
'✅ Суточная подписка восстановлена в ACTIVE (miniapp)',
subscription_id=subscription.id,
previous_status='disabled/expired',
)
await db.commit()
await db.refresh(subscription)
await db.refresh(user)
# Синхронизация с RemnaWave
# При паузе VPN продолжает работать до конца оплаченного времени,
# поэтому НЕ отключаем пользователя в RemnaWave
# При возобновлении включаем если был отключен (например, из-за истечения срока)
if not new_paused_state:
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
if not new_paused_state and was_disabled:
try:
service = SubscriptionService()
if user.remnawave_uuid:
await service.enable_remnawave_user(user.remnawave_uuid)
await service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)