356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""Ежемесячная оплата доп. трафика и устройств."""
|
||
|
||
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)
|