v2.0
This commit is contained in:
983
app/services/payment_service.py
Normal file
983
app/services/payment_service.py
Normal file
@@ -0,0 +1,983 @@
|
||||
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 PaymentPlan:
|
||||
code: str
|
||||
title: str
|
||||
description: str
|
||||
duration_days: int
|
||||
amount_rub: int
|
||||
discount_percent: int
|
||||
original_amount_rub: int
|
||||
applied_referral_code: str = ""
|
||||
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_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
return [
|
||||
self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
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_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
plan = self._get_plan_by_code(
|
||||
plan_code,
|
||||
applied_referral_code=applied_referral_code,
|
||||
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=applied_referral_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)
|
||||
config = await self._get_config()
|
||||
is_renewal = False
|
||||
|
||||
try:
|
||||
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, config=config)
|
||||
else:
|
||||
is_renewal = True
|
||||
remote_user = await self._extend_remnawave_user(
|
||||
order,
|
||||
remote_user,
|
||||
config=config,
|
||||
)
|
||||
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)
|
||||
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.applied_referral_code:
|
||||
lines.append(
|
||||
f"Реферальный код: <code>{order.plan.applied_referral_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_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
for plan in config.payment_plans:
|
||||
if plan.code == plan_code:
|
||||
return self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
|
||||
raise ValueError("Тариф не найден.")
|
||||
|
||||
def _build_plan(
|
||||
self,
|
||||
plan_settings: PaymentPlanSettings,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 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,
|
||||
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
|
||||
description=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=applied_referral_code,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
||||
internal_squad_uuids=config.payment_internal_squad_uuids,
|
||||
external_squad_uuid=config.payment_external_squad_uuid_normalized,
|
||||
)
|
||||
|
||||
@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_applied_referral_code(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
config: BotConfigSnapshot | None = None,
|
||||
) -> str:
|
||||
resolved_config = config or await self._get_config()
|
||||
if not resolved_config.referral_enabled:
|
||||
return ""
|
||||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
||||
return summary.applied_referral_code.strip().upper()
|
||||
|
||||
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,
|
||||
*,
|
||||
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": config.payment_internal_squad_uuids,
|
||||
}
|
||||
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,
|
||||
*,
|
||||
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": config.payment_internal_squad_uuids,
|
||||
}
|
||||
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]
|
||||
Reference in New Issue
Block a user