Template
255 lines
8.1 KiB
Python
255 lines
8.1 KiB
Python
"""Напоминания об окончании подписки и автопродление с баланса."""
|
||
|
||
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)
|