"""Ежемесячная оплата доп. трафика и устройств.""" from __future__ import annotations import asyncio import logging from dataclasses import dataclass from datetime import datetime, timedelta, timezone from aiogram import Bot from remnawave import RemnawaveSDK from remnawave.models import UpdateUserRequestDto from services.db import BotUser, Database from services.devices import EXTRA_DEVICE_PRICE_RUB, allowed_devices from services.mailer import notify_important from config import get_settings from services.remnawave_users import find_panel_user, sync_device_policy from services.traffic import ( PACK_GB, PACK_PRICE_RUB, gb_to_bytes, ensure_base_limit_bytes, ) logger = logging.getLogger(__name__) BILLING_PERIOD = timedelta(days=30) def _utc(dt: datetime | None) -> datetime | None: if dt is None: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def has_extras_subscription(user: BotUser) -> bool: return user.extra_devices > 0 or user.extra_traffic_packs > 0 def monthly_extras_fee_rub(*, extra_devices: int, extra_traffic_packs: int) -> float: return max(0, int(extra_devices)) * EXTRA_DEVICE_PRICE_RUB + max( 0, int(extra_traffic_packs) ) * PACK_PRICE_RUB def effective_extra_devices(user: BotUser) -> int: if not has_extras_subscription(user): return 0 if not user.extras_active: return 0 return max(0, user.extra_devices) def effective_extra_traffic_packs(user: BotUser) -> int: if not has_extras_subscription(user): return 0 if not user.extras_active: return 0 return max(0, user.extra_traffic_packs) def extras_monthly_fee(user: BotUser) -> float: if not has_extras_subscription(user): return 0.0 return monthly_extras_fee_rub( extra_devices=user.extra_devices, extra_traffic_packs=user.extra_traffic_packs, ) def format_monthly_suffix() -> str: return "/мес" def extras_billing_line(user: BotUser) -> str: if not has_extras_subscription(user): return "" fee = int(extras_monthly_fee(user)) parts: list[str] = [] if user.extra_traffic_packs: parts.append(f"+{user.extra_traffic_packs}×500 ГБ") if user.extra_devices: parts.append(f"+{user.extra_devices} устр.") sub = ", ".join(parts) nxt = _utc(user.extras_next_billing_at) nxt_s = nxt.strftime("%d.%m.%Y") if nxt else "—" if not user.extras_active: return ( f"\n⚠️ Доп. опции приостановлены (не оплачен период).\n" f"Подписка: {sub} · {fee} ₽/мес\n" f"Пополни баланс минимум на {fee} ₽ — списание при открытии кабинета " f"или автоматически раз в сутки." ) return ( f"\n📅 Доп. опции: {sub} · {fee} ₽/мес\n" f"Следующее списание: {nxt_s}" ) def extras_as_dict(user: BotUser) -> dict: fee = extras_monthly_fee(user) nxt = _utc(user.extras_next_billing_at) return { "active": bool(user.extras_active), "has_subscription": has_extras_subscription(user), "extra_traffic_packs": user.extra_traffic_packs, "extra_devices": user.extra_devices, "monthly_fee": fee, "monthly_fee_label": f"{int(fee)} ₽/мес" if fee else "0 ₽/мес", "next_billing_at": nxt.isoformat() if nxt else None, "next_billing_label": nxt.strftime("%d.%m.%Y") if nxt else None, "slot_price_monthly": EXTRA_DEVICE_PRICE_RUB, "pack_price_monthly": PACK_PRICE_RUB, "pack_gb": PACK_GB, "warning": ( "Доп. трафик и устройства оплачиваются ежемесячно с баланса. " "При нехватке средств доп. лимиты отключаются до оплаты." ), } @dataclass(slots=True) class ExtrasBillingResult: charged: bool = False suspended: bool = False reactivated: bool = False amount: float = 0.0 message: str = "" async def apply_panel_extras( sdk: RemnawaveSDK, db: Database, *, telegram_id: int, login: str | None = None, ) -> None: """Привести лимиты панели в соответствие с оплаченными доп. опциями.""" from services.traffic import BASE_TRAFFIC_GB user = db.get_user(telegram_id) if user is None: return panel_user = await find_panel_user(sdk, telegram_id, login=login or user.login) if panel_user is None: return subscribed_packs = max(0, user.extra_traffic_packs) panel_limit = float(panel_user.traffic_limit_bytes or 0) base_floor = gb_to_bytes(BASE_TRAFFIC_GB) if subscribed_packs > 0: inferred_base = panel_limit - gb_to_bytes(PACK_GB * subscribed_packs) base_bytes = ensure_base_limit_bytes(max(inferred_base, base_floor)) if user.extras_active: target_limit = base_bytes + gb_to_bytes(PACK_GB * subscribed_packs) else: target_limit = base_bytes else: target_limit = ensure_base_limit_bytes(panel_limit) try: current = float(panel_user.traffic_limit_bytes or 0) if abs(current - target_limit) > 1: panel_user = await sdk.users.update_user( UpdateUserRequestDto( uuid=panel_user.uuid, traffic_limit_bytes=target_limit, ) ) except Exception: # noqa: BLE001 logger.exception("apply_panel_extras traffic telegram_id=%s", telegram_id) extra_dev = effective_extra_devices(user) await sync_device_policy( sdk, panel_user, db=db, telegram_id=telegram_id, extra_paid=extra_dev, device_blocked=user.device_blocked, ) async def _notify_user( 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("extras billing notify failed tg=%s", telegram_id) async def process_user_extras_billing( db: Database, sdk: RemnawaveSDK, telegram_id: int, *, bot: Bot | None = None, login: str | None = None, ) -> ExtrasBillingResult: user = db.get_user(telegram_id) if user is None or not has_extras_subscription(user): return ExtrasBillingResult() now = datetime.now(timezone.utc) fee = extras_monthly_fee(user) next_at = _utc(user.extras_next_billing_at) if not user.extras_active: if user.balance < fee: return ExtrasBillingResult() ok, _bal = db.try_charge( telegram_id, fee, kind="extras_renewal", meta=f"extras:reactivate:{user.extra_devices}d+{user.extra_traffic_packs}t", ) if not ok: return ExtrasBillingResult() anchor = now if not next_at or next_at <= now else next_at new_next = anchor + BILLING_PERIOD db.set_extras_billing(telegram_id, next_billing_at=new_next, active=True) await apply_panel_extras(sdk, db, telegram_id=telegram_id, login=login) await _notify_user( bot, telegram_id, ( "✅ Доп. опции снова активны\n\n" f"Списано {int(fee)} ₽ за месяц.\n" f"Следующее списание: {new_next.strftime('%d.%m.%Y')}" ), db=db, subject="Доп. опции активны — VPN Service", ) return ExtrasBillingResult( charged=True, reactivated=True, amount=fee, message="reactivated" ) if not next_at or next_at > now: return ExtrasBillingResult() ok, _bal = db.try_charge( telegram_id, fee, kind="extras_renewal", meta=f"extras:{user.extra_devices}d+{user.extra_traffic_packs}t", ) if not ok: db.set_extras_active(telegram_id, False) await apply_panel_extras(sdk, db, telegram_id=telegram_id, login=login) await _notify_user( bot, telegram_id, ( "⚠️ Доп. опции приостановлены\n\n" f"На балансе не хватает {int(fee)} ₽ для продления.\n" "Пополни баланс для восстановления лимитов." ), db=db, subject="Доп. опции приостановлены — VPN Service", ) return ExtrasBillingResult(suspended=True, amount=fee, message="charge_failed") new_next = next_at + BILLING_PERIOD if new_next <= now: new_next = now + BILLING_PERIOD db.set_extras_billing(telegram_id, next_billing_at=new_next, active=True) await _notify_user( bot, telegram_id, ( "📅 Продление доп. опций\n\n" f"Списано {int(fee)} ₽ за месяц.\n" f"Следующее списание: {new_next.strftime('%d.%m.%Y')}" ), db=db, subject="Продление доп. опций — VPN Service", ) return ExtrasBillingResult(charged=True, amount=fee, message="renewed") def on_extras_purchased(db: Database, telegram_id: int) -> None: """Старт или продление учёта периода после покупки доп. опций.""" user = db.get_user(telegram_id) if user is None or not has_extras_subscription(user): return if user.extras_next_billing_at is None: db.set_extras_billing( telegram_id, next_billing_at=datetime.now(timezone.utc) + BILLING_PERIOD, active=True, ) else: db.set_extras_active(telegram_id, True) async def run_extras_billing_cycle( db: Database, sdk: RemnawaveSDK, *, bot: Bot | None = None, limit: int = 200, ) -> int: ids = db.list_users_for_extras_billing(limit=limit) done = 0 for tid in ids: try: await process_user_extras_billing(db, sdk, tid, bot=bot) done += 1 except Exception: # noqa: BLE001 logger.exception("extras billing failed tg=%s", tid) return done async def extras_billing_worker( db: Database, sdk: RemnawaveSDK, bot: Bot, *, interval_sec: int = 6 * 3600, ) -> None: while True: try: n = await run_extras_billing_cycle(db, sdk, bot=bot) if n: logger.info("extras billing processed %s users", n) except Exception: # noqa: BLE001 logger.exception("extras billing cycle error") await asyncio.sleep(interval_sec)