Files
RemnaWaveBOT/app/services/payment_service.py
2026-07-09 16:45:17 +03:00

1302 lines
48 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 html
import logging
import unicodedata
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__)
_CYRILLIC_TRANSLITERATION = str.maketrans(
{
"\u0410": "A",
"\u0411": "B",
"\u0412": "V",
"\u0413": "G",
"\u0414": "D",
"\u0415": "E",
"\u0401": "E",
"\u0416": "Zh",
"\u0417": "Z",
"\u0418": "I",
"\u0419": "Y",
"\u041a": "K",
"\u041b": "L",
"\u041c": "M",
"\u041d": "N",
"\u041e": "O",
"\u041f": "P",
"\u0420": "R",
"\u0421": "S",
"\u0422": "T",
"\u0423": "U",
"\u0424": "F",
"\u0425": "Kh",
"\u0426": "Ts",
"\u0427": "Ch",
"\u0428": "Sh",
"\u0429": "Sch",
"\u042a": "",
"\u042b": "Y",
"\u042c": "",
"\u042d": "E",
"\u042e": "Yu",
"\u042f": "Ya",
"\u0430": "a",
"\u0431": "b",
"\u0432": "v",
"\u0433": "g",
"\u0434": "d",
"\u0435": "e",
"\u0451": "e",
"\u0436": "zh",
"\u0437": "z",
"\u0438": "i",
"\u0439": "y",
"\u043a": "k",
"\u043b": "l",
"\u043c": "m",
"\u043d": "n",
"\u043e": "o",
"\u043f": "p",
"\u0440": "r",
"\u0441": "s",
"\u0442": "t",
"\u0443": "u",
"\u0444": "f",
"\u0445": "kh",
"\u0446": "ts",
"\u0447": "ch",
"\u0448": "sh",
"\u0449": "sch",
"\u044a": "",
"\u044b": "y",
"\u044c": "",
"\u044d": "e",
"\u044e": "yu",
"\u044f": "ya",
}
)
@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)
renewal_target_record = self._select_cached_renewal_target(existing_accesses)
renewal_target_uuid: str | None = None
renewal_target_username: str | None = None
if renewal_target_record is not None:
renewal_target_uuid = str(renewal_target_record.rw_uuid)
renewal_target_username = renewal_target_record.username
else:
try:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
telegram_id
)
except RemnawaveApiError:
remote_users = []
remote_user = self._select_referral_bonus_target(remote_users)
if remote_user is not None:
renewal_target_uuid = str(remote_user.uuid)
renewal_target_username = remote_user.username
order_uuid = str(uuid4())
invoice_payload = f"manual:{plan.code}:{order_uuid}"
provision_username = (
renewal_target_username
or 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,
remnawave_user_uuid=renewal_target_uuid,
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) or renewal_target_uuid is not None,
)
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:
try:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
remote_user = self._select_referral_bonus_target(remote_users)
except Exception:
logger.warning(
"Failed to look up user by telegram_id=%s, will try to create",
order.telegram_id,
exc_info=True,
)
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
except Exception as exc:
error_msg = f"Unexpected error: {exc.__class__.__name__}: {exc}"
logger.error(
"Unexpected error during order approval %s: %s",
order.order_uuid,
error_msg,
exc_info=True,
)
await self._restore_review_after_failed_approval(order.order_uuid, error_msg)
raise RemnawaveApiError(
status_code=0,
message=f"Ошибка подключения к панели. Попробуйте ещё раз. ({exc.__class__.__name__})",
)
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:
# If the order has been stuck in PROCESSING for more than 60s,
# it means the previous approval attempt crashed (e.g. ConnectError).
# Allow re-claiming it instead of blocking forever.
stale_threshold = utcnow() - timedelta(seconds=60)
if order.updated_at and order.updated_at > stale_threshold:
raise ValueError("Заказ уже обрабатывается другим модератором.")
logger.warning(
"Order %s was stuck in PROCESSING since %s, reclaiming",
order_uuid,
order.updated_at,
)
# Already in PROCESSING — just refresh the timestamp and proceed
order.updated_at = utcnow()
await session.commit()
return order
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
try:
return await self._remnawave_client.create_user(body)
except RemnawaveApiError as exc:
if exc.status_code != 400:
raise
# Creation failed (likely duplicate telegramId) — fall back to
# finding the existing user and extending their subscription.
logger.warning(
"POST /api/users returned 400 for order=%s telegram_id=%s: %s. "
"Falling back to extend existing user.",
order.order_uuid,
order.telegram_id,
exc.message,
)
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
existing_user = self._select_referral_bonus_target(remote_users)
if existing_user is None:
raise # re-raise original 400 if we truly can't find anyone
return await self._extend_remnawave_user(
order,
existing_user,
plan=plan,
config=config,
)
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]
@staticmethod
def _select_cached_renewal_target(cached_users: list[object]) -> object | None:
records = [
record
for access in cached_users
if (record := getattr(access, "record", None)) is not None
and getattr(record, "rw_uuid", None)
and getattr(record, "username", None)
]
if not records:
return None
def _sort_key(record: object) -> tuple[int, datetime, str]:
expire_at = getattr(record, "expire_at", None) or datetime.min
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
status = str(getattr(record, "status", "")).upper()
username = str(getattr(record, "username", ""))
return (0 if status == "ACTIVE" else 1, expire_at, username)
return sorted(records, 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 = self._normalize_provision_name(base_name)
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]
@staticmethod
def _normalize_provision_name(raw_name: str) -> str:
transliterated = raw_name.translate(_CYRILLIC_TRANSLITERATION)
decomposed = unicodedata.normalize("NFKD", transliterated)
ascii_name = decomposed.encode("ascii", "ignore").decode("ascii")
normalized_name = "".join(
ch for ch in ascii_name if ch.isalnum() or ch in "_-"
).strip("-_")
return normalized_name or "user"