diff --git a/app/database/crud/riopay.py b/app/database/crud/riopay.py
index 81b8b8ac..da1b0f8a 100644
--- a/app/database/crud/riopay.py
+++ b/app/database/crud/riopay.py
@@ -1,6 +1,5 @@
"""CRUD операции для платежей RioPay."""
-import json
from datetime import UTC, datetime
import structlog
@@ -25,7 +24,7 @@ async def create_riopay_payment(
payment_method: str | None = None,
riopay_order_id: str | None = None,
expires_at: datetime | None = None,
- metadata_json: str | None = None,
+ metadata_json: dict | None = None,
) -> RioPayPayment:
"""Создает запись о платеже RioPay."""
payment = RioPayPayment(
@@ -38,14 +37,14 @@ async def create_riopay_payment(
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,
+ metadata_json=metadata_json,
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)
+ logger.info('Создан платеж RioPay', order_id=order_id, user_id=user_id)
return payment
@@ -72,7 +71,7 @@ async def update_riopay_payment_status(
payment: RioPayPayment,
*,
status: str,
- is_paid: bool = False,
+ is_paid: bool | None = None,
riopay_order_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
@@ -80,11 +79,12 @@ async def update_riopay_payment_status(
) -> 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 is_paid is not None:
+ payment.is_paid = is_paid
+ 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:
@@ -97,10 +97,10 @@ async def update_riopay_payment_status(
await db.commit()
await db.refresh(payment)
logger.info(
- 'Обновлен статус платежа RioPay: order_id=, status=, is_paid',
+ 'Обновлен статус платежа RioPay',
order_id=payment.order_id,
status=status,
- is_paid=is_paid,
+ is_paid=payment.is_paid,
)
return payment
diff --git a/app/database/models.py b/app/database/models.py
index c71536aa..55df6670 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -721,7 +721,7 @@ class RioPayPayment(Base):
__tablename__ = 'riopay_payments'
id = Column(Integer, primary_key=True, index=True)
- user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
+ user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
diff --git a/app/handlers/balance/riopay.py b/app/handlers/balance/riopay.py
index 15f3a1dd..8869d180 100644
--- a/app/handlers/balance/riopay.py
+++ b/app/handlers/balance/riopay.py
@@ -18,6 +18,19 @@ from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
+def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
+ """Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе None."""
+ if not getattr(db_user, 'restriction_topup', False):
+ return None
+
+ keyboard = []
+ support_url = settings.get_support_contact_url()
+ if support_url:
+ keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
+ keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
+ return InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
async def _create_riopay_payment_and_respond(
message_or_callback,
db_user: User,
@@ -111,7 +124,7 @@ async def _create_riopay_payment_and_respond(
parse_mode='HTML',
)
- logger.info('RioPay payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
+ logger.info('RioPay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
@@ -127,19 +140,13 @@ async def process_riopay_payment_amount(
"""
texts = get_texts(db_user.language)
- # Проверка ограничения на пополнение
- if getattr(db_user, 'restriction_topup', False):
+ restriction_kb = _check_topup_restriction(db_user, texts)
+ if restriction_kb:
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'🚫 Пополнение ограничено\n\n{reason}',
parse_mode='HTML',
- reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
+ reply_markup=restriction_kb,
)
await state.clear()
return
@@ -191,19 +198,13 @@ async def start_riopay_topup(
"""
texts = get_texts(db_user.language)
- # Проверка ограничения на пополнение
- if getattr(db_user, 'restriction_topup', False):
+ restriction_kb = _check_topup_restriction(db_user, texts)
+ if restriction_kb:
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'🚫 Пополнение ограничено\n\n{reason}',
parse_mode='HTML',
- reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
+ reply_markup=restriction_kb,
)
return
@@ -261,7 +262,7 @@ async def process_riopay_custom_amount(
try:
amount_text = message.text.replace(',', '.').replace(' ', '').strip()
amount_rubles = float(amount_text)
- amount_kopeks = int(amount_rubles * 100)
+ amount_kopeks = round(amount_rubles * 100)
except (ValueError, TypeError):
await message.answer(
texts.t(
@@ -313,19 +314,13 @@ async def process_riopay_quick_amount(
await callback.answer('Invalid amount', show_alert=True)
return
- # Проверка ограничения на пополнение
- if getattr(db_user, 'restriction_topup', False):
+ restriction_kb = _check_topup_restriction(db_user, texts)
+ if restriction_kb:
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'🚫 Пополнение ограничено\n\n{reason}',
parse_mode='HTML',
- reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
+ reply_markup=restriction_kb,
)
return
diff --git a/app/services/payment/riopay.py b/app/services/payment/riopay.py
index 402b34ba..210fc0ff 100644
--- a/app/services/payment/riopay.py
+++ b/app/services/payment/riopay.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import json
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
@@ -125,7 +124,7 @@ class RioPayPaymentMixin:
riopay_order_id=riopay_order_id,
payment_method=result.get('paymentType'),
expires_at=expires_at,
- metadata_json=json.dumps(metadata, ensure_ascii=False),
+ metadata_json=metadata,
)
logger.info(
@@ -264,7 +263,7 @@ class RioPayPaymentMixin:
if payment.transaction_id:
logger.info(
- 'RioPay платеж уже привязан к транзакции (trigger=)', order_id=payment.order_id, trigger=trigger
+ 'RioPay платеж уже привязан к транзакции', order_id=payment.order_id, trigger=trigger
)
return True
@@ -272,7 +271,7 @@ class RioPayPaymentMixin:
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
- 'Пользователь не найден для RioPay платежа (trigger=)',
+ 'Пользователь не найден для RioPay платежа',
user_id=payment.user_id,
order_id=payment.order_id,
trigger=trigger,
@@ -308,6 +307,10 @@ class RioPayPaymentMixin:
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.now(UTC)
+ # Обновляем флаг первого пополнения в том же коммите
+ if was_first_topup:
+ user.has_made_first_topup = True
+
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
@@ -323,10 +326,6 @@ class RioPayPaymentMixin:
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)
@@ -385,7 +384,7 @@ class RioPayPaymentMixin:
)
logger.info(
- '✅ Обработан RioPay платеж для пользователя (trigger=)',
+ 'Обработан RioPay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
diff --git a/app/services/riopay_service.py b/app/services/riopay_service.py
index 236a5572..c8438394 100644
--- a/app/services/riopay_service.py
+++ b/app/services/riopay_service.py
@@ -15,11 +15,21 @@ logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://api.riopay.online'
+class RioPayAPIError(Exception):
+ """Ошибка API RioPay."""
+
+ def __init__(self, status_code: int, message: str):
+ self.status_code = status_code
+ self.message = message
+ super().__init__(f'RioPay API error ({status_code}): {message}')
+
+
class RioPayService:
"""Сервис для работы с API RioPay."""
def __init__(self):
self._api_token: str | None = None
+ self._session: aiohttp.ClientSession | None = None
@property
def api_token(self) -> str:
@@ -39,6 +49,20 @@ class RioPayService:
'Content-Type': 'application/json',
}
+ async def _get_session(self) -> aiohttp.ClientSession:
+ """Возвращает переиспользуемую HTTP-сессию."""
+ if self._session is None or self._session.closed:
+ self._session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=30),
+ )
+ return self._session
+
+ async def close(self) -> None:
+ """Закрывает HTTP-сессию."""
+ if self._session and not self._session.closed:
+ await self._session.close()
+ self._session = None
+
async def create_order(
self,
*,
@@ -76,23 +100,19 @@ class RioPayService:
)
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)
-
+ session = await self._get_session()
+ async with session.post(
+ f'{API_BASE_URL}/v1/orders',
+ json=payload,
+ headers=self._get_headers(),
+ ) as response:
if response.status == 201:
data = await response.json(content_type=None)
+ logger.info('RioPay API order created', status_code=response.status, order_id=data.get('id'))
return data
# Ошибка
+ text = await response.text()
try:
error_data = await response.json(content_type=None)
error_msg = error_data.get('message') or error_data.get('error') or text
@@ -100,7 +120,7 @@ class RioPayService:
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}')
+ raise RioPayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('RioPay API connection error', error=e)
@@ -114,21 +134,17 @@ class RioPayService:
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)
-
+ session = await self._get_session()
+ async with session.get(
+ f'{API_BASE_URL}/v1/orders/{order_id}',
+ headers=self._get_headers(),
+ ) as response:
if response.status == 200:
return await response.json(content_type=None)
- raise Exception(f'RioPay get_order error ({response.status}): {text}')
+ text = await response.text()
+ logger.error('RioPay get_order error', status_code=response.status)
+ raise RioPayAPIError(response.status, text)
except aiohttp.ClientError as e:
logger.exception('RioPay API connection error', error=e)
diff --git a/app/webserver/payments.py b/app/webserver/payments.py
index c9a6ba7f..492ced2d 100644
--- a/app/webserver/payments.py
+++ b/app/webserver/payments.py
@@ -1166,7 +1166,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
if success:
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
- logger.error('RioPay webhook processing failed', payload=payload)
+ logger.error(
+ 'RioPay webhook processing failed',
+ order_id=payload.get('id'),
+ status=payload.get('status'),
+ )
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.exception('RioPay webhook processing error', e=e)
diff --git a/migrations/alembic/versions/0015_add_riopay_payments.py b/migrations/alembic/versions/0015_add_riopay_payments.py
index 63d148d2..164be00a 100644
--- a/migrations/alembic/versions/0015_add_riopay_payments.py
+++ b/migrations/alembic/versions/0015_add_riopay_payments.py
@@ -11,15 +11,35 @@ from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
-revision: str = '0016'
+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:
- pass
+ 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, index=True),
+ 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('0')),
+ 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(), onupdate=sa.func.now()),
+ sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
+ )
def downgrade() -> None:
- pass
+ op.drop_table('riopay_payments')