Files
vpn-telegram-bot/services/subscription_renewal.py

255 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Напоминания об окончании подписки и автопродление с баланса."""
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,
(
"<b>✅ Подписка продлена автоматически</b>\n\n"
f"Тариф: <b>{tariff.title}</b>\n"
f"Списано: <b>{tariff.format_price()}</b> с баланса\n"
f"Баланс: <b>{result.balance:.0f} ₽</b>\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 = [
"<b>⏳ Подписка скоро закончится</b>\n",
f"Осталось: <b>{left_label}</b>\n",
f"Действует до: <b>{expire_label}</b>\n",
f"Тариф для продления: <b>{tariff.title}</b> · {tariff.format_price()}\n",
f"Баланс: <b>{balance:.0f} ₽</b>\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"<b>{tariff.format_price()}</b> с баланса."
)
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Пополни баланс минимум на <b>{need:.0f} ₽</b> "
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)