Files
RemnaWaveBOT/app/services/payment_service.py
2026-04-22 18:34:13 +03:00

984 lines
36 KiB
Python
Raw 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
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]