Files

356 lines
12 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 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⚠️ <b>Доп. опции приостановлены</b> (не оплачен период).\n"
f"Подписка: {sub} · <b>{fee} ₽/мес</b>\n"
f"Пополни баланс минимум на <b>{fee} ₽</b> — списание при открытии кабинета "
f"или автоматически раз в сутки."
)
return (
f"\n📅 Доп. опции: {sub} · <b>{fee} ₽/мес</b>\n"
f"Следующее списание: <b>{nxt_s}</b>"
)
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,
(
"<b>✅ Доп. опции снова активны</b>\n\n"
f"Списано <b>{int(fee)} ₽</b> за месяц.\n"
f"Следующее списание: <b>{new_next.strftime('%d.%m.%Y')}</b>"
),
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,
(
"<b>⚠️ Доп. опции приостановлены</b>\n\n"
f"На балансе не хватает <b>{int(fee)} ₽</b> для продления.\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,
(
"<b>📅 Продление доп. опций</b>\n\n"
f"Списано <b>{int(fee)} ₽</b> за месяц.\n"
f"Следующее списание: <b>{new_next.strftime('%d.%m.%Y')}</b>"
),
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)