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')