- RioPay payment system integration

This commit is contained in:
firewookie
2026-03-06 11:44:22 +05:00
parent b31a893b13
commit 7ed91b13eb
21 changed files with 9934 additions and 1 deletions
+29
View File
@@ -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
+132
View File
@@ -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())
+63
View File
@@ -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'
+48 -1
View File
@@ -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()
+2
View File
@@ -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
+12
View File
@@ -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_'))
+359
View File
@@ -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,
)
+2
View File
@@ -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',
+468
View File
@@ -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',
]
+2
View File
@@ -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-ам."""
+238
View File
@@ -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()
+3
View File
@@ -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',
+57
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1744
View File
File diff suppressed because it is too large Load Diff
+1747
View File
File diff suppressed because it is too large Load Diff
+1613
View File
File diff suppressed because it is too large Load Diff
+1611
View File
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