1120 lines
41 KiB
Python
1120 lines
41 KiB
Python
from __future__ import annotations
|
||
|
||
import html
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from math import ceil
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
||
from app.config import PaymentPlanSettings, Settings
|
||
from app.db.base import utcnow
|
||
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
|
||
from app.schemas.remnawave import RemnawaveUser
|
||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||
from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError
|
||
from app.services.sync_service import SyncService
|
||
|
||
|
||
PENDING_PAYMENT_STATUS = "PENDING"
|
||
REVIEW_PAYMENT_STATUS = "REVIEW"
|
||
PROCESSING_PAYMENT_STATUS = "PROCESSING"
|
||
REJECTED_PAYMENT_STATUS = "REJECTED"
|
||
FULFILLED_PAYMENT_STATUS = "FULFILLED"
|
||
REFERRAL_BONUS_PENDING_STATUS = "PENDING"
|
||
REFERRAL_BONUS_PROCESSING_STATUS = "PROCESSING"
|
||
REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class AppliedDiscount:
|
||
code: str = ""
|
||
source: str = ""
|
||
discount_percent: int = 0
|
||
plan_code: str | None = None
|
||
expires_at: datetime | None = None
|
||
|
||
def applies_to_plan(self, plan_code: str, *, days: int | None = None) -> bool:
|
||
if not self.code:
|
||
return False
|
||
if self.plan_code is None:
|
||
return True
|
||
# "all_30" matches any product with 30 days, including single-duration product codes.
|
||
if self.plan_code.startswith("all_"):
|
||
raw_days = self.plan_code.removeprefix("all_")
|
||
return raw_days.isdigit() and (
|
||
plan_code.endswith(f"_{raw_days}") or days == int(raw_days)
|
||
)
|
||
# Exact match or product-prefix match (e.g. promo plan_code="vpn_white" matches plan "vpn_white_30")
|
||
return self.plan_code == plan_code or plan_code.startswith(f"{self.plan_code}_")
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class PaymentPlan:
|
||
code: str
|
||
label: str
|
||
title: str
|
||
description: str
|
||
duration_days: int
|
||
amount_rub: int
|
||
discount_percent: int
|
||
original_amount_rub: int
|
||
applied_referral_code: str = ""
|
||
discount_code: str = ""
|
||
discount_source: str = ""
|
||
discount_scope_plan_code: str | None = None
|
||
traffic_limit_bytes: int = 0
|
||
traffic_limit_strategy: str = "NO_RESET"
|
||
internal_squad_uuids: list[str] | None = None
|
||
external_squad_uuid: str | None = None
|
||
|
||
@property
|
||
def is_ready(self) -> bool:
|
||
return bool(self.internal_squad_uuids) and self.duration_days > 0 and self.amount_rub > 0
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class CreatedPaymentOrder:
|
||
order_uuid: str
|
||
provision_username: str
|
||
plan: PaymentPlan
|
||
transfer_text: str
|
||
extends_existing_access: bool = False
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class StoredPaymentOrder:
|
||
order_uuid: str
|
||
telegram_id: int
|
||
plan_title: str
|
||
plan_duration_days: int
|
||
amount_rub: int
|
||
provision_username: str
|
||
status: str
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class IssuedAccess:
|
||
telegram_id: int
|
||
username: str
|
||
subscription_url: str
|
||
expire_at: datetime
|
||
short_uuid: str
|
||
remnawave_user_uuid: str
|
||
traffic_limit_bytes: int
|
||
referral_bonus: ReferralBonusGrant | None = None
|
||
is_renewal: bool = False
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class ReferralBonusGrant:
|
||
inviter_telegram_id: int
|
||
bonus_days: int
|
||
status: str
|
||
total_applied_days: int = 0
|
||
expire_at: datetime | None = None
|
||
applied_to_user_uuid: str | None = None
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class AppliedReferralBonus:
|
||
telegram_id: int
|
||
total_bonus_days: int
|
||
expire_at: datetime
|
||
applied_to_user_uuid: str
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class PaymentAdminStats:
|
||
pending_orders: int = 0
|
||
review_orders: int = 0
|
||
pending_referral_bonuses: int = 0
|
||
|
||
|
||
class PaymentService:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
settings: Settings,
|
||
session_factory: async_sessionmaker[AsyncSession],
|
||
remnawave_client: RemnawaveApiClient,
|
||
sync_service: SyncService,
|
||
config_service: StaticBotConfigService | None = None,
|
||
) -> None:
|
||
self._settings = settings
|
||
self._session_factory = session_factory
|
||
self._remnawave_client = remnawave_client
|
||
self._sync_service = sync_service
|
||
self._config_service = config_service or StaticBotConfigService(settings)
|
||
|
||
async def _get_config(self) -> BotConfigSnapshot:
|
||
return await self._config_service.get_snapshot()
|
||
|
||
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
||
config = await self._get_config()
|
||
applied_discount = await self._get_applied_discount(
|
||
telegram_id,
|
||
config=config,
|
||
)
|
||
return [
|
||
self._build_plan(
|
||
plan,
|
||
applied_discount=applied_discount,
|
||
config=config,
|
||
)
|
||
for plan in config.payment_plans
|
||
]
|
||
|
||
async def create_order(
|
||
self,
|
||
*,
|
||
telegram_id: int,
|
||
username: str | None,
|
||
first_name: str | None,
|
||
last_name: str | None,
|
||
language_code: str | None,
|
||
plan_code: str,
|
||
) -> CreatedPaymentOrder:
|
||
config = await self._get_config()
|
||
applied_discount = await self._get_applied_discount(
|
||
telegram_id,
|
||
config=config,
|
||
)
|
||
plan = self._get_plan_by_code(
|
||
plan_code,
|
||
applied_discount=applied_discount,
|
||
config=config,
|
||
)
|
||
if not plan.is_ready:
|
||
raise ValueError(
|
||
"Покупка не настроена. Проверьте PAYMENT_PLANS и PAYMENT_INTERNAL_SQUAD_UUIDS."
|
||
)
|
||
|
||
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
|
||
order_uuid = str(uuid4())
|
||
invoice_payload = f"manual:{plan.code}:{order_uuid}"
|
||
provision_username = self._build_provision_username(
|
||
config=config,
|
||
order_uuid=order_uuid,
|
||
telegram_id=telegram_id,
|
||
username=username,
|
||
first_name=first_name,
|
||
)
|
||
|
||
async with self._session_factory() as session:
|
||
user = await self._upsert_telegram_user(
|
||
session,
|
||
telegram_id=telegram_id,
|
||
username=username,
|
||
first_name=first_name,
|
||
last_name=last_name,
|
||
language_code=language_code,
|
||
)
|
||
session.add(
|
||
PaymentOrder(
|
||
order_uuid=order_uuid,
|
||
telegram_user_id=user.id,
|
||
telegram_id=telegram_id,
|
||
plan_code=plan.code,
|
||
plan_title=plan.title,
|
||
plan_duration_days=plan.duration_days,
|
||
traffic_limit_bytes=plan.traffic_limit_bytes,
|
||
traffic_limit_strategy=plan.traffic_limit_strategy,
|
||
amount_stars=plan.amount_rub,
|
||
currency="RUB",
|
||
status=PENDING_PAYMENT_STATUS,
|
||
invoice_payload=invoice_payload,
|
||
provision_username=provision_username,
|
||
error_message=plan.discount_code or None,
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
return CreatedPaymentOrder(
|
||
order_uuid=order_uuid,
|
||
provision_username=provision_username,
|
||
plan=plan,
|
||
transfer_text=config.payment_transfer_text.strip(),
|
||
extends_existing_access=bool(existing_accesses),
|
||
)
|
||
|
||
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder).where(
|
||
PaymentOrder.order_uuid == order_uuid,
|
||
PaymentOrder.telegram_id == telegram_id,
|
||
)
|
||
)
|
||
if order is None:
|
||
raise ValueError("Заказ не найден.")
|
||
|
||
return StoredPaymentOrder(
|
||
order_uuid=order.order_uuid,
|
||
telegram_id=order.telegram_id,
|
||
plan_title=order.plan_title,
|
||
plan_duration_days=order.plan_duration_days,
|
||
amount_rub=order.amount_stars,
|
||
provision_username=order.provision_username,
|
||
status=order.status,
|
||
)
|
||
|
||
async def mark_order_under_review(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder)
|
||
.where(
|
||
PaymentOrder.order_uuid == order_uuid,
|
||
PaymentOrder.telegram_id == telegram_id,
|
||
)
|
||
.with_for_update()
|
||
)
|
||
if order is None:
|
||
raise ValueError("Заказ не найден.")
|
||
if order.status == REVIEW_PAYMENT_STATUS:
|
||
raise ValueError("Чек уже отправлен на проверку.")
|
||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||
raise ValueError("Заказ уже обрабатывается модератором.")
|
||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||
raise ValueError("Этот заказ уже подтверждён.")
|
||
if order.status == REJECTED_PAYMENT_STATUS:
|
||
raise ValueError("Этот заказ уже отклонён. Создайте новый платёж.")
|
||
if order.status != PENDING_PAYMENT_STATUS:
|
||
raise ValueError("Некорректный статус заказа для отправки чека.")
|
||
|
||
order.status = REVIEW_PAYMENT_STATUS
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
return StoredPaymentOrder(
|
||
order_uuid=order.order_uuid,
|
||
telegram_id=order.telegram_id,
|
||
plan_title=order.plan_title,
|
||
plan_duration_days=order.plan_duration_days,
|
||
amount_rub=order.amount_stars,
|
||
provision_username=order.provision_username,
|
||
status=order.status,
|
||
)
|
||
|
||
async def revert_order_to_pending(self, *, order_uuid: str, telegram_id: int) -> None:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder)
|
||
.where(
|
||
PaymentOrder.order_uuid == order_uuid,
|
||
PaymentOrder.telegram_id == telegram_id,
|
||
)
|
||
.with_for_update()
|
||
)
|
||
if order is None or order.status != REVIEW_PAYMENT_STATUS:
|
||
return
|
||
|
||
order.status = PENDING_PAYMENT_STATUS
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
||
order = await self._claim_order_for_approval(order_uuid)
|
||
is_renewal = False
|
||
|
||
try:
|
||
config = await self._get_config()
|
||
plan = self._get_plan_by_code(
|
||
order.plan_code,
|
||
applied_discount=AppliedDiscount(),
|
||
config=config,
|
||
)
|
||
remote_user: RemnawaveUser | None = None
|
||
if order.remnawave_user_uuid:
|
||
try:
|
||
remote_user = await self._remnawave_client.get_user_by_uuid(
|
||
order.remnawave_user_uuid
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
if exc.status_code != 404:
|
||
raise
|
||
remote_user = None
|
||
|
||
if remote_user is None:
|
||
try:
|
||
remote_user = await self._remnawave_client.get_user_by_username(
|
||
order.provision_username
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
if exc.status_code != 404:
|
||
raise
|
||
|
||
if remote_user is None:
|
||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||
order.telegram_id
|
||
)
|
||
remote_user = self._select_referral_bonus_target(remote_users)
|
||
|
||
if remote_user is None:
|
||
remote_user = await self._create_remnawave_user(
|
||
order,
|
||
plan=plan,
|
||
config=config,
|
||
)
|
||
else:
|
||
is_renewal = True
|
||
remote_user = await self._extend_remnawave_user(
|
||
order,
|
||
remote_user,
|
||
plan=plan,
|
||
config=config,
|
||
)
|
||
except ValueError as exc:
|
||
await self._restore_review_after_failed_approval(order.order_uuid, str(exc))
|
||
raise
|
||
except RemnawaveApiError as exc:
|
||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||
raise
|
||
|
||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
||
|
||
# Burn the applied discount (promo or referral) so it cannot be reused
|
||
try:
|
||
await self._sync_service.consume_discount_for_user(order.telegram_id)
|
||
except Exception:
|
||
logger.warning(
|
||
"Could not consume discount for telegram_id=%s after order=%s",
|
||
order.telegram_id,
|
||
order.order_uuid,
|
||
exc_info=True,
|
||
)
|
||
|
||
own_bonus = None
|
||
try:
|
||
own_bonus = await self.apply_pending_referral_bonuses(
|
||
telegram_id=order.telegram_id,
|
||
refresh_cache=False,
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
logger.warning(
|
||
"Could not apply pending referral bonus for telegram_id=%s: %s",
|
||
order.telegram_id,
|
||
exc.message,
|
||
)
|
||
|
||
try:
|
||
await self._sync_service.refresh_cached_users_for_telegram(
|
||
telegram_id=order.telegram_id,
|
||
)
|
||
except RemnawaveApiError:
|
||
# Access is already issued at this point; cache can be refreshed later.
|
||
pass
|
||
|
||
expire_at = remote_user.expire_at
|
||
if own_bonus is not None and own_bonus.applied_to_user_uuid == str(remote_user.uuid):
|
||
expire_at = own_bonus.expire_at
|
||
|
||
referral_bonus = await self._grant_referral_bonus(order, config=config)
|
||
|
||
return IssuedAccess(
|
||
telegram_id=order.telegram_id,
|
||
username=remote_user.username,
|
||
subscription_url=remote_user.subscription_url,
|
||
expire_at=expire_at,
|
||
short_uuid=remote_user.short_uuid,
|
||
remnawave_user_uuid=str(remote_user.uuid),
|
||
traffic_limit_bytes=remote_user.traffic_limit_bytes,
|
||
referral_bonus=referral_bonus,
|
||
is_renewal=is_renewal,
|
||
)
|
||
|
||
async def reject_order(self, *, order_uuid: str, reason: str | None = None) -> StoredPaymentOrder:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder)
|
||
.where(PaymentOrder.order_uuid == order_uuid)
|
||
.with_for_update()
|
||
)
|
||
if order is None:
|
||
raise ValueError("Заказ не найден.")
|
||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||
raise ValueError("Подтверждённый заказ нельзя отклонить.")
|
||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||
if order.status == PENDING_PAYMENT_STATUS:
|
||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||
if order.status == REJECTED_PAYMENT_STATUS:
|
||
raise ValueError("Заказ уже отклонён.")
|
||
|
||
order.status = REJECTED_PAYMENT_STATUS
|
||
order.error_message = reason or "Платёж отклонён"
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
return StoredPaymentOrder(
|
||
order_uuid=order.order_uuid,
|
||
telegram_id=order.telegram_id,
|
||
plan_title=order.plan_title,
|
||
plan_duration_days=order.plan_duration_days,
|
||
amount_rub=order.amount_stars,
|
||
provision_username=order.provision_username,
|
||
status=order.status,
|
||
)
|
||
|
||
async def get_referral_code_for_order(self, *, order_uuid: str) -> str:
|
||
order = await self._get_order(order_uuid)
|
||
return order.error_message or ""
|
||
|
||
def build_transfer_text(self, *, order: CreatedPaymentOrder) -> str:
|
||
lines = [
|
||
"<b>Оплата тарифа</b>",
|
||
"",
|
||
f"<b>{html.escape(order.plan.title)}</b>",
|
||
html.escape(order.plan.description),
|
||
"",
|
||
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
|
||
]
|
||
if order.plan.discount_code:
|
||
discount_label = (
|
||
"Промокод"
|
||
if order.plan.discount_source == "promo"
|
||
else "Реферальный код"
|
||
)
|
||
lines.append(
|
||
f"{discount_label}: <code>{order.plan.discount_code}</code> "
|
||
f"(<b>-{order.plan.discount_percent}%</b>)"
|
||
)
|
||
if order.extends_existing_access:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
"У вас уже есть доступ. После подтверждения оплаты бот автоматически "
|
||
"продлит текущую подписку на выбранный срок."
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<b>Реквизиты</b>",
|
||
(
|
||
"<blockquote>"
|
||
f"{html.escape(order.transfer_text or 'Укажите реквизиты в PAYMENT_TRANSFER_TEXT.')}"
|
||
"</blockquote>"
|
||
),
|
||
"",
|
||
"После перевода нажмите кнопку ниже и отправьте чек следующим сообщением.",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def _get_plan_by_code(
|
||
self,
|
||
plan_code: str,
|
||
*,
|
||
applied_discount: AppliedDiscount,
|
||
config: BotConfigSnapshot,
|
||
) -> PaymentPlan:
|
||
for plan in config.payment_plans:
|
||
if plan.code == plan_code:
|
||
return self._build_plan(
|
||
plan,
|
||
applied_discount=applied_discount,
|
||
config=config,
|
||
)
|
||
|
||
raise ValueError("Тариф не найден.")
|
||
|
||
def _build_plan(
|
||
self,
|
||
plan_settings: PaymentPlanSettings,
|
||
*,
|
||
applied_discount: AppliedDiscount,
|
||
config: BotConfigSnapshot,
|
||
) -> PaymentPlan:
|
||
resolved_discount = (
|
||
applied_discount
|
||
if applied_discount.applies_to_plan(
|
||
plan_settings.code,
|
||
days=plan_settings.days,
|
||
)
|
||
else AppliedDiscount()
|
||
)
|
||
discount_percent = max(resolved_discount.discount_percent, 0)
|
||
discounted_amount = self._apply_discount(
|
||
amount_rub=plan_settings.amount_rub,
|
||
discount_percent=discount_percent,
|
||
)
|
||
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
||
return PaymentPlan(
|
||
code=plan_settings.code,
|
||
label=plan_settings.title or f"{plan_settings.days} дней",
|
||
title=(
|
||
f"{config.bot_brand_name} · {plan_settings.title}"
|
||
if plan_settings.title
|
||
else f"{config.bot_brand_name} на {plan_settings.days} дней"
|
||
),
|
||
description=plan_settings.description or f"Доступ к VPN на {plan_settings.days} дней",
|
||
duration_days=plan_settings.days,
|
||
amount_rub=discounted_amount,
|
||
discount_percent=discount_percent,
|
||
original_amount_rub=plan_settings.amount_rub,
|
||
applied_referral_code=(
|
||
resolved_discount.code if resolved_discount.source == "referral" else ""
|
||
),
|
||
discount_code=resolved_discount.code,
|
||
discount_source=resolved_discount.source,
|
||
discount_scope_plan_code=resolved_discount.plan_code,
|
||
traffic_limit_bytes=traffic_limit_bytes,
|
||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
||
internal_squad_uuids=self._resolve_plan_internal_squad_uuids(
|
||
plan_settings,
|
||
config=config,
|
||
),
|
||
external_squad_uuid=config.payment_external_squad_uuid_normalized,
|
||
)
|
||
|
||
@staticmethod
|
||
def _resolve_plan_internal_squad_uuids(
|
||
plan_settings: PaymentPlanSettings,
|
||
*,
|
||
config: BotConfigSnapshot,
|
||
) -> list[str]:
|
||
if not plan_settings.squad_groups:
|
||
return config.payment_internal_squad_uuids
|
||
|
||
values: list[str] = []
|
||
for group in plan_settings.squad_groups:
|
||
if group == "vpn":
|
||
group_values = config.payment_vpn_squad_uuids
|
||
elif group == "white":
|
||
group_values = config.payment_white_squad_uuids
|
||
else:
|
||
group_values = []
|
||
|
||
for squad_uuid in group_values:
|
||
if squad_uuid not in values:
|
||
values.append(squad_uuid)
|
||
|
||
return values
|
||
|
||
@staticmethod
|
||
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
||
if discount_percent <= 0:
|
||
return amount_rub
|
||
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
|
||
|
||
async def get_active_discount_for_user(self, *, telegram_id: int) -> AppliedDiscount:
|
||
config = await self._get_config()
|
||
return await self._get_applied_discount(telegram_id, config=config)
|
||
|
||
async def _get_applied_discount(
|
||
self,
|
||
telegram_id: int,
|
||
*,
|
||
config: BotConfigSnapshot | None = None,
|
||
) -> AppliedDiscount:
|
||
resolved_config = config or await self._get_config()
|
||
promo_getter = getattr(self._sync_service, "get_active_promo_for_user", None)
|
||
if callable(promo_getter):
|
||
promo = await promo_getter(telegram_id)
|
||
if promo is not None:
|
||
return AppliedDiscount(
|
||
code=str(promo.code).strip().upper(),
|
||
source="promo",
|
||
discount_percent=max(int(promo.discount_percent), 0),
|
||
plan_code=promo.plan_code,
|
||
expires_at=promo.expires_at,
|
||
)
|
||
|
||
if not resolved_config.referral_enabled:
|
||
return AppliedDiscount()
|
||
|
||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
||
referral_code = summary.applied_referral_code.strip().upper()
|
||
if not referral_code:
|
||
return AppliedDiscount()
|
||
|
||
# Check if referral discount was already consumed by a previous payment
|
||
discount_used_checker = getattr(
|
||
self._sync_service, "is_referral_discount_used", None
|
||
)
|
||
if callable(discount_used_checker) and await discount_used_checker(telegram_id):
|
||
return AppliedDiscount()
|
||
|
||
return AppliedDiscount(
|
||
code=referral_code,
|
||
source="referral",
|
||
discount_percent=max(resolved_config.referral_discount_percent, 0),
|
||
)
|
||
|
||
async def apply_pending_referral_bonuses(
|
||
self,
|
||
*,
|
||
telegram_id: int,
|
||
refresh_cache: bool = True,
|
||
) -> AppliedReferralBonus | None:
|
||
config = await self._get_config()
|
||
if not config.referral_enabled:
|
||
return None
|
||
|
||
claimed_bonuses = await self._claim_pending_referral_bonuses(telegram_id)
|
||
if not claimed_bonuses:
|
||
return None
|
||
|
||
total_bonus_days = sum(max(bonus.bonus_days, 0) for bonus in claimed_bonuses)
|
||
if total_bonus_days <= 0:
|
||
await self._restore_claimed_referral_bonuses(
|
||
[bonus.id for bonus in claimed_bonuses],
|
||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||
)
|
||
return None
|
||
|
||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||
target_user = self._select_referral_bonus_target(remote_users)
|
||
if target_user is None:
|
||
await self._restore_claimed_referral_bonuses(
|
||
[bonus.id for bonus in claimed_bonuses],
|
||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||
)
|
||
return None
|
||
|
||
current_expire_at = target_user.expire_at
|
||
if current_expire_at.tzinfo is None:
|
||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||
next_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + timedelta(
|
||
days=total_bonus_days
|
||
)
|
||
|
||
try:
|
||
updated_user = await self._remnawave_client.update_user(
|
||
{
|
||
"uuid": str(target_user.uuid),
|
||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||
"description": (
|
||
f"Referral bonus applied by Telegram bot "
|
||
f"({total_bonus_days} days)"
|
||
),
|
||
}
|
||
)
|
||
except RemnawaveApiError:
|
||
await self._restore_claimed_referral_bonuses(
|
||
[bonus.id for bonus in claimed_bonuses],
|
||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||
)
|
||
raise
|
||
|
||
await self._mark_referral_bonuses_applied(
|
||
[bonus.id for bonus in claimed_bonuses],
|
||
applied_to_user_uuid=str(updated_user.uuid),
|
||
)
|
||
|
||
if refresh_cache:
|
||
try:
|
||
await self._sync_service.refresh_cached_users_for_telegram(
|
||
telegram_id=telegram_id,
|
||
)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
return AppliedReferralBonus(
|
||
telegram_id=telegram_id,
|
||
total_bonus_days=total_bonus_days,
|
||
expire_at=updated_user.expire_at,
|
||
applied_to_user_uuid=str(updated_user.uuid),
|
||
)
|
||
|
||
async def get_admin_stats(self) -> PaymentAdminStats:
|
||
async with self._session_factory() as session:
|
||
pending_orders = await session.scalar(
|
||
select(func.count(PaymentOrder.id)).where(
|
||
PaymentOrder.status == PENDING_PAYMENT_STATUS
|
||
)
|
||
)
|
||
review_orders = await session.scalar(
|
||
select(func.count(PaymentOrder.id)).where(
|
||
PaymentOrder.status == REVIEW_PAYMENT_STATUS
|
||
)
|
||
)
|
||
pending_referral_bonuses = await session.scalar(
|
||
select(func.count(ReferralBonus.id)).where(
|
||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||
)
|
||
)
|
||
|
||
return PaymentAdminStats(
|
||
pending_orders=int(pending_orders or 0),
|
||
review_orders=int(review_orders or 0),
|
||
pending_referral_bonuses=int(pending_referral_bonuses or 0),
|
||
)
|
||
|
||
async def _get_order(self, order_uuid: str) -> PaymentOrder:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||
)
|
||
if order is None:
|
||
raise ValueError("Заказ не найден.")
|
||
return order
|
||
|
||
async def _claim_order_for_approval(self, order_uuid: str) -> PaymentOrder:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder)
|
||
.where(PaymentOrder.order_uuid == order_uuid)
|
||
.with_for_update()
|
||
)
|
||
if order is None:
|
||
raise ValueError("Заказ не найден.")
|
||
if order.status == PENDING_PAYMENT_STATUS:
|
||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||
if order.status == REJECTED_PAYMENT_STATUS:
|
||
raise ValueError("Заказ уже отклонён.")
|
||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||
raise ValueError("Этот заказ уже подтверждён.")
|
||
if order.status != REVIEW_PAYMENT_STATUS:
|
||
raise ValueError("Некорректный статус заказа для подтверждения.")
|
||
|
||
order.status = PROCESSING_PAYMENT_STATUS
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
return order
|
||
|
||
async def _create_remnawave_user(
|
||
self,
|
||
order: PaymentOrder,
|
||
*,
|
||
plan: PaymentPlan,
|
||
config: BotConfigSnapshot,
|
||
) -> RemnawaveUser:
|
||
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
||
body: dict[str, object] = {
|
||
"username": order.provision_username,
|
||
"expireAt": expire_at.isoformat().replace("+00:00", "Z"),
|
||
"trafficLimitBytes": order.traffic_limit_bytes,
|
||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||
"telegramId": order.telegram_id,
|
||
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
|
||
"activeInternalSquads": plan.internal_squad_uuids or [],
|
||
}
|
||
if config.payment_user_tag_normalized:
|
||
body["tag"] = config.payment_user_tag_normalized
|
||
if config.payment_external_squad_uuid_normalized:
|
||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||
return await self._remnawave_client.create_user(body)
|
||
|
||
async def _extend_remnawave_user(
|
||
self,
|
||
order: PaymentOrder,
|
||
remote_user: RemnawaveUser,
|
||
*,
|
||
plan: PaymentPlan,
|
||
config: BotConfigSnapshot,
|
||
) -> RemnawaveUser:
|
||
current_expire_at = remote_user.expire_at
|
||
if current_expire_at.tzinfo is None:
|
||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||
|
||
base_expire_at = max(current_expire_at, datetime.now(timezone.utc))
|
||
next_expire_at = base_expire_at + timedelta(days=order.plan_duration_days)
|
||
|
||
traffic_limit_bytes = order.traffic_limit_bytes
|
||
if traffic_limit_bytes <= 0:
|
||
traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||
|
||
body: dict[str, object] = {
|
||
"uuid": str(remote_user.uuid),
|
||
"username": remote_user.username,
|
||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||
"trafficLimitBytes": traffic_limit_bytes,
|
||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||
"telegramId": order.telegram_id,
|
||
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
|
||
"activeInternalSquads": plan.internal_squad_uuids or [],
|
||
}
|
||
if config.payment_user_tag_normalized:
|
||
body["tag"] = config.payment_user_tag_normalized
|
||
if config.payment_external_squad_uuid_normalized:
|
||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||
return await self._remnawave_client.update_user(body)
|
||
|
||
async def _save_fulfilled_order(self, order_uuid: str, remote_user: RemnawaveUser) -> None:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||
)
|
||
if order is None:
|
||
return
|
||
order.status = FULFILLED_PAYMENT_STATUS
|
||
order.remnawave_user_uuid = str(remote_user.uuid)
|
||
order.subscription_url = remote_user.subscription_url
|
||
order.error_message = None
|
||
order.paid_at = utcnow()
|
||
order.fulfilled_at = utcnow()
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
async def _save_order_error(self, order_uuid: str, error_message: str) -> None:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||
)
|
||
if order is None:
|
||
return
|
||
order.error_message = error_message
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
async def _restore_review_after_failed_approval(
|
||
self,
|
||
order_uuid: str,
|
||
error_message: str,
|
||
) -> None:
|
||
async with self._session_factory() as session:
|
||
order = await session.scalar(
|
||
select(PaymentOrder)
|
||
.where(PaymentOrder.order_uuid == order_uuid)
|
||
.with_for_update()
|
||
)
|
||
if order is None:
|
||
return
|
||
|
||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||
order.status = REVIEW_PAYMENT_STATUS
|
||
order.error_message = error_message
|
||
order.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
async def _grant_referral_bonus(
|
||
self,
|
||
order: PaymentOrder,
|
||
*,
|
||
config: BotConfigSnapshot,
|
||
) -> ReferralBonusGrant | None:
|
||
if not config.referral_enabled:
|
||
return None
|
||
|
||
bonus_days = max(config.referral_bonus_days, 0)
|
||
if bonus_days <= 0:
|
||
return None
|
||
|
||
inviter_telegram_id = await self._get_inviter_telegram_id(order.telegram_id)
|
||
if inviter_telegram_id is None or inviter_telegram_id == order.telegram_id:
|
||
return None
|
||
|
||
await self._create_referral_bonus(
|
||
inviter_telegram_id=inviter_telegram_id,
|
||
invited_telegram_id=order.telegram_id,
|
||
source_order_uuid=order.order_uuid,
|
||
bonus_days=bonus_days,
|
||
)
|
||
|
||
try:
|
||
applied_bonus = await self.apply_pending_referral_bonuses(
|
||
telegram_id=inviter_telegram_id,
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
logger.warning(
|
||
"Could not apply referral bonus for inviter=%s after order=%s: %s",
|
||
inviter_telegram_id,
|
||
order.order_uuid,
|
||
exc.message,
|
||
)
|
||
applied_bonus = None
|
||
|
||
if applied_bonus is None:
|
||
return ReferralBonusGrant(
|
||
inviter_telegram_id=inviter_telegram_id,
|
||
bonus_days=bonus_days,
|
||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||
)
|
||
|
||
return ReferralBonusGrant(
|
||
inviter_telegram_id=inviter_telegram_id,
|
||
bonus_days=bonus_days,
|
||
status=REFERRAL_BONUS_APPLIED_STATUS,
|
||
total_applied_days=applied_bonus.total_bonus_days,
|
||
expire_at=applied_bonus.expire_at,
|
||
applied_to_user_uuid=applied_bonus.applied_to_user_uuid,
|
||
)
|
||
|
||
async def _get_inviter_telegram_id(self, invited_telegram_id: int) -> int | None:
|
||
async with self._session_factory() as session:
|
||
return await session.scalar(
|
||
select(ReferralInvite.inviter_telegram_id).where(
|
||
ReferralInvite.invited_telegram_id == invited_telegram_id
|
||
)
|
||
)
|
||
|
||
async def _create_referral_bonus(
|
||
self,
|
||
*,
|
||
inviter_telegram_id: int,
|
||
invited_telegram_id: int,
|
||
source_order_uuid: str,
|
||
bonus_days: int,
|
||
) -> None:
|
||
async with self._session_factory() as session:
|
||
existing = await session.scalar(
|
||
select(ReferralBonus).where(
|
||
ReferralBonus.source_order_uuid == source_order_uuid
|
||
)
|
||
)
|
||
if existing is not None:
|
||
return
|
||
|
||
session.add(
|
||
ReferralBonus(
|
||
inviter_telegram_id=inviter_telegram_id,
|
||
invited_telegram_id=invited_telegram_id,
|
||
source_order_uuid=source_order_uuid,
|
||
bonus_days=bonus_days,
|
||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
async def _claim_pending_referral_bonuses(self, telegram_id: int) -> list[ReferralBonus]:
|
||
async with self._session_factory() as session:
|
||
bonuses = list(
|
||
(
|
||
await session.scalars(
|
||
select(ReferralBonus)
|
||
.where(
|
||
ReferralBonus.inviter_telegram_id == telegram_id,
|
||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS,
|
||
)
|
||
.order_by(ReferralBonus.created_at.asc(), ReferralBonus.id.asc())
|
||
.with_for_update()
|
||
)
|
||
).all()
|
||
)
|
||
if not bonuses:
|
||
return []
|
||
|
||
for bonus in bonuses:
|
||
bonus.status = REFERRAL_BONUS_PROCESSING_STATUS
|
||
bonus.updated_at = utcnow()
|
||
await session.commit()
|
||
return bonuses
|
||
|
||
async def _restore_claimed_referral_bonuses(
|
||
self,
|
||
bonus_ids: list[int],
|
||
*,
|
||
status: str,
|
||
) -> None:
|
||
if not bonus_ids:
|
||
return
|
||
|
||
async with self._session_factory() as session:
|
||
bonuses = list(
|
||
(
|
||
await session.scalars(
|
||
select(ReferralBonus)
|
||
.where(ReferralBonus.id.in_(bonus_ids))
|
||
.with_for_update()
|
||
)
|
||
).all()
|
||
)
|
||
for bonus in bonuses:
|
||
bonus.status = status
|
||
bonus.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
async def _mark_referral_bonuses_applied(
|
||
self,
|
||
bonus_ids: list[int],
|
||
*,
|
||
applied_to_user_uuid: str,
|
||
) -> None:
|
||
if not bonus_ids:
|
||
return
|
||
|
||
async with self._session_factory() as session:
|
||
bonuses = list(
|
||
(
|
||
await session.scalars(
|
||
select(ReferralBonus)
|
||
.where(ReferralBonus.id.in_(bonus_ids))
|
||
.with_for_update()
|
||
)
|
||
).all()
|
||
)
|
||
for bonus in bonuses:
|
||
bonus.status = REFERRAL_BONUS_APPLIED_STATUS
|
||
bonus.applied_to_user_uuid = applied_to_user_uuid
|
||
bonus.applied_at = utcnow()
|
||
bonus.updated_at = utcnow()
|
||
await session.commit()
|
||
|
||
@staticmethod
|
||
def _select_referral_bonus_target(
|
||
remote_users: list[RemnawaveUser],
|
||
) -> RemnawaveUser | None:
|
||
if not remote_users:
|
||
return None
|
||
|
||
def _sort_key(user: RemnawaveUser) -> tuple[int, datetime, str]:
|
||
expire_at = user.expire_at
|
||
if expire_at.tzinfo is None:
|
||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||
return (0 if user.status.upper() == "ACTIVE" else 1, expire_at, user.username)
|
||
|
||
return sorted(remote_users, key=_sort_key)[0]
|
||
|
||
async def _upsert_telegram_user(
|
||
self,
|
||
session: AsyncSession,
|
||
*,
|
||
telegram_id: int,
|
||
username: str | None,
|
||
first_name: str | None,
|
||
last_name: str | None,
|
||
language_code: str | None,
|
||
) -> TelegramUser:
|
||
record = await session.scalar(
|
||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||
)
|
||
|
||
if record is None:
|
||
record = TelegramUser(telegram_id=telegram_id)
|
||
session.add(record)
|
||
|
||
record.username = username
|
||
record.first_name = first_name
|
||
record.last_name = last_name
|
||
record.language_code = language_code
|
||
record.is_admin = self._settings.is_admin(telegram_id)
|
||
record.last_seen_at = utcnow()
|
||
record.updated_at = utcnow()
|
||
|
||
await session.flush()
|
||
return record
|
||
|
||
def _build_provision_username(
|
||
self,
|
||
*,
|
||
config: BotConfigSnapshot,
|
||
order_uuid: str,
|
||
telegram_id: int,
|
||
username: str | None,
|
||
first_name: str | None,
|
||
) -> str:
|
||
base_name = username or first_name or "user"
|
||
normalized_name = "".join(
|
||
ch for ch in base_name if ch.isalnum() or ch in "_-"
|
||
).strip("-_")
|
||
if not normalized_name:
|
||
normalized_name = "user"
|
||
|
||
prefix = config.payment_username_prefix_normalized or "Oreol"
|
||
unique_suffix = order_uuid.replace("-", "")[:6]
|
||
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
|
||
return provision_username[:36]
|