feat: integrate PayPear payment provider

Full integration following SeverPay patterns:
- API client with Basic Auth, idempotency, HMAC webhook verification
- PayPearPaymentMixin with create, webhook, finalize (all side effects), status check
- CRUD, model, migration (0058), PaymentMethod.PAYPEAR enum
- Webhook endpoint, cabinet topup, bot handler, payment verification
- Sub-options: bank_card, sbp, sberpay, tpay
- Config: PAYPEAR_ENABLED, PAYPEAR_SHOP_ID, PAYPEAR_SECRET_KEY, etc.
This commit is contained in:
Fringg
2026-04-16 05:36:48 +03:00
parent 92eaf45311
commit a18f6caa9b
16 changed files with 1572 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

+29
View File
@@ -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(
+22
View File
@@ -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()
+157
View File
@@ -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
+63
View File
@@ -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'<SeverPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
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'<PayPearPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+11
View File
@@ -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_'))
+247
View File
@@ -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 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</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('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 <b>Пополнение ограничено</b>\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 <b>Пополнение ограничено</b>\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 <b>Пополнение через {name}</b>\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,
)
+12
View File
@@ -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(
[
+2
View File
@@ -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',
+624
View File
@@ -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 <b>Пополнение успешно!</b>\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
@@ -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',
]
+59
View File
@@ -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:
+235
View File
@@ -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()
+50
View File
@@ -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(),
}
)
@@ -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')
Generated
+1 -2
View File
@@ -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" },