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:
Binary file not shown.
|
After Width: | Height: | Size: 822 KiB |
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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_'))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user