diff --git a/.github/assets/cabinet-admin.png b/.github/assets/cabinet-admin.png new file mode 100644 index 00000000..6c5d1987 Binary files /dev/null and b/.github/assets/cabinet-admin.png differ diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index cdb9f848..990ff297 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -794,6 +794,35 @@ async def create_topup( detail='Failed to create SeverPay payment', ) + elif request.payment_method == 'paypear': + if not settings.is_paypear_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='PayPear payment method is unavailable', + ) + + payment_service = PaymentService() + result = await payment_service.create_paypear_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create PayPear payment', + ) + else: # For other payment methods, redirect to bot raise HTTPException( diff --git a/app/config.py b/app/config.py index 025e19a8..f083b3af 100644 --- a/app/config.py +++ b/app/config.py @@ -588,6 +588,18 @@ class Settings(BaseSettings): SEVERPAY_RETURN_URL: str | None = None SEVERPAY_LIFETIME: int = 1440 # minutes, 30-4320 + # PayPear (paypear.ru) + PAYPEAR_ENABLED: bool = False + PAYPEAR_SHOP_ID: str | None = None + PAYPEAR_SECRET_KEY: str | None = None + PAYPEAR_DISPLAY_NAME: str = 'PayPear' + PAYPEAR_CURRENCY: str = 'RUB' + PAYPEAR_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽ + PAYPEAR_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽ + PAYPEAR_WEBHOOK_PATH: str = '/paypear-webhook' + PAYPEAR_RETURN_URL: str | None = None + PAYPEAR_PAYMENT_METHOD: str = 'sbp' # bank_card, sbp, sberpay, tpay + MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet' # Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции) CABINET_BUTTON_STYLE: str = '' @@ -1973,6 +1985,16 @@ class Settings(BaseSettings): def get_severpay_display_name_html(self) -> str: return html.escape(self.get_severpay_display_name()) + def is_paypear_enabled(self) -> bool: + return self.PAYPEAR_ENABLED and self.PAYPEAR_SHOP_ID is not None and self.PAYPEAR_SECRET_KEY is not None + + def get_paypear_display_name(self) -> str: + name = (self.PAYPEAR_DISPLAY_NAME or '').strip() + return name if name else 'PayPear' + + def get_paypear_display_name_html(self) -> str: + return html.escape(self.get_paypear_display_name()) + def is_kassa_ai_sbp_enabled(self) -> bool: return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled() diff --git a/app/database/crud/paypear.py b/app/database/crud/paypear.py new file mode 100644 index 00000000..5deb6c50 --- /dev/null +++ b/app/database/crud/paypear.py @@ -0,0 +1,157 @@ +"""CRUD операции для платежей PayPear.""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import PayPearPayment + + +logger = structlog.get_logger(__name__) + + +async def create_paypear_payment( + db: AsyncSession, + *, + user_id: int | None, + order_id: str, + amount_kopeks: int, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_method: str | None = None, + paypear_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> PayPearPayment: + """Создает запись о платеже PayPear.""" + payment = PayPearPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + paypear_id=paypear_id, + expires_at=expires_at, + metadata_json=metadata_json, + status='pending', + is_paid=False, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + logger.info('Создан платеж PayPear', order_id=order_id, user_id=user_id) + return payment + + +async def get_paypear_payment_by_order_id(db: AsyncSession, order_id: str) -> PayPearPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(PayPearPayment).where(PayPearPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_paypear_payment_by_paypear_id(db: AsyncSession, paypear_id: str) -> PayPearPayment | None: + """Получает платеж по ID от PayPear.""" + result = await db.execute(select(PayPearPayment).where(PayPearPayment.paypear_id == paypear_id)) + return result.scalar_one_or_none() + + +async def get_paypear_payment_by_id(db: AsyncSession, payment_id: int) -> PayPearPayment | None: + """Получает платеж по ID.""" + result = await db.execute(select(PayPearPayment).where(PayPearPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_paypear_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PayPearPayment | None: + """Получает платеж по ID с блокировкой FOR UPDATE.""" + result = await db.execute( + select(PayPearPayment) + .where(PayPearPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_paypear_payment_status( + db: AsyncSession, + payment: PayPearPayment, + *, + status: str, + is_paid: bool | None = None, + paypear_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> PayPearPayment: + """Обновляет статус платежа.""" + payment.status = status + payment.updated_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 paypear_id is not None: + payment.paypear_id = paypear_id + if payment_method is not None: + payment.payment_method = payment_method + if callback_payload is not None: + payment.callback_payload = callback_payload + if transaction_id is not None: + payment.transaction_id = transaction_id + + await db.commit() + await db.refresh(payment) + logger.info( + 'Обновлен статус платежа PayPear', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_paypear_payments(db: AsyncSession, user_id: int) -> list[PayPearPayment]: + """Получает незавершенные платежи пользователя.""" + result = await db.execute( + select(PayPearPayment).where( + PayPearPayment.user_id == user_id, + PayPearPayment.status == 'pending', + PayPearPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_paypear_payments( + db: AsyncSession, +) -> list[PayPearPayment]: + """Получает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(PayPearPayment).where( + PayPearPayment.status == 'pending', + PayPearPayment.is_paid == False, + PayPearPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_paypear_payment_to_transaction( + db: AsyncSession, + *, + payment: PayPearPayment, + transaction_id: int, +) -> PayPearPayment: + """Связывает платеж с транзакцией.""" + payment.transaction_id = transaction_id + payment.updated_at = datetime.now(UTC) + await db.flush() + await db.refresh(payment) + return payment diff --git a/app/database/models.py b/app/database/models.py index 98cd6785..89d549a7 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -162,6 +162,7 @@ class PaymentMethod(Enum): KASSA_AI = 'kassa_ai' RIOPAY = 'riopay' SEVERPAY = 'severpay' + PAYPEAR = 'paypear' MANUAL = 'manual' BALANCE = 'balance' @@ -878,6 +879,68 @@ class SeverPayPayment(Base): return f'' +class PayPearPayment(Base): + """Платежи через PayPear (paypear.ru).""" + + __tablename__ = 'paypear_payments' + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + + # Идентификаторы + order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID + paypear_id = Column(String(64), unique=True, nullable=True, index=True) # ID от PayPear + + # Суммы + 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') + is_paid = Column(Boolean, default=False) + + # Данные платежа + payment_url = Column(Text, nullable=True) + payment_method = Column(String(32), nullable=True) + + # Метаданные + 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='paypear_payments') + transaction = relationship('Transaction', backref='paypear_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', 'amount_mismatch'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + class PromoGroup(Base): __tablename__ = 'promo_groups' diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 1c947074..d25d2d15 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -149,6 +149,13 @@ async def route_payment_by_method( await process_severpay_payment_amount(message, db_user, db, amount_kopeks, state) return True + if payment_method == 'paypear': + from .paypear import process_paypear_payment_amount + + async with AsyncSessionLocal() as db: + await process_paypear_payment_amount(message, db_user, db, amount_kopeks, state) + return True + if payment_method == 'riopay': from .riopay import process_riopay_payment_amount @@ -719,6 +726,10 @@ def register_balance_handlers(dp: Dispatcher): dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay') + from .paypear import start_paypear_topup + + dp.callback_query.register(start_paypear_topup, F.data == 'topup_paypear') + from .mulenpay import check_mulenpay_payment_status dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) diff --git a/app/handlers/balance/paypear.py b/app/handlers/balance/paypear.py new file mode 100644 index 00000000..d775e5ce --- /dev/null +++ b/app/handlers/balance/paypear.py @@ -0,0 +1,247 @@ +"""Handler for PayPear balance top-up.""" + +import html + +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__) + + +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='\U0001f198 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +async def _create_paypear_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, +): + """ + Common logic for creating PayPear 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_paypear_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_paypear_display_name() + + # Create keyboard with payment button + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + 'PAY_BUTTON', + '\U0001f4b3 Оплатить {amount}\u20bd', + ).format(amount=f'{amount_rub:.0f}'), + url=payment_url, + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'), + callback_data='menu_balance', + ) + ], + ] + ) + + response_text = texts.t( + 'PAYPEAR_PAYMENT_CREATED', + '\U0001f4b3 Оплата через {name}\n\n' + 'Сумма: {amount}\u20bd\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('PayPear payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_paypear_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +): + """ + Process payment amount directly. + """ + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await message.answer( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + await state.clear() + return + + # Validate amount + min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS + max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS + + if amount_kopeks < min_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}\u20bd', + ).format(min_amount=min_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + if amount_kopeks > max_amount: + await message.answer( + texts.t( + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}\u20bd', + ).format(max_amount=max_amount // 100), + reply_markup=get_back_keyboard(db_user.language), + parse_mode='HTML', + ) + return + + await state.clear() + + await _create_paypear_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_paypear_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """ + Start PayPear top-up process - ask for amount. + """ + texts = get_texts(db_user.language) + + restriction_kb = _check_topup_restriction(db_user, texts) + if restriction_kb: + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + await callback.message.edit_text( + f'\U0001f6ab Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=restriction_kb, + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method='paypear') + + min_amount = settings.PAYPEAR_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.PAYPEAR_MAX_AMOUNT_KOPEKS // 100 + display_name = settings.get_paypear_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'), + callback_data='menu_balance', + ) + ] + ] + ) + + await callback.message.edit_text( + texts.t( + 'PAYPEAR_ENTER_AMOUNT', + '\U0001f4b3 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}\u20bd\n' + 'Максимум: {max_amount}\u20bd', + ).format( + name=display_name, + min_amount=min_amount, + max_amount=f'{max_amount:,}'.replace(',', ' '), + ), + parse_mode='HTML', + reply_markup=keyboard, + ) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index e824d312..b126249a 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1807,6 +1807,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True + if settings.is_paypear_enabled(): + paypear_name = settings.get_paypear_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_PAYPEAR', f'💳 Оплата ({paypear_name})'), + callback_data=_build_callback('paypear'), + ) + ] + ) + has_direct_payment_methods = True + if settings.is_support_topup_enabled(): keyboard.append( [ diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 2eb448f5..960fb320 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -12,6 +12,7 @@ from .heleket import HeleketPaymentMixin from .kassa_ai import KassaAiPaymentMixin from .mulenpay import MulenPayPaymentMixin from .pal24 import Pal24PaymentMixin +from .paypear import PayPearPaymentMixin from .platega import PlategaPaymentMixin from .riopay import RioPayPaymentMixin from .severpay import SeverPayPaymentMixin @@ -29,6 +30,7 @@ __all__ = [ 'KassaAiPaymentMixin', 'MulenPayPaymentMixin', 'Pal24PaymentMixin', + 'PayPearPaymentMixin', 'PaymentCommonMixin', 'PlategaPaymentMixin', 'RioPayPaymentMixin', diff --git a/app/services/payment/paypear.py b/app/services/payment/paypear.py new file mode 100644 index 00000000..ab8595f6 --- /dev/null +++ b/app/services/payment/paypear.py @@ -0,0 +1,624 @@ +"""Mixin для интеграции с PayPear (paypear.ru).""" + +from __future__ import annotations + +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.paypear_service import paypear_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг статусов PayPear -> internal +PAYPEAR_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'NEW': ('pending', False), + 'PROCESS': ('processing', False), + 'CONFIRMED': ('success', True), + 'CANCELED': ('canceled', False), + 'REFUNDED': ('refunded', False), + 'EXPIRED': ('expired', False), +} + + +class PayPearPaymentMixin: + """Mixin для работы с платежами PayPear.""" + + async def create_paypear_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + return_url: str | None = None, + ) -> dict[str, Any] | None: + """ + Создает платеж PayPear. + + Returns: + Словарь с данными платежа или None при ошибке + """ + if not settings.is_paypear_enabled(): + logger.error('PayPear не настроен') + return None + + # Валидация лимитов + if amount_kopeks < settings.PAYPEAR_MIN_AMOUNT_KOPEKS: + logger.warning( + 'PayPear: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + PAYPEAR_MIN_AMOUNT_KOPEKS=settings.PAYPEAR_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.PAYPEAR_MAX_AMOUNT_KOPEKS: + logger.warning( + 'PayPear: сумма больше максимальной', + amount_kopeks=amount_kopeks, + PAYPEAR_MAX_AMOUNT_KOPEKS=settings.PAYPEAR_MAX_AMOUNT_KOPEKS, + ) + return None + + # Получаем telegram_id пользователя для order_id + payment_module = import_module('app.services.payment_service') + if user_id is not None: + user = await payment_module.get_user_by_id(db, user_id) + tg_id = user.telegram_id if user else user_id + else: + user = None + tg_id = 'guest' + + # Генерируем уникальный order_id с telegram_id для удобного поиска + order_id = f'pp{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + currency = settings.PAYPEAR_CURRENCY + + # Метаданные + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + } + + try: + payment_method_type = settings.PAYPEAR_PAYMENT_METHOD + + # Используем API для создания платежа + result = await paypear_service.create_payment( + order_id=order_id, + amount_rubles=amount_rubles, + currency=currency, + payment_method_type=payment_method_type, + description=description, + return_url=return_url or settings.PAYPEAR_RETURN_URL, + metadata=metadata, + ) + + confirmation = result.get('confirmation', {}) + payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None + paypear_id = result.get('id') + + if not payment_url: + logger.error('PayPear API не вернул URL платежа', result=result) + return None + + logger.info( + 'PayPear API: создан платеж', + order_id=order_id, + paypear_id=paypear_id, + payment_url=payment_url, + ) + + # Срок действия — 30 минут по умолчанию + expires_at = datetime.now(UTC) + timedelta(minutes=30) + + # Сохраняем в БД + paypear_crud = import_module('app.database.crud.paypear') + local_payment = await paypear_crud.create_paypear_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method_type, + paypear_id=paypear_id, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'PayPear: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'paypear_id': paypear_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('PayPear: ошибка создания платежа', error=e) + return None + + async def process_paypear_webhook( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """ + Обрабатывает webhook от PayPear. + + Подпись проверяется в webserver/payments.py до вызова этого метода. + + Args: + db: Сессия БД + payload: JSON тело webhook (signature проверена в webserver) + + Returns: + True если платеж успешно обработан + """ + try: + event = payload.get('event') + obj = payload.get('object', {}) + paypear_id = obj.get('id') + order_id = obj.get('order_id') + paypear_status = obj.get('status') + + if not paypear_id or not paypear_status: + logger.warning('PayPear webhook: отсутствуют обязательные поля', payload=payload) + return False + + # Определяем is_paid по event + is_confirmed = event == 'payment.confirmed' + + # Ищем платеж по order_id (наш) или paypear_id + paypear_crud = import_module('app.database.crud.paypear') + payment = None + if order_id: + payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id) + if not payment and paypear_id: + payment = await paypear_crud.get_paypear_payment_by_paypear_id(db, paypear_id) + + if not payment: + logger.warning( + 'PayPear webhook: платеж не найден', + order_id=order_id, + paypear_id=paypear_id, + ) + return False + + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Проверка дублирования (re-check from locked row) + if payment.is_paid: + logger.info('PayPear webhook: платеж уже обработан', order_id=payment.order_id) + return True + + # Маппинг статуса + status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False)) + internal_status, is_paid = status_info + + # Если event = payment.confirmed, принудительно считаем оплаченным + if is_confirmed: + is_paid = True + internal_status = 'success' + + callback_payload = { + 'paypear_id': paypear_id, + 'order_id': order_id, + 'status': paypear_status, + 'event': event, + 'amount': obj.get('amount'), + } + + # Проверка суммы ДО обновления статуса + if is_paid: + amount_info = obj.get('amount', {}) + if isinstance(amount_info, dict): + amount_value = amount_info.get('value') + else: + amount_value = amount_info + + if amount_value is not None: + received_kopeks = round(float(amount_value) * 100) + if abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'PayPear amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await paypear_crud.update_paypear_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + paypear_id=paypear_id, + callback_payload=callback_payload, + ) + return False + + # Финализируем платеж если оплачен — без промежуточного commit + if is_paid: + # Inline field assignments to keep FOR UPDATE lock intact + payment.status = internal_status + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.paypear_id = paypear_id or payment.paypear_id + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_paypear_payment(db, payment, paypear_id=paypear_id, trigger='webhook') + + # Для не-success статусов можно безопасно коммитить + payment = await paypear_crud.update_paypear_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + paypear_id=paypear_id, + callback_payload=callback_payload, + ) + + return True + + except Exception as e: + logger.exception('PayPear webhook: ошибка обработки', error=e) + return False + + async def _finalize_paypear_payment( + self, + db: AsyncSession, + payment: Any, + *, + paypear_id: str | None, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock must be acquired by the caller before invoking this method. + """ + payment_module = import_module('app.services.payment_service') + paypear_crud = import_module('app.database.crud.paypear') + + # FOR UPDATE lock already acquired by caller — just check idempotency + if payment.transaction_id: + logger.info( + 'PayPear платеж уже связан с транзакцией', + order_id=payment.order_id, + transaction_id=payment.transaction_id, + trigger=trigger, + ) + return True + + # Read fresh metadata AFTER lock to avoid stale data + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + + # --- Guest purchase flow --- + from app.services.payment.common import try_fulfill_guest_purchase + + guest_result = await try_fulfill_guest_purchase( + db, + metadata=metadata, + payment_amount_kopeks=payment.amount_kopeks, + provider_payment_id=str(paypear_id) if paypear_id else payment.order_id, + provider_name='paypear', + ) + if guest_result is not None: + return True + + # Ensure paid fields are set (idempotent — caller may have already set them) + if not payment.is_paid: + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.updated_at = datetime.now(UTC) + + balance_already_credited = bool(metadata.get('balance_credited')) + + user = await payment_module.get_user_by_id(db, payment.user_id) + if not user: + logger.error('Пользователь не найден для PayPear', user_id=payment.user_id) + return False + + # Загружаем промогруппы в асинхронном контексте + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) + + promo_group = user.get_primary_promo_group() + subscription = getattr(user, 'subscription', None) + referrer_info = format_referrer_info(user) + + transaction_external_id = str(paypear_id) if paypear_id else payment.order_id + + # Проверяем дупликат транзакции + existing_transaction = None + if transaction_external_id: + existing_transaction = await payment_module.get_transaction_by_external_id( + db, + transaction_external_id, + PaymentMethod.PAYPEAR, + ) + + display_name = settings.get_paypear_display_name() + description = f'Пополнение через {display_name}' + + transaction = existing_transaction + created_transaction = False + + if not transaction: + transaction = await payment_module.create_transaction( + db, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=payment.amount_kopeks, + description=description, + payment_method=PaymentMethod.PAYPEAR, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await paypear_crud.link_paypear_payment_to_transaction(db, payment=payment, transaction_id=transaction.id) + + should_credit_balance = created_transaction or not balance_already_credited + + if not should_credit_balance: + logger.info('PayPear платеж уже зачислил баланс ранее', order_id=payment.order_id) + return True + + # Lock user row to prevent concurrent balance race conditions + from app.database.crud.user import lock_user_for_update + + user = await lock_user_for_update(db, user) + + 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) + await db.commit() + await db.refresh(user) + + # Emit deferred side-effects after atomic commit + from app.database.crud.transaction import emit_transaction_side_effects + + await emit_transaction_side_effects( + db, + transaction, + amount_kopeks=payment.amount_kopeks, + user_id=payment.user_id, + type=TransactionType.DEPOSIT, + payment_method=PaymentMethod.PAYPEAR, + external_id=transaction_external_id, + ) + + topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение' + + 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('Ошибка обработки реферального пополнения PayPear', error=error) + + if was_first_topup and not user.has_made_first_topup and not user.referred_by_id: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + 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('Ошибка отправки админ уведомления PayPear', error=error) + + if getattr(self, 'bot', None) and user.telegram_id: + try: + keyboard = await self.build_topup_success_keyboard(user) + await self.bot.send_message( + user.telegram_id, + ( + '\u2705 Пополнение успешно!\n\n' + f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'\U0001f4b3 Способ: {display_name}\n' + f'\U0001f194 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' + ), + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.error('Ошибка отправки уведомления пользователю PayPear', 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=payment.user_id, + error=error, + exc_info=True, + ) + + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.now(UTC).isoformat(), + } + metadata['balance_credited'] = True + payment.metadata_json = metadata + await db.commit() + + logger.info( + 'Обработан PayPear платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_paypear_payment_status( + self, + db: AsyncSession, + order_id: str, + ) -> dict[str, Any] | None: + """Проверяет статус платежа через API.""" + try: + paypear_crud = import_module('app.database.crud.paypear') + payment = await paypear_crud.get_paypear_payment_by_order_id(db, order_id) + if not payment: + logger.warning('PayPear payment not found', order_id=order_id) + return None + + if payment.is_paid: + return { + 'payment': payment, + 'status': 'success', + 'is_paid': True, + } + + # Проверяем через API по paypear_id + if payment.paypear_id: + try: + order_data = await paypear_service.get_payment(payment.paypear_id) + paypear_status = order_data.get('status') + + if paypear_status: + status_info = PAYPEAR_STATUS_MAP.get(paypear_status, ('pending', False)) + internal_status, is_paid = status_info + + if is_paid: + # Проверка суммы + amount_info = order_data.get('amount', {}) + api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info + if api_amount is not None: + received_kopeks = round(float(api_amount) * 100) + if abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'PayPear amount mismatch (API check)', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + order_id=payment.order_id, + ) + await paypear_crud.update_paypear_payment_status( + db=db, + payment=payment, + status='amount_mismatch', + is_paid=False, + paypear_id=payment.paypear_id, + callback_payload={ + 'check_source': 'api', + 'paypear_order_data': order_data, + }, + ) + return { + 'payment': payment, + 'status': 'amount_mismatch', + 'is_paid': False, + } + + # Acquire FOR UPDATE lock before finalization + locked = await paypear_crud.get_paypear_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('PayPear: не удалось заблокировать платёж', payment_id=payment.id) + return None + payment = locked + + if payment.is_paid: + logger.info('PayPear платеж уже обработан (api_check)', order_id=payment.order_id) + return { + 'payment': payment, + 'status': 'success', + 'is_paid': True, + } + + logger.info('PayPear payment confirmed via API', order_id=payment.order_id) + + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.callback_payload = { + 'check_source': 'api', + 'paypear_order_data': order_data, + } + payment.updated_at = datetime.now(UTC) + await db.flush() + + await self._finalize_paypear_payment( + db, + payment, + paypear_id=payment.paypear_id, + trigger='api_check', + ) + elif internal_status != payment.status: + # Обновляем статус если изменился + payment = await paypear_crud.update_paypear_payment_status( + db=db, + payment=payment, + status=internal_status, + ) + + except Exception as e: + logger.error('Error checking PayPear payment status via API', error=e) + + return { + 'payment': payment, + 'status': payment.status or 'pending', + 'is_paid': payment.is_paid, + } + + except Exception as e: + logger.exception('PayPear: ошибка проверки статуса', error=e) + return None diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index ebb8d552..9eb72562 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -146,6 +146,18 @@ def _get_method_defaults() -> dict: 'default_max': settings.SEVERPAY_MAX_AMOUNT_KOPEKS, 'available_sub_options': None, }, + 'paypear': { + 'default_display_name': settings.get_paypear_display_name(), + 'is_configured': settings.is_paypear_enabled(), + 'default_min': settings.PAYPEAR_MIN_AMOUNT_KOPEKS, + 'default_max': settings.PAYPEAR_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'bank_card', 'name': 'Карта'}, + {'id': 'sbp', 'name': 'СБП'}, + {'id': 'sberpay', 'name': 'SberPay'}, + {'id': 'tpay', 'name': 'T-Pay'}, + ], + }, } @@ -188,6 +200,7 @@ DEFAULT_METHOD_ORDER = [ 'kassa_ai', 'riopay', 'severpay', + 'paypear', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 0fa4d73e..79cf33f3 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -33,6 +33,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.paypear import PayPearPaymentMixin from app.services.payment.riopay import RioPayPaymentMixin from app.services.payment.severpay import SeverPayPaymentMixin from app.services.platega_service import PlategaService @@ -332,6 +333,41 @@ async def link_severpay_payment_to_transaction(*args, **kwargs): return await severpay_crud.link_severpay_payment_to_transaction(*args, **kwargs) +async def create_paypear_payment(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.create_paypear_payment(*args, **kwargs) + + +async def get_paypear_payment_by_order_id(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.get_paypear_payment_by_order_id(*args, **kwargs) + + +async def get_paypear_payment_by_paypear_id(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.get_paypear_payment_by_paypear_id(*args, **kwargs) + + +async def get_paypear_payment_by_id(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.get_paypear_payment_by_id(*args, **kwargs) + + +async def get_paypear_payment_by_id_for_update(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.get_paypear_payment_by_id_for_update(*args, **kwargs) + + +async def update_paypear_payment_status(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.update_paypear_payment_status(*args, **kwargs) + + +async def link_paypear_payment_to_transaction(*args, **kwargs): + paypear_crud = import_module('app.database.crud.paypear') + return await paypear_crud.link_paypear_payment_to_transaction(*args, **kwargs) + + # Mapping from model_name to getter function name for providers # where it differs from the standard get_{model_name}_payment_by_id pattern. _GETTER_OVERRIDES: dict[str, str] = { @@ -355,6 +391,7 @@ class PaymentService( KassaAiPaymentMixin, RioPayPaymentMixin, SeverPayPaymentMixin, + PayPearPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -774,6 +811,28 @@ class PaymentService( } return None + # --- PayPear ---------------------------------------------------------- + if payment_method == 'paypear': + if not settings.is_paypear_enabled(): + logger.warning('PayPear is not enabled, cannot create guest payment') + return None + + result = await self.create_paypear_payment( + db=db, + user_id=None, + amount_kopeks=amount_kopeks, + description=description, + return_url=return_url, + ) + if result: + await _patch_guest_metadata(result['local_payment_id'], 'paypear') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('paypear_id') or result.get('order_id'), + 'provider': 'paypear', + } + return None + # --- Telegram Stars --------------------------------------------------- if payment_method == 'telegram_stars': if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/services/paypear_service.py b/app/services/paypear_service.py new file mode 100644 index 00000000..04f1f299 --- /dev/null +++ b/app/services/paypear_service.py @@ -0,0 +1,235 @@ +"""Сервис для работы с API PayPear (paypear.ru).""" + +import hashlib +import hmac +import uuid +from base64 import b64encode +from typing import Any + +import aiohttp +import structlog + +from app.config import settings + + +logger = structlog.get_logger(__name__) + +API_BASE_URL = 'https://api.paypear.ru/v1' + + +class PayPearAPIError(Exception): + """Ошибка API PayPear.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + super().__init__(f'PayPear API error ({status_code}): {message}') + + +class PayPearService: + """Сервис для работы с API PayPear.""" + + def __init__(self): + self._session: aiohttp.ClientSession | None = None + + @property + def shop_id(self) -> str: + return settings.PAYPEAR_SHOP_ID or '' + + @property + def secret_key(self) -> str: + return settings.PAYPEAR_SECRET_KEY or '' + + def _basic_auth_header(self) -> str: + """Генерирует HTTP Basic Auth заголовок.""" + credentials = f'{self.shop_id}:{self.secret_key}' + encoded = b64encode(credentials.encode('utf-8')).decode('utf-8') + return f'Basic {encoded}' + + 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_payment( + self, + *, + order_id: str, + amount_rubles: float, + currency: str = 'RUB', + payment_method_type: str = 'sbp', + description: str = '', + return_url: str | None = None, + webhook_url: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """ + Создает платеж через API PayPear. + POST /v1/payment/ + """ + idempotence_key = str(uuid.uuid4()) + + payload: dict[str, Any] = { + 'amount': { + 'value': f'{amount_rubles:.2f}', + 'currency': currency, + }, + 'order_id': order_id, + 'payment_method_data': { + 'type': payment_method_type, + }, + } + + if description: + payload['description'] = description + if return_url: + payload['confirmation'] = { + 'type': 'redirect', + 'return_url': return_url, + } + if webhook_url: + payload['webhook_url'] = webhook_url + if metadata: + payload['metadata'] = metadata + + logger.info( + 'PayPear API create_payment', + order_id=order_id, + amount_rubles=amount_rubles, + currency=currency, + payment_method_type=payment_method_type, + ) + + try: + session = await self._get_session() + async with session.post( + f'{API_BASE_URL}/payment/', + json=payload, + headers={ + 'Content-Type': 'application/json', + 'Authorization': self._basic_auth_header(), + 'Idempotence-Key': idempotence_key, + }, + ) as response: + data = await response.json(content_type=None) + + if response.status == 200 and data.get('success') is True: + result = data.get('result', {}) + logger.info( + 'PayPear API payment created', + order_id=order_id, + paypear_id=result.get('id'), + ) + return result + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'PayPear create_payment error', + status_code=response.status, + error_msg=error_msg, + response_data=data, + ) + raise PayPearAPIError(response.status, error_msg) + + except aiohttp.ClientError as e: + logger.exception('PayPear API connection error', error=e) + raise + + async def get_payment(self, payment_id: str) -> dict[str, Any]: + """ + Получает информацию о платеже по ID. + GET /v1/payment/{id}/ + """ + logger.info('PayPear get_payment', payment_id=payment_id) + + try: + session = await self._get_session() + async with session.get( + f'{API_BASE_URL}/payment/{payment_id}/', + headers={ + 'Authorization': self._basic_auth_header(), + }, + ) as response: + data = await response.json(content_type=None) + + if response.status == 200 and data.get('success') is True: + return data.get('result', {}) + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'PayPear get_payment error', + status_code=response.status, + error_msg=error_msg, + ) + raise PayPearAPIError(response.status, error_msg) + + except aiohttp.ClientError as e: + logger.exception('PayPear API connection error', error=e) + raise + + async def get_payment_by_order_id(self, order_id: str) -> dict[str, Any]: + """ + Получает информацию о платеже по order_id. + GET /v1/payment/order/{order_id}/ + """ + logger.info('PayPear get_payment_by_order_id', order_id=order_id) + + try: + session = await self._get_session() + async with session.get( + f'{API_BASE_URL}/payment/order/{order_id}/', + headers={ + 'Authorization': self._basic_auth_header(), + }, + ) as response: + data = await response.json(content_type=None) + + if response.status == 200 and data.get('success') is True: + return data.get('result', {}) + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'PayPear get_payment_by_order_id error', + status_code=response.status, + error_msg=error_msg, + ) + raise PayPearAPIError(response.status, error_msg) + + except aiohttp.ClientError as e: + logger.exception('PayPear API connection error', error=e) + raise + + def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool: + """Верификация подписи webhook PayPear через HMAC-SHA256. + + PayPear sends signature in the webhook JSON field 'signature'. + The signature is HMAC-SHA256(secret_key, raw_body). + """ + try: + if not received_signature: + logger.warning('PayPear webhook: отсутствует signature') + return False + + expected = hmac.new( + self.secret_key.encode('utf-8'), + raw_body, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(expected, received_signature) + except Exception as e: + logger.error('PayPear webhook verify error', error=e) + return False + + +# Singleton instance +paypear_service = PayPearService() diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 77685933..6463b2b3 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1241,6 +1241,55 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute routes_registered = True + # PayPear webhook + if settings.is_paypear_enabled(): + + @router.get(settings.PAYPEAR_WEBHOOK_PATH) + async def paypear_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'paypear_webhook', + 'enabled': settings.is_paypear_enabled(), + } + ) + + @router.post(settings.PAYPEAR_WEBHOOK_PATH) + async def paypear_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('PayPear webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + # Извлекаем подпись из тела webhook + received_signature = payload.get('signature', '') + + from app.services.paypear_service import paypear_service + + if not paypear_service.verify_webhook_signature(raw_body, received_signature): + logger.warning('PayPear webhook: invalid signature') + return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_paypear_webhook', + ) + if not success: + logger.error( + 'PayPear webhook processing failed', + data=payload.get('object'), + ) + except Exception as e: + logger.exception('PayPear webhook processing error', error=e) + # Always return 200 — PayPear may retry on non-200 + return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) + + routes_registered = True + if routes_registered: @router.get('/health/payment-webhooks') @@ -1261,6 +1310,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 'kassa_ai_enabled': settings.is_kassa_ai_enabled(), 'riopay_enabled': settings.is_riopay_enabled(), 'severpay_enabled': settings.is_severpay_enabled(), + 'paypear_enabled': settings.is_paypear_enabled(), } ) diff --git a/migrations/alembic/versions/0058_create_paypear_payments.py b/migrations/alembic/versions/0058_create_paypear_payments.py new file mode 100644 index 00000000..85c9b2f3 --- /dev/null +++ b/migrations/alembic/versions/0058_create_paypear_payments.py @@ -0,0 +1,47 @@ +"""create paypear_payments table + +Revision ID: 0058 +Revises: 0057 +Create Date: 2026-04-15 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0058' +down_revision: Union[str, None] = '0057' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'paypear_payments', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True), + sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('paypear_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'), nullable=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('paypear_payments') diff --git a/uv.lock b/uv.lock index 2d051b58..495a55ac 100644 --- a/uv.lock +++ b/uv.lock @@ -564,7 +564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -1143,7 +1142,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.45.1" +version = "3.46.1" source = { virtual = "." } dependencies = [ { name = "aiogram" },