250 lines
8.9 KiB
Python
250 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import TYPE_CHECKING
|
|
|
|
from database import (
|
|
add_referral,
|
|
get_balance,
|
|
get_referral_by_referred_id,
|
|
store_gift_link,
|
|
update_balance,
|
|
update_trial,
|
|
)
|
|
from database.access.resolution import resolve_user_optional
|
|
from database.gifts import (
|
|
count_gift_usages,
|
|
get_gift_locked,
|
|
get_gift_usage,
|
|
mark_gift_fully_redeemed,
|
|
record_gift_usage,
|
|
)
|
|
from database.tariffs import get_tariff_by_id
|
|
from logger import logger
|
|
from services.formatting import format_days, format_months, get_gift_link, get_site_gift_link
|
|
|
|
from .errors import InsufficientFundsError, NotFoundError, ValidationError
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
@dataclass
|
|
class GiftRedeemResult:
|
|
message: str
|
|
gift_id: str
|
|
tariff_id: int
|
|
duration_days: int
|
|
|
|
|
|
@dataclass
|
|
class GiftCreateResult:
|
|
gift_id: str
|
|
gift_link: str
|
|
site_gift_link: str
|
|
tariff_name: str
|
|
duration_days: int
|
|
duration_text: str
|
|
expiry_time: datetime
|
|
price_charged: int
|
|
|
|
|
|
def normalize_gift_code(raw: str) -> str:
|
|
s = raw.strip()
|
|
if not s:
|
|
return ""
|
|
if "/gift/" in s:
|
|
from_url = s.split("/gift/")[-1]
|
|
s = from_url.split("?")[0].split("#")[0].strip()
|
|
if s.startswith("gift_"):
|
|
s = s[5:].strip()
|
|
for prefix in ("start=gift_", "start="):
|
|
idx = s.find(prefix)
|
|
if idx >= 0:
|
|
token = s[idx + len(prefix) :]
|
|
return token.split("&")[0].split("?")[0].split("#")[0].strip()
|
|
return s.split("?")[0].split("#")[0].strip()
|
|
|
|
|
|
def _format_duration(days: int) -> str:
|
|
return format_months(days // 30) if days % 30 == 0 else format_days(days)
|
|
|
|
|
|
async def redeem_gift(
|
|
session: AsyncSession,
|
|
gift_code: str,
|
|
billing_user_ref: int,
|
|
) -> GiftRedeemResult:
|
|
"""Активирует подарок для пользователя.
|
|
|
|
Создаёт ключ, записывает usage, привязывает реферала.
|
|
Raises: ValidationError, NotFoundError
|
|
"""
|
|
code = normalize_gift_code(gift_code)
|
|
if not code:
|
|
raise ValidationError("Укажите ссылку или код подарка")
|
|
|
|
gift_info = await get_gift_locked(session, code)
|
|
if not gift_info:
|
|
raise NotFoundError("Подарок не найден или срок ссылки истёк")
|
|
|
|
wu = await resolve_user_optional(session, billing_user_ref)
|
|
if wu is None:
|
|
raise NotFoundError("Пользователь не найден")
|
|
|
|
if gift_info.expiry_time and gift_info.expiry_time < datetime.utcnow():
|
|
raise ValidationError("Срок действия подарка истёк")
|
|
|
|
if gift_info.sender_user_id == wu.id:
|
|
raise ValidationError("Нельзя активировать подарок, который вы создали сами")
|
|
|
|
if await get_gift_usage(session, gift_info.gift_id, wu.id) is not None:
|
|
raise ValidationError("Вы уже активировали этот подарок")
|
|
|
|
if gift_info.recipient_user_id and not gift_info.is_unlimited:
|
|
raise ValidationError("Этот подарок уже был активирован другим пользователем")
|
|
|
|
if not gift_info.is_unlimited:
|
|
usage_count = await count_gift_usages(session, gift_info.gift_id)
|
|
if (
|
|
gift_info.is_used
|
|
or (gift_info.max_usages is not None and usage_count >= gift_info.max_usages)
|
|
or (gift_info.max_usages is None and gift_info.recipient_user_id is not None)
|
|
):
|
|
raise ValidationError("Этот подарок уже был использован")
|
|
|
|
existing_referral = await get_referral_by_referred_id(session, wu.id)
|
|
if not existing_referral and gift_info.sender_user_id:
|
|
await add_referral(session, wu.id, gift_info.sender_user_id)
|
|
|
|
await update_trial(session, wu.id, 1)
|
|
|
|
tariff = await get_tariff_by_id(session, int(gift_info.tariff_id)) if gift_info.tariff_id else None
|
|
if not tariff:
|
|
raise NotFoundError("Тариф, связанный с подарком, не найден")
|
|
|
|
from services.keys import create_vpn_key_headless
|
|
|
|
duration_days = int(tariff["duration_days"] or 0)
|
|
expiry_time = datetime.now(timezone.utc) + timedelta(days=duration_days)
|
|
|
|
selected_device_limit = getattr(gift_info, "selected_device_limit", None)
|
|
selected_traffic_gb = getattr(gift_info, "selected_traffic_gb", None)
|
|
selected_price_rub = getattr(gift_info, "selected_price_rub", None)
|
|
|
|
await create_vpn_key_headless(
|
|
session=session,
|
|
tg_id=wu.id,
|
|
expiry_time=expiry_time,
|
|
plan=int(tariff["id"]),
|
|
selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None,
|
|
selected_traffic_gb=int(selected_traffic_gb) if selected_traffic_gb is not None else None,
|
|
selected_price_rub=int(selected_price_rub) if selected_price_rub is not None else None,
|
|
skip_balance_charge=True,
|
|
)
|
|
|
|
await record_gift_usage(session, gift_info.gift_id, wu.id, wu.tg_id)
|
|
|
|
if not gift_info.is_unlimited:
|
|
usage_count = await count_gift_usages(session, gift_info.gift_id)
|
|
if gift_info.max_usages and usage_count >= gift_info.max_usages:
|
|
await mark_gift_fully_redeemed(session, code, wu.id, wu.tg_id)
|
|
|
|
duration_text = _format_duration(duration_days)
|
|
|
|
try:
|
|
from database.web_notifications import notify_web
|
|
|
|
if wu.tg_id is not None:
|
|
await notify_web(
|
|
session,
|
|
tg_id=wu.tg_id,
|
|
type="gift_received",
|
|
template_vars={"name": tariff["name"], "duration": duration_text},
|
|
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
|
|
)
|
|
if gift_info.sender_user_id:
|
|
sender = await resolve_user_optional(session, gift_info.sender_user_id)
|
|
if sender and sender.tg_id is not None:
|
|
await notify_web(
|
|
session,
|
|
tg_id=int(sender.tg_id),
|
|
type="gift_redeemed",
|
|
title="Ваш подарок активирован",
|
|
message=f"Получатель активировал подарок — подписка на {duration_text}.",
|
|
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
|
|
)
|
|
except Exception as e:
|
|
logger.warning("[Gifts] Ошибка отправки уведомления о подарке: {}", e)
|
|
|
|
return GiftRedeemResult(
|
|
message=f"Подарок активирован — подписка на {duration_text}",
|
|
gift_id=gift_info.gift_id,
|
|
tariff_id=int(tariff["id"]),
|
|
duration_days=duration_days,
|
|
)
|
|
|
|
|
|
async def create_gift(
|
|
session: AsyncSession,
|
|
sender_user_ref: int,
|
|
tariff_id: int,
|
|
selected_device_limit: int | None = None,
|
|
selected_traffic_gb: int | None = None,
|
|
selected_price_rub: int | None = None,
|
|
) -> GiftCreateResult:
|
|
"""Создаёт подарок: списывает баланс, сохраняет в БД.
|
|
|
|
Raises: NotFoundError, InsufficientFundsError
|
|
"""
|
|
tariff = await get_tariff_by_id(session, int(tariff_id))
|
|
if not tariff or tariff.get("group_code") != "gifts":
|
|
raise NotFoundError("Тариф не найден")
|
|
|
|
price_to_charge = int(selected_price_rub) if selected_price_rub is not None else int(tariff["price_rub"])
|
|
|
|
balance = await get_balance(session, sender_user_ref)
|
|
if balance < price_to_charge:
|
|
raise InsufficientFundsError(
|
|
"Недостаточно средств для создания подарка",
|
|
required=price_to_charge,
|
|
balance=balance,
|
|
)
|
|
|
|
await update_balance(session, sender_user_ref, -price_to_charge)
|
|
|
|
duration_days = int(tariff["duration_days"] or 0)
|
|
expiry_time = datetime.utcnow() + timedelta(days=duration_days)
|
|
gift_id = uuid.uuid4().hex
|
|
gift_link = get_gift_link(sender_user_ref, gift_id)
|
|
site_gift_link = get_site_gift_link(gift_id)
|
|
|
|
await store_gift_link(
|
|
session=session,
|
|
gift_id=gift_id,
|
|
sender_legacy_ref=sender_user_ref,
|
|
selected_months=duration_days // 30,
|
|
expiry_time=expiry_time,
|
|
gift_link=gift_link,
|
|
tariff_id=int(tariff["id"]),
|
|
max_usages=1,
|
|
selected_device_limit=selected_device_limit,
|
|
selected_traffic_gb=selected_traffic_gb,
|
|
selected_price_rub=price_to_charge,
|
|
)
|
|
|
|
return GiftCreateResult(
|
|
gift_id=gift_id,
|
|
gift_link=gift_link,
|
|
site_gift_link=site_gift_link,
|
|
tariff_name=tariff["name"],
|
|
duration_days=duration_days,
|
|
duration_text=_format_duration(duration_days),
|
|
expiry_time=expiry_time,
|
|
price_charged=price_to_charge,
|
|
)
|