Files

675 lines
23 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.
"""Покупка тарифа: баланс + создание/продление пользователя Remnawave."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from remnawave import RemnawaveSDK
from remnawave.enums import TrafficLimitStrategy, UserStatus
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto
from remnawave_client import friendly_remnawave_error
from services.db import Database
from services.devices import (
BASE_DEVICES,
DEVICE_PACKS,
EXTRA_DEVICE_PRICE_RUB,
DevicePack,
DeviceUsage,
)
from services.extras_billing import (
effective_extra_devices,
extras_billing_line,
on_extras_purchased,
process_user_extras_billing,
)
from services.tariffs import Tariff
from services.traffic import (
BASE_TRAFFIC_GB,
TrafficPack,
TrafficUsage,
ensure_base_limit_bytes,
format_gb,
gb_to_bytes,
traffic_from_panel_user,
)
from services.remnawave_users import (
device_limit_for_user as _device_limit_for_user,
find_panel_user as _find_panel_user,
sync_device_policy,
username_for,
)
logger = logging.getLogger(__name__)
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _traffic_limit_for_update(existing, tariff: Tariff) -> float:
"""При продлении не затираем докупленный трафик, но не меньше базы тарифа."""
current = ensure_base_limit_bytes(getattr(existing, "traffic_limit_bytes", 0))
return max(current, float(tariff.traffic_bytes))
async def provision_subscription(
sdk: RemnawaveSDK,
*,
telegram_id: int,
tariff: Tariff,
login: str | None = None,
extra_devices: int = 0,
) -> tuple[str, str, datetime]:
"""Создать или продлить пользователя. Возвращает (uuid, sub_url, expire_at)."""
now = datetime.now(timezone.utc)
existing = await _find_panel_user(sdk, telegram_id, login=login)
panel_tg_id = telegram_id if telegram_id > 0 else None
device_limit = _device_limit_for_user(extra_devices, soft_cap=True)
if existing is not None:
current_expire = _as_utc(existing.expire_at)
base = current_expire if current_expire > now else now
new_expire = base + timedelta(days=tariff.days)
body = UpdateUserRequestDto(
uuid=existing.uuid,
status=UserStatus.ACTIVE,
expire_at=new_expire,
telegram_id=panel_tg_id,
hwid_device_limit=device_limit,
traffic_limit_bytes=_traffic_limit_for_update(existing, tariff),
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
active_internal_squads=list(tariff.squad_uuids) or None,
)
updated = await sdk.users.update_user(body)
return str(updated.uuid), updated.subscription_url, _as_utc(updated.expire_at)
new_expire = now + timedelta(days=tariff.days)
created = await sdk.users.create_user(
CreateUserRequestDto(
username=username_for(telegram_id, login=login),
expire_at=new_expire,
status=UserStatus.ACTIVE,
telegram_id=panel_tg_id,
hwid_device_limit=device_limit,
traffic_limit_bytes=float(tariff.traffic_bytes),
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
active_internal_squads=list(tariff.squad_uuids) or None,
description=f"Bought via bot: {tariff.id}",
tag="BOT",
)
)
return str(created.uuid), created.subscription_url, _as_utc(created.expire_at)
async def purchase_tariff(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
tariff: Tariff,
charge_balance: bool = True,
payment_meta: str | None = None,
renew: bool = False,
promo_code: str | None = None,
) -> PurchaseResult:
bot_user = db.ensure_user(telegram_id)
if bot_user.banned:
return PurchaseResult(
ok=False,
message="Аккаунт заблокирован. Обратитесь в поддержку.",
balance=bot_user.balance,
)
balance = bot_user.balance
login = bot_user.login
if renew:
existing = await _find_panel_user(sdk, telegram_id, login=login)
if existing is None:
return PurchaseResult(
ok=False,
message=(
"Нет подписки для продления.\n"
"Сначала купи тариф в разделе «Тарифы»."
),
balance=balance,
)
charge_price = float(tariff.price)
bonus_days = 0
promo_row = None
if promo_code and charge_balance:
from services.db import AuthError
try:
charge_price, bonus_days, promo_row = db.apply_promo_to_price(
code=promo_code,
telegram_id=telegram_id,
tariff_id=tariff.id,
price=float(tariff.price),
days=tariff.days,
)
except AuthError as exc:
return PurchaseResult(ok=False, message=str(exc), balance=balance)
effective_tariff = tariff
if bonus_days > 0:
effective_tariff = Tariff(
id=tariff.id,
title=tariff.title,
price=tariff.price,
days=tariff.days + bonus_days,
traffic_gb=tariff.traffic_gb,
device_limit=tariff.device_limit,
squad_uuids=tariff.squad_uuids,
description=tariff.description,
)
if charge_balance:
if charge_price > 0:
ok, balance = db.try_charge(
telegram_id,
charge_price,
kind="renewal" if renew else "purchase",
meta=payment_meta
or (
f"promo:{promo_row['code']}:" if promo_row else ""
)
+ ("renew:" + tariff.id if renew else tariff.id),
)
if not ok:
return PurchaseResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{charge_price:.0f} ₽</b>\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n\n"
"Пополни баланс криптой или через админа."
),
balance=balance,
)
# charge_price == 0 — бесплатно по промо
try:
rw_uuid, sub_url, expire_at = await provision_subscription(
sdk,
telegram_id=telegram_id,
tariff=effective_tariff,
login=login,
extra_devices=effective_extra_devices(bot_user),
)
except Exception as exc: # noqa: BLE001
if charge_balance and charge_price > 0:
balance = db.add_balance(
telegram_id,
charge_price,
kind="refund",
meta=f"refund:{tariff.id}:{exc}",
)
return PurchaseResult(
ok=False,
message=(
"Не удалось продлить/выдать подписку. Средства возвращены.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
return PurchaseResult(
ok=False,
message=(
"Оплата прошла, но выдача подписки упала. Напиши админу.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
if promo_row and charge_balance:
saved = max(0.0, float(tariff.price) - charge_price)
try:
db.redeem_promo(
promo_id=int(promo_row["id"]),
telegram_id=telegram_id,
amount_saved=saved,
)
except Exception: # noqa: BLE001
logger.exception("promo redeem failed")
db.record_purchase(
telegram_id,
tariff_id=tariff.id,
price=charge_price if charge_balance else float(tariff.price),
remnawave_uuid=rw_uuid,
subscription_url=sub_url,
)
if not renew and charge_balance:
try:
ref_bonus = float(db.get_setting("referral_bonus_referrer") or "50")
welcome = float(db.get_setting("referral_bonus_referee") or "50")
db.grant_referral_rewards_on_first_purchase(
telegram_id,
referrer_bonus=max(0.0, ref_bonus),
referee_bonus=max(0.0, welcome),
)
except Exception: # noqa: BLE001
logger.exception("referral reward failed")
try:
from services.xui_wg import sync_wg_configs_limits
from services.awg_configs import sync_awg_configs_expiry
await sync_wg_configs_limits(
db=db,
telegram_id=telegram_id,
expire_at=expire_at,
restore_enabled=True,
)
await sync_awg_configs_expiry(
db=db,
sdk=sdk,
telegram_id=telegram_id,
login=login,
restore_enabled=True,
)
except Exception: # noqa: BLE001
logger.exception("WG/AWG expiry sync after tariff purchase failed")
expire_str = expire_at.strftime("%d.%m.%Y %H:%M UTC")
if renew:
paid_how = "с баланса" if charge_balance else "криптой"
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Подписка продлена</b>\n\n"
f"Тариф: <b>{tariff.title}</b> (+{tariff.days} дн.)\n"
f"Списано: <b>{tariff.format_price()}</b> ({paid_how})\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"Новый срок: <b>{expire_str}</b>\n\n"
f"🔗 Подписка:\n<code>{sub_url}</code>"
),
balance=balance,
subscription_url=sub_url,
expire_at=expire_at,
)
paid_how = "с баланса" if charge_balance else "криптой"
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Тариф «{tariff.title}» активирован</b>\n\n"
f"Оплата: <b>{tariff.format_price()}</b> ({paid_how})\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"Действует до: <b>{expire_str}</b>\n\n"
f"🔗 Подписка:\n<code>{sub_url}</code>"
),
balance=balance,
subscription_url=sub_url,
expire_at=expire_at,
)
@dataclass(frozen=True, slots=True)
class PurchaseResult:
ok: bool
message: str
balance: float
subscription_url: str | None = None
expire_at: datetime | None = None
traffic: TrafficUsage | None = None
devices: DeviceUsage | None = None
@dataclass(frozen=True, slots=True)
class SubscriptionInfo:
text: str
has_subscription: bool
expire_at: datetime | None = None
subscription_url: str | None = None
traffic: TrafficUsage | None = None
devices: DeviceUsage | None = None
async def get_my_subscription_info(
sdk: RemnawaveSDK,
telegram_id: int,
*,
login: str | None = None,
db: Database | None = None,
tariffs: list[Tariff] | None = None,
renewal_bot=None,
) -> SubscriptionInfo:
user = await _find_panel_user(sdk, telegram_id, login=login)
if user is None:
return SubscriptionInfo(
text=(
"<b>Моя подписка</b>\n\n"
"У тебя пока нет подписки.\n"
"Купи тариф в разделе «Тарифы».\n\n"
f"База: <b>{format_gb(BASE_TRAFFIC_GB, digits=0)}</b> · "
f"<b>{BASE_DEVICES} устройств</b>\n"
f"Докупка: +500 ГБ = 50 ₽/мес · +1 устройство = "
f"{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес\n"
"Доп. опции списываются с баланса каждые 30 дней."
),
has_subscription=False,
)
bot_user = db.ensure_user(telegram_id) if db is not None else None
if db is not None:
await process_user_extras_billing(
db, sdk, telegram_id, login=login
)
if tariffs:
from services.subscription_renewal import (
process_user_subscription_renewal,
)
await process_user_subscription_renewal(
db,
sdk,
tariffs,
telegram_id,
bot=renewal_bot,
login=login,
)
bot_user = db.get_user(telegram_id)
extra_paid = effective_extra_devices(bot_user) if bot_user else 0
device_blocked = bot_user.device_blocked if bot_user else False
# Старые «безлимит» (0) → база 1000 ГБ
if float(getattr(user, "traffic_limit_bytes", 0) or 0) <= 0:
try:
user = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=user.uuid,
traffic_limit_bytes=gb_to_bytes(BASE_TRAFFIC_GB),
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
)
)
except Exception: # noqa: BLE001
pass
devices: DeviceUsage | None = None
if db is not None:
user, devices = await sync_device_policy(
sdk,
user,
db=db,
telegram_id=telegram_id,
extra_paid=extra_paid,
device_blocked=device_blocked,
)
expire = _as_utc(user.expire_at)
now = datetime.now(timezone.utc)
if devices and devices.over_limit:
status = "отключена (превышение устройств)"
elif expire > now and user.status == UserStatus.ACTIVE:
status = "активна"
else:
status = "истекла / неактивна"
traffic = traffic_from_panel_user(user)
devices_line = devices.format_line() if devices else f"📱 Устройства: база <b>{BASE_DEVICES}</b>"
extras_line = extras_billing_line(bot_user) if bot_user else ""
text = (
f"<b>Моя подписка</b>\n\n"
f"Статус: <b>{status}</b>\n"
f"До: <b>{expire.strftime('%d.%m.%Y %H:%M UTC')}</b>\n"
f"{devices_line}\n"
f"{traffic.format_line()}\n"
f"{extras_line}\n\n"
f"🔗 <code>{user.subscription_url}</code>\n\n"
f"Докупка: трафик +500 ГБ / <b>50 ₽/мес</b> · устройство +1 / "
f"<b>{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес</b>"
)
return SubscriptionInfo(
text=text,
has_subscription=True,
expire_at=expire,
subscription_url=user.subscription_url,
traffic=traffic,
devices=devices,
)
async def purchase_device_pack(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
pack: DevicePack,
charge_balance: bool = True,
login: str | None = None,
payment_meta: str | None = None,
) -> PurchaseResult:
bot_user = db.ensure_user(telegram_id)
balance = bot_user.balance
login = login or bot_user.login
existing = await _find_panel_user(sdk, telegram_id, login=login)
if existing is None:
return PurchaseResult(
ok=False,
message="Нет подписки. Сначала купи тариф (база 10 устройств).",
balance=balance,
)
if charge_balance:
ok, balance = db.try_charge(
telegram_id,
pack.price,
kind="devices",
meta=payment_meta or f"devices:{pack.id}",
)
if not ok:
return PurchaseResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{pack.format_price()}</b>\n"
f"Баланс: <b>{balance:.0f} ₽</b>"
),
balance=balance,
)
new_extra = db.add_extra_devices(telegram_id, pack.slots)
on_extras_purchased(db, telegram_id)
try:
updated, devices = await sync_device_policy(
sdk,
existing,
db=db,
telegram_id=telegram_id,
extra_paid=new_extra,
device_blocked=False,
)
except Exception as exc: # noqa: BLE001
if charge_balance:
# откат слотов и денег
with db.pool.connection() as conn:
conn.execute(
"""
UPDATE users
SET extra_devices = GREATEST(extra_devices - %s, 0)
WHERE telegram_id = %s
""",
(pack.slots, telegram_id),
)
conn.commit()
balance = db.add_balance(
telegram_id,
pack.price,
kind="refund",
meta=f"refund:devices:{pack.id}:{exc}",
)
return PurchaseResult(
ok=False,
message=(
"Не удалось обновить лимит устройств. Средства возвращены.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
return PurchaseResult(
ok=False,
message="Оплата прошла, но лимит не обновился. Напиши админу.",
balance=balance,
)
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Лимит устройств увеличен</b>\n\n"
f"Добавлено: <b>+{pack.slots}</b>\n"
f"Списано: <b>{pack.format_price()}</b> (первый месяц)\n"
f"Далее — <b>{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес</b> за каждое доп. устройство с баланса.\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"{devices.format_line()}"
),
balance=balance,
subscription_url=getattr(updated, "subscription_url", None),
devices=devices,
)
async def purchase_traffic_pack(
*,
db: Database,
sdk: RemnawaveSDK,
telegram_id: int,
pack: TrafficPack,
charge_balance: bool = True,
login: str | None = None,
payment_meta: str | None = None,
) -> PurchaseResult:
bot_user = db.ensure_user(telegram_id)
balance = bot_user.balance
login = login or bot_user.login
existing = await _find_panel_user(sdk, telegram_id, login=login)
if existing is None:
return PurchaseResult(
ok=False,
message=(
"Нет активной подписки.\n"
"Сначала купи тариф — база 1000 ГБ уже входит."
),
balance=balance,
)
if charge_balance:
ok, balance = db.try_charge(
telegram_id,
pack.price,
kind="traffic",
meta=payment_meta or f"traffic:{pack.id}",
)
if not ok:
return PurchaseResult(
ok=False,
message=(
f"Недостаточно средств.\n"
f"Нужно: <b>{pack.format_price()}</b> (первый месяц)\n"
f"Баланс: <b>{balance:.0f} ₽</b>"
),
balance=balance,
)
db.add_extra_traffic_packs(telegram_id, pack.packs)
on_extras_purchased(db, telegram_id)
current_limit = ensure_base_limit_bytes(existing.traffic_limit_bytes)
new_limit = current_limit + gb_to_bytes(pack.gb)
try:
updated = await sdk.users.update_user(
UpdateUserRequestDto(
uuid=existing.uuid,
traffic_limit_bytes=new_limit,
traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
status=UserStatus.ACTIVE,
)
)
except Exception as exc: # noqa: BLE001
with db.pool.connection() as conn:
conn.execute(
"""
UPDATE users
SET extra_traffic_packs = GREATEST(extra_traffic_packs - %s, 0)
WHERE telegram_id = %s
""",
(pack.packs, telegram_id),
)
conn.commit()
if charge_balance:
balance = db.add_balance(
telegram_id,
pack.price,
kind="refund",
meta=f"refund:traffic:{pack.id}:{exc}",
)
return PurchaseResult(
ok=False,
message=(
"Не удалось увеличить трафик. Средства возвращены.\n\n"
+ friendly_remnawave_error(exc)
),
balance=balance,
)
return PurchaseResult(
ok=False,
message="Оплата прошла, но лимит не обновился. Напиши админу.\n\n"
+ friendly_remnawave_error(exc),
balance=balance,
)
traffic = traffic_from_panel_user(updated)
try:
from services.xui_wg import sync_wg_configs_limits
from services.awg_configs import sync_awg_configs_expiry
await sync_wg_configs_limits(
db=db,
telegram_id=telegram_id,
traffic_bytes=int(new_limit),
expire_at=_as_utc(updated.expire_at) if updated.expire_at else None,
restore_enabled=True,
)
await sync_awg_configs_expiry(
db=db,
sdk=sdk,
telegram_id=telegram_id,
login=login,
restore_enabled=True,
)
except Exception: # noqa: BLE001
logger.exception("WG/AWG sync after pack purchase failed")
return PurchaseResult(
ok=True,
message=(
f"✅ <b>Трафик увеличен</b>\n\n"
f"Добавлено: <b>{format_gb(pack.gb, digits=0)}</b>\n"
f"Списано: <b>{pack.format_price()}</b> (первый месяц)\n"
f"Далее — <b>{int(pack.price / pack.packs)} ₽/мес</b> за каждые 500 ГБ с баланса.\n"
f"Баланс: <b>{balance:.0f} ₽</b>\n"
f"{traffic.format_line()}"
),
balance=balance,
subscription_url=updated.subscription_url,
expire_at=_as_utc(updated.expire_at) if updated.expire_at else None,
traffic=traffic,
)
async def get_my_subscription(sdk: RemnawaveSDK, telegram_id: int) -> str:
return (await get_my_subscription_info(sdk, telegram_id)).text