- RioPay payment system integration
This commit is contained in:
@@ -522,6 +522,21 @@ class Settings(BaseSettings):
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
# RioPay (api.riopay.online)
|
||||
RIOPAY_ENABLED: bool = False
|
||||
RIOPAY_API_TOKEN: str | None = None # x-api-token header
|
||||
RIOPAY_JWT_TOKEN: str | None = None # Authorization: Bearer
|
||||
RIOPAY_MERCHANT_ID: str | None = None # x-merchant-id (опционально)
|
||||
RIOPAY_DISPLAY_NAME: str = 'RioPay'
|
||||
RIOPAY_CURRENCY: str = 'RUB'
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000₽
|
||||
RIOPAY_WEBHOOK_PATH: str = '/riopay-webhook'
|
||||
RIOPAY_WEBHOOK_HOST: str = '0.0.0.0'
|
||||
RIOPAY_WEBHOOK_PORT: int = 8090
|
||||
RIOPAY_SUCCESS_URL: str | None = None
|
||||
RIOPAY_FAIL_URL: str | None = None
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -1794,6 +1809,20 @@ class Settings(BaseSettings):
|
||||
def get_kassa_ai_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_display_name())
|
||||
|
||||
def is_riopay_enabled(self) -> bool:
|
||||
return (
|
||||
self.RIOPAY_ENABLED
|
||||
and self.RIOPAY_API_TOKEN is not None
|
||||
and self.RIOPAY_JWT_TOKEN is not None
|
||||
)
|
||||
|
||||
def get_riopay_display_name(self) -> str:
|
||||
name = (self.RIOPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'RioPay'
|
||||
|
||||
def get_riopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_riopay_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CRUD операции для платежей RioPay."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import RioPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_riopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
riopay_order_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: str | None = None,
|
||||
) -> RioPayPayment:
|
||||
"""Создает запись о платеже RioPay."""
|
||||
payment = RioPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
riopay_order_id=riopay_order_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж RioPay: order_id=, user_id', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_riopay_payment_by_order_id(db: AsyncSession, order_id: str) -> RioPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_riopay_order_id(db: AsyncSession, riopay_order_id: str) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID от RioPay (UUID)."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.riopay_order_id == riopay_order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_riopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RioPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
riopay_order_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> RioPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if riopay_order_id:
|
||||
payment.riopay_order_id = riopay_order_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа RioPay: order_id=, status=, is_paid',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_riopay_payments(db: AsyncSession, user_id: int) -> list[RioPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(RioPayPayment).where(
|
||||
RioPayPayment.user_id == user_id,
|
||||
RioPayPayment.status == 'pending',
|
||||
RioPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_riopay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[RioPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(RioPayPayment).where(
|
||||
RioPayPayment.status == 'pending',
|
||||
RioPayPayment.is_paid == False,
|
||||
RioPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -155,6 +155,7 @@ class PaymentMethod(Enum):
|
||||
CLOUDPAYMENTS = 'cloudpayments'
|
||||
FREEKASSA = 'freekassa'
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -714,6 +715,68 @@ class KassaAiPayment(Base):
|
||||
return f'<KassaAiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class RioPayPayment(Base):
|
||||
"""Платежи через RioPay (api.riopay.online)."""
|
||||
|
||||
__tablename__ = 'riopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
riopay_order_id = Column(String(64), unique=True, nullable=True, index=True) # UUID от RioPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired, canceled
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True) # CARD, SBP
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='riopay_payments')
|
||||
transaction = relationship('Transaction', backref='riopay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -74,6 +74,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'CLOUDPAYMENTS',
|
||||
'FREEKASSA',
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -263,6 +264,7 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
|
||||
'CloudPayments': settings.is_cloudpayments_enabled(),
|
||||
'Freekassa': settings.is_freekassa_enabled(),
|
||||
'Kassa AI': settings.is_kassa_ai_enabled(),
|
||||
'RioPay': settings.is_riopay_enabled(),
|
||||
'MulenPay': settings.is_mulenpay_enabled(),
|
||||
'PAL24': settings.is_pal24_enabled(),
|
||||
'Tribute': settings.TRIBUTE_ENABLED,
|
||||
@@ -1251,6 +1253,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'KASSA_AI':
|
||||
label = texts.t('PAYMENT_KASSA_AI', f'💳 {settings.get_kassa_ai_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'kassa_ai')])
|
||||
elif category_key == 'RIOPAY':
|
||||
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
@@ -2325,6 +2330,48 @@ async def test_payment_provider(
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
if method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer('❌ RioPay отключена', show_alert=True)
|
||||
return
|
||||
|
||||
amount_kopeks = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
payment_result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description='Тестовый платеж RioPay (админ)',
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get('payment_url'):
|
||||
await callback.answer('❌ Не удалось создать тестовый платеж RioPay', show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result['payment_url']
|
||||
display_name = settings.get_riopay_display_name()
|
||||
message_text = (
|
||||
f'🧪 <b>Тестовый платеж {display_name}</b>\n\n'
|
||||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||||
f'🆔 Order ID: {payment_result["order_id"]}'
|
||||
)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💳 Перейти к оплате',
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||||
await callback.answer(f'✅ Ссылка на платеж {display_name} отправлена', show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True)
|
||||
await _refresh_markup()
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return 'Telegram Stars'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.RIOPAY:
|
||||
return settings.get_riopay_display_name()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.get_freekassa_display_name()
|
||||
return method.value
|
||||
|
||||
@@ -138,6 +138,13 @@ async def route_payment_by_method(
|
||||
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_riopay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -873,6 +880,11 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai')
|
||||
dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|'))
|
||||
|
||||
from .riopay import process_riopay_quick_amount, start_riopay_topup
|
||||
|
||||
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
|
||||
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Handler for RioPay balance top-up."""
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _create_riopay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating RioPay payment and sending response.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_riopay_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'💳 Оплатить {amount}₽',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'RIOPAY_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info('RioPay payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await message.answer(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).format(min_amount=min_amount // 100),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма пополнения: {max_amount}₽',
|
||||
).format(max_amount=max_amount // 100),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_riopay_payment_and_respond(
|
||||
message_or_callback=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_riopay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start RioPay top-up process - ask for amount.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='riopay')
|
||||
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_riopay_display_name()
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'RIOPAY_ENTER_AMOUNT',
|
||||
'💳 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).format(
|
||||
name=display_name,
|
||||
min_amount=min_amount,
|
||||
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||
),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_custom_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process custom amount input for RioPay payment.
|
||||
"""
|
||||
data = await state.get_data()
|
||||
if data.get('payment_method') != 'riopay':
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_text = message.text.replace(',', '.').replace(' ', '').strip()
|
||||
amount_rubles = float(amount_text)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_INVALID_AMOUNT',
|
||||
'Введите корректную сумму числом.',
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
await process_riopay_payment_amount(
|
||||
message=message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for RioPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|riopay|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Invalid amount', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > max_amount:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
await _create_riopay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .riopay import RioPayPaymentMixin
|
||||
from .stars import TelegramStarsMixin
|
||||
from .tribute import TributePaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
@@ -29,6 +30,7 @@ __all__ = [
|
||||
'Pal24PaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
'PlategaPaymentMixin',
|
||||
'RioPayPaymentMixin',
|
||||
'TelegramStarsMixin',
|
||||
'TributePaymentMixin',
|
||||
'WataPaymentMixin',
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Mixin для интеграции с RioPay (api.riopay.online)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.riopay_service import riopay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов RioPay → internal
|
||||
RIOPAY_STATUS_MAP = {
|
||||
'COMPLETED': ('success', True),
|
||||
'CANCELED': ('canceled', False),
|
||||
'FAILED': ('failed', False),
|
||||
'EXPIRED': ('expired', False),
|
||||
'CREATED': ('pending', False),
|
||||
'PENDING': ('pending', False),
|
||||
}
|
||||
|
||||
|
||||
class RioPayPaymentMixin:
|
||||
"""Mixin для работы с платежами RioPay."""
|
||||
|
||||
async def create_riopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж RioPay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_riopay_enabled():
|
||||
logger.error('RioPay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.RIOPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RioPay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS=settings.RIOPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.RIOPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'RioPay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS=settings.RIOPAY_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
# Получаем telegram_id пользователя для order_id
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
user = await payment_module.get_user_by_id(db, user_id)
|
||||
tg_id = user.telegram_id if user else user_id
|
||||
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.RIOPAY_CURRENCY
|
||||
|
||||
# Срок действия платежа (1 час по умолчанию)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
try:
|
||||
# Используем API для создания заказа
|
||||
result = await riopay_service.create_order(
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
external_id=order_id,
|
||||
purpose=description,
|
||||
success_url=settings.RIOPAY_SUCCESS_URL,
|
||||
fail_url=settings.RIOPAY_FAIL_URL,
|
||||
)
|
||||
|
||||
payment_url = result.get('paymentLink')
|
||||
riopay_order_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('RioPay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info('RioPay API: создан заказ', order_id=order_id, riopay_order_id=riopay_order_id, payment_url=payment_url)
|
||||
|
||||
# Импортируем CRUD модуль
|
||||
riopay_crud = import_module('app.database.crud.riopay')
|
||||
|
||||
# Сохраняем в БД
|
||||
local_payment = await riopay_crud.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=result.get('paymentType'),
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.dumps(metadata, ensure_ascii=False),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'RioPay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'riopay_order_id': riopay_order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RioPay: ошибка создания платежа', e=e)
|
||||
return None
|
||||
|
||||
async def process_riopay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
raw_body: bytes,
|
||||
signature: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от RioPay.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook
|
||||
raw_body: Сырое тело для проверки подписи
|
||||
signature: Подпись из заголовка
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
# Проверка подписи (если есть)
|
||||
if signature:
|
||||
if not await riopay_service.verify_webhook_signature(raw_body, signature):
|
||||
logger.warning('RioPay webhook: неверная подпись')
|
||||
return False
|
||||
|
||||
# Извлекаем данные из payload
|
||||
riopay_order_id = payload.get('id')
|
||||
external_id = payload.get('externalId')
|
||||
riopay_status = payload.get('status')
|
||||
amount = payload.get('amount')
|
||||
|
||||
if not riopay_order_id or not riopay_status:
|
||||
logger.warning('RioPay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Импортируем CRUD модуль
|
||||
riopay_crud = import_module('app.database.crud.riopay')
|
||||
|
||||
# Ищем платеж по external_id (наш order_id) или riopay_order_id
|
||||
payment = None
|
||||
if external_id:
|
||||
payment = await riopay_crud.get_riopay_payment_by_order_id(db, external_id)
|
||||
if not payment and riopay_order_id:
|
||||
payment = await riopay_crud.get_riopay_payment_by_riopay_order_id(db, riopay_order_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'RioPay webhook: платеж не найден',
|
||||
external_id=external_id,
|
||||
riopay_order_id=riopay_order_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверка дублирования
|
||||
if payment.is_paid:
|
||||
logger.info('RioPay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = RIOPAY_STATUS_MAP.get(riopay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
# Обновляем статус платежа
|
||||
callback_payload = {
|
||||
'riopay_order_id': riopay_order_id,
|
||||
'external_id': external_id,
|
||||
'status': riopay_status,
|
||||
'amount': amount,
|
||||
'payment_type': payload.get('paymentType'),
|
||||
'raw_payload': payload,
|
||||
}
|
||||
|
||||
payment = await riopay_crud.update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=is_paid,
|
||||
riopay_order_id=riopay_order_id,
|
||||
payment_method=payload.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
# Финализируем платеж если оплачен
|
||||
if is_paid:
|
||||
return await self._finalize_riopay_payment(
|
||||
db, payment, riopay_order_id=riopay_order_id, trigger='webhook'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RioPay webhook: ошибка обработки', e=e)
|
||||
return False
|
||||
|
||||
async def _finalize_riopay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
riopay_order_id: str | None,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'RioPay платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
|
||||
)
|
||||
return True
|
||||
|
||||
# Получаем пользователя
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error(
|
||||
'Пользователь не найден для RioPay платежа (trigger=)',
|
||||
user_id=payment.user_id,
|
||||
order_id=payment.order_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return False
|
||||
|
||||
# Создаем транзакцию
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f'Пополнение через RioPay (#{riopay_order_id or payment.order_id})',
|
||||
payment_method=PaymentMethod.RIOPAY,
|
||||
external_id=str(riopay_order_id) if riopay_order_id else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
riopay_crud = import_module('app.database.crud.riopay')
|
||||
await riopay_crud.update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=payment.status,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
# Начисляем баланс
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Обработка реферального пополнения
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения RioPay', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(payment)
|
||||
|
||||
# Отправка уведомления админам
|
||||
if getattr(self, 'bot', None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления RioPay', error=error)
|
||||
|
||||
# Отправка уведомления пользователю (только Telegram-пользователям)
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
display_name = settings.get_riopay_display_name()
|
||||
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки уведомления пользователю RioPay', error=error)
|
||||
|
||||
# Автопокупка подписки и уведомление о корзине
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с сохраненной корзиной для пользователя', user_id=user.id, error=error, exc_info=True
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Обработан RioPay платеж для пользователя (trigger=)',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_riopay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Проверяет статус платежа через API.
|
||||
"""
|
||||
try:
|
||||
riopay_crud = import_module('app.database.crud.riopay')
|
||||
|
||||
payment = await riopay_crud.get_riopay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('RioPay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по riopay_order_id (UUID)
|
||||
if payment.riopay_order_id:
|
||||
try:
|
||||
order_data = await riopay_service.get_order(payment.riopay_order_id)
|
||||
riopay_status = order_data.get('status')
|
||||
|
||||
if riopay_status:
|
||||
status_info = RIOPAY_STATUS_MAP.get(riopay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
logger.info('RioPay payment confirmed via API', order_id=payment.order_id)
|
||||
|
||||
callback_payload = {
|
||||
'check_source': 'api',
|
||||
'riopay_order_data': order_data,
|
||||
}
|
||||
|
||||
payment = await riopay_crud.update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='success',
|
||||
is_paid=True,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
payment_method=order_data.get('paymentType'),
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
await self._finalize_riopay_payment(
|
||||
db,
|
||||
payment,
|
||||
riopay_order_id=payment.riopay_order_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await riopay_crud.update_riopay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking RioPay payment status via API', e=e)
|
||||
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': payment.status or 'pending',
|
||||
'is_paid': payment.is_paid,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('RioPay: ошибка проверки статуса', e=e)
|
||||
return None
|
||||
@@ -129,6 +129,13 @@ def _get_method_defaults() -> dict:
|
||||
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'riopay': {
|
||||
'default_display_name': settings.get_riopay_display_name(),
|
||||
'is_configured': settings.is_riopay_enabled(),
|
||||
'default_min': settings.RIOPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.RIOPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +176,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'freekassa_card',
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
'riopay',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.services.payment import (
|
||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
@@ -306,6 +307,7 @@ class PaymentService(
|
||||
CloudPaymentsPaymentMixin,
|
||||
FreekassaPaymentMixin,
|
||||
KassaAiPaymentMixin,
|
||||
RioPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Сервис для работы с API RioPay (api.riopay.online)."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
API_BASE_URL = 'https://api.riopay.online'
|
||||
|
||||
# Кэш для публичного ключа
|
||||
_cached_pubkey: str | None = None
|
||||
_pubkey_lock = asyncio.Lock()
|
||||
|
||||
|
||||
class RioPayService:
|
||||
"""Сервис для работы с API RioPay."""
|
||||
|
||||
def __init__(self):
|
||||
self._api_token: str | None = None
|
||||
self._jwt_token: str | None = None
|
||||
self._merchant_id: str | None = None
|
||||
|
||||
@property
|
||||
def api_token(self) -> str:
|
||||
if self._api_token is None:
|
||||
self._api_token = settings.RIOPAY_API_TOKEN
|
||||
return self._api_token or ''
|
||||
|
||||
@property
|
||||
def jwt_token(self) -> str:
|
||||
if self._jwt_token is None:
|
||||
self._jwt_token = settings.RIOPAY_JWT_TOKEN
|
||||
return self._jwt_token or ''
|
||||
|
||||
@property
|
||||
def merchant_id(self) -> str | None:
|
||||
if self._merchant_id is None:
|
||||
self._merchant_id = settings.RIOPAY_MERCHANT_ID
|
||||
return self._merchant_id
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""Формирует заголовки для API запросов."""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.jwt_token}',
|
||||
'x-api-token': self.api_token,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if self.merchant_id:
|
||||
headers['x-merchant-id'] = self.merchant_id
|
||||
return headers
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
*,
|
||||
amount: float,
|
||||
currency: str = 'RUB',
|
||||
external_id: str,
|
||||
purpose: str = 'Пополнение баланса',
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает заказ через API RioPay.
|
||||
POST /v1/orders
|
||||
|
||||
Returns:
|
||||
OrderData dict с полями id, status, paymentLink, amount, currency, etc.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'externalId': external_id,
|
||||
'purpose': purpose,
|
||||
}
|
||||
|
||||
if success_url:
|
||||
payload['successUrl'] = success_url
|
||||
if fail_url:
|
||||
payload['failUrl'] = fail_url
|
||||
|
||||
logger.info(
|
||||
'RioPay API create_order',
|
||||
external_id=external_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.post(
|
||||
f'{API_BASE_URL}/v1/orders',
|
||||
json=payload,
|
||||
headers=self._get_headers(),
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response,
|
||||
):
|
||||
text = await response.text()
|
||||
logger.info('RioPay API response', status_code=response.status, text=text)
|
||||
|
||||
if response.status == 201:
|
||||
data = await response.json(content_type=None)
|
||||
return data
|
||||
|
||||
# Ошибка
|
||||
try:
|
||||
error_data = await response.json(content_type=None)
|
||||
error_msg = error_data.get('message') or error_data.get('error') or text
|
||||
except Exception:
|
||||
error_msg = text
|
||||
|
||||
logger.error('RioPay create_order error', status_code=response.status, error_msg=error_msg)
|
||||
raise Exception(f'RioPay API error ({response.status}): {error_msg}')
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RioPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_order(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает заказ по UUID.
|
||||
GET /v1/orders/{id}
|
||||
"""
|
||||
logger.info('RioPay get_order', order_id=order_id)
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(
|
||||
f'{API_BASE_URL}/v1/orders/{order_id}',
|
||||
headers=self._get_headers(),
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as response,
|
||||
):
|
||||
text = await response.text()
|
||||
logger.info('RioPay get_order response', status_code=response.status, text=text)
|
||||
|
||||
if response.status == 200:
|
||||
return await response.json(content_type=None)
|
||||
|
||||
raise Exception(f'RioPay get_order error ({response.status}): {text}')
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RioPay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_public_key(self) -> str:
|
||||
"""
|
||||
Получает публичный ключ для проверки подписи вебхуков.
|
||||
GET /v1/orders/pubkey
|
||||
Результат кэшируется.
|
||||
"""
|
||||
global _cached_pubkey
|
||||
|
||||
if _cached_pubkey:
|
||||
return _cached_pubkey
|
||||
|
||||
async with _pubkey_lock:
|
||||
if _cached_pubkey:
|
||||
return _cached_pubkey
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.get(
|
||||
f'{API_BASE_URL}/v1/orders/pubkey',
|
||||
headers=self._get_headers(),
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as response,
|
||||
):
|
||||
if response.status == 200:
|
||||
pubkey = await response.text()
|
||||
_cached_pubkey = pubkey.strip()
|
||||
logger.info('RioPay: получен публичный ключ для верификации')
|
||||
return _cached_pubkey
|
||||
|
||||
text = await response.text()
|
||||
logger.error('RioPay pubkey error', status_code=response.status, text=text)
|
||||
raise Exception(f'RioPay pubkey error ({response.status}): {text}')
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception('RioPay pubkey connection error', error=e)
|
||||
raise
|
||||
|
||||
async def verify_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
|
||||
"""
|
||||
Проверяет подпись webhook через публичный ключ RSA/ECDSA.
|
||||
|
||||
Args:
|
||||
raw_body: Сырое тело запроса (bytes)
|
||||
signature: Подпись из заголовка (base64-encoded)
|
||||
|
||||
Returns:
|
||||
True если подпись валидна
|
||||
"""
|
||||
try:
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, padding, utils
|
||||
|
||||
pubkey_pem = await self.get_public_key()
|
||||
|
||||
public_key = serialization.load_pem_public_key(pubkey_pem.encode())
|
||||
signature_bytes = base64.b64decode(signature)
|
||||
|
||||
# Определяем тип ключа и проверяем подпись
|
||||
if hasattr(public_key, 'key_size'):
|
||||
# RSA ключ
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
else:
|
||||
# ECDSA ключ
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body,
|
||||
ec.ECDSA(hashes.SHA256()),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('RioPay webhook verify error', error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
riopay_service = RioPayService()
|
||||
@@ -86,6 +86,7 @@ class BotConfigurationService:
|
||||
'CLOUDPAYMENTS': '💳 CloudPayments',
|
||||
'FREEKASSA': '💳 Freekassa',
|
||||
'KASSA_AI': '💳 KassaAI',
|
||||
'RIOPAY': '💳 RioPay',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
@@ -146,6 +147,7 @@ class BotConfigurationService:
|
||||
'CLOUDPAYMENTS': 'CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.',
|
||||
'FREEKASSA': 'Freekassa: ID магазина, API ключ, секретные слова и вебхуки.',
|
||||
'KASSA_AI': 'KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.',
|
||||
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -340,6 +342,7 @@ class BotConfigurationService:
|
||||
'CLOUDPAYMENTS_': 'CLOUDPAYMENTS',
|
||||
'FREEKASSA_': 'FREEKASSA',
|
||||
'KASSA_AI_': 'KASSA_AI',
|
||||
'RIOPAY_': 'RIOPAY',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
|
||||
@@ -1108,6 +1108,62 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# RioPay webhook
|
||||
if settings.is_riopay_enabled():
|
||||
|
||||
@router.get(settings.RIOPAY_WEBHOOK_PATH)
|
||||
async def riopay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'riopay_webhook',
|
||||
'enabled': settings.is_riopay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.RIOPAY_WEBHOOK_PATH)
|
||||
async def riopay_webhook(request: Request) -> Response:
|
||||
# Получаем JSON тело
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('RioPay webhook: не удалось прочитать JSON', parse_error=parse_error)
|
||||
return Response('Error reading JSON', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Подпись из заголовка (X-Signature)
|
||||
signature = request.headers.get('X-Signature') or request.headers.get('x-signature')
|
||||
|
||||
# Обрабатываем webhook
|
||||
db_generator = get_db()
|
||||
try:
|
||||
db = await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return Response('DB Error', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
try:
|
||||
success = await payment_service.process_riopay_webhook(
|
||||
db,
|
||||
payload=payload,
|
||||
raw_body=raw_body,
|
||||
signature=signature,
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
logger.error('RioPay webhook processing failed', payload=payload)
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
logger.exception('RioPay webhook processing error', e=e)
|
||||
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
|
||||
@router.get('/health/payment-webhooks')
|
||||
@@ -1126,6 +1182,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'cloudpayments_enabled': settings.is_cloudpayments_enabled(),
|
||||
'freekassa_enabled': settings.is_freekassa_enabled(),
|
||||
'kassa_ai_enabled': settings.is_kassa_ai_enabled(),
|
||||
'riopay_enabled': settings.is_riopay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+1726
File diff suppressed because it is too large
Load Diff
+1744
File diff suppressed because it is too large
Load Diff
+1747
File diff suppressed because it is too large
Load Diff
+1613
File diff suppressed because it is too large
Load Diff
+1611
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
"""add riopay_payments table
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
Create Date: 2026-03-06
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0015'
|
||||
down_revision: Union[str, None] = '0014'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'riopay_payments',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column('riopay_order_id', sa.String(64), unique=True, nullable=True, index=True),
|
||||
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
|
||||
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false')),
|
||||
sa.Column('payment_url', sa.Text(), nullable=True),
|
||||
sa.Column('payment_method', sa.String(32), nullable=True),
|
||||
sa.Column('metadata_json', sa.JSON(), nullable=True),
|
||||
sa.Column('callback_payload', sa.JSON(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('riopay_payments')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add riopay_payments table
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
Create Date: 2026-03-06
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0016'
|
||||
down_revision: Union[str, None] = '0015'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
Reference in New Issue
Block a user