"""Напоминания об окончании подписки и автопродление с баланса.""" from __future__ import annotations import asyncio import logging from datetime import date, datetime, timedelta, timezone from aiogram import Bot from remnawave import RemnawaveSDK from config import get_settings from services.db import Database from services.mailer import notify_important from services.remnawave_users import find_panel_user from services.purchase import purchase_tariff from services.tariffs import Tariff, get_tariff logger = logging.getLogger(__name__) REMIND_WITHIN_DAYS = 7 # Автопродление: в последние сутки или уже истекло (до 3 суток простоя) AUTO_RENEW_MAX_PAST_DAYS = 3 def _utc_now() -> datetime: return datetime.now(timezone.utc) def _as_utc(dt: datetime) -> datetime: if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def _days_left(expire_at: datetime) -> float: return (_as_utc(expire_at) - _utc_now()).total_seconds() / 86400.0 def _utc_today() -> date: return _utc_now().date() async def _notify( bot: Bot | None, telegram_id: int, text: str, *, db: Database | None = None, subject: str = "VPN Service", ) -> None: if db is not None: try: settings = get_settings(require_bot_token=False) except SystemExit: settings = None if settings is not None: await notify_important( db=db, settings=settings, telegram_id=telegram_id, html=text, subject=subject, bot=bot, ) return if bot is None or telegram_id <= 0: return try: await bot.send_message(telegram_id, text, parse_mode="HTML") except Exception: # noqa: BLE001 logger.warning("renewal notify failed tg=%s", telegram_id) def _format_days_left(days: float) -> str: if days < 0: n = int(abs(days)) if n == 0: return "истекла сегодня" return f"истекла {n} дн. назад" if days < 1: return "менее суток" n = int(round(days)) if n == 1: return "1 день" if 2 <= n <= 4: return f"{n} дня" return f"{n} дней" async def process_user_subscription_renewal( db: Database, sdk: RemnawaveSDK, tariffs: list[Tariff], telegram_id: int, *, bot: Bot | None = None, login: str | None = None, ) -> None: if telegram_id <= 0: return bot_user = db.get_user(telegram_id) login = login or (bot_user.login if bot_user else None) panel_user = await find_panel_user(sdk, telegram_id, login=login) if panel_user is None or not panel_user.expire_at: return expire = _as_utc(panel_user.expire_at) days = _days_left(expire) if days > REMIND_WITHIN_DAYS: return tariff_id = db.get_last_tariff_id(telegram_id) if not tariff_id: return tariff = get_tariff(tariffs, tariff_id) if not tariff: return balance = db.get_balance(telegram_id) can_auto = balance >= tariff.price bot_user = db.get_user(telegram_id) device_blocked = bool(bot_user and bot_user.device_blocked) auto_renew_on = bool(bot_user.auto_renew) if bot_user else True # Автопродление — только если пользователь включил опцию should_auto = days <= 1.0 or ( days < 0 and days >= -AUTO_RENEW_MAX_PAST_DAYS ) if should_auto and auto_renew_on and can_auto and not device_blocked: result = await purchase_tariff( db=db, sdk=sdk, telegram_id=telegram_id, tariff=tariff, charge_balance=True, renew=True, payment_meta=f"auto_renew:{tariff.id}", ) if result.ok: await _notify( bot, telegram_id, ( "✅ Подписка продлена автоматически\n\n" f"Тариф: {tariff.title}\n" f"Списано: {tariff.format_price()} с баланса\n" f"Баланс: {result.balance:.0f} ₽\n\n" "Отключить автопродление можно в кабинете в любой момент." ), db=db, subject="Подписка продлена — VPN Service", ) db.mark_expiry_reminder_sent(telegram_id, _utc_today()) return logger.info( "auto renew failed tg=%s: %s", telegram_id, result.message[:120], ) # Напоминание раз в сутки (UTC) if db.expiry_reminder_sent_on(telegram_id) == _utc_today(): return expire_label = expire.strftime("%d.%m.%Y %H:%M UTC") left_label = _format_days_left(days) lines = [ "⏳ Подписка скоро закончится\n", f"Осталось: {left_label}\n", f"Действует до: {expire_label}\n", f"Тариф для продления: {tariff.title} · {tariff.format_price()}\n", f"Баланс: {balance:.0f} ₽\n", ] if auto_renew_on and can_auto and days <= 1.0: lines.append( "\nАвтопродление включено — при достаточном балансе подписка продлится сама." ) elif auto_renew_on and can_auto: lines.append( f"\nАвтопродление включено. За сутки до окончания спишем " f"{tariff.format_price()} с баланса." ) elif auto_renew_on and device_blocked: lines.append( "\n⚠️ Подписка отключена из‑за превышения устройств — " "сначала оплати доп. слоты или отвяжи лишние устройства." ) elif auto_renew_on: need = max(0.0, tariff.price - balance) lines.append( f"\nПополни баланс минимум на {need:.0f} ₽ " f"для автопродления или продли вручную в боте / Mini App." ) else: lines.append( "\nАвтопродление выключено. Включите в кабинете или продлите тариф вручную." ) await _notify( bot, telegram_id, "".join(lines), db=db, subject="Подписка скоро закончится — VPN Service", ) db.mark_expiry_reminder_sent(telegram_id, _utc_today()) async def run_subscription_renewal_cycle( db: Database, sdk: RemnawaveSDK, tariffs: list[Tariff], *, bot: Bot | None = None, limit: int = 500, ) -> int: ids = db.list_telegram_ids_with_purchases(limit=limit) done = 0 for tid in ids: try: await process_user_subscription_renewal( db, sdk, tariffs, tid, bot=bot ) done += 1 except Exception: # noqa: BLE001 logger.exception("subscription renewal tg=%s", tid) return done async def subscription_renewal_worker( db: Database, sdk: RemnawaveSDK, bot: Bot, tariffs: list[Tariff], *, interval_sec: int = 6 * 3600, ) -> None: """Проверка несколько раз в сутки; напоминание — не чаще 1 раза в день на пользователя.""" await asyncio.sleep(90) while True: try: n = await run_subscription_renewal_cycle( db, sdk, tariffs, bot=bot ) if n: logger.info("subscription renewal cycle: %s users", n) except Exception: # noqa: BLE001 logger.exception("subscription renewal cycle error") await asyncio.sleep(interval_sec)