feat: integrate Overpay payment provider (pay.overpay.io)
Full integration across bot, cabinet, admin panel and landing pages: - Config: 16 OVERPAY_* settings (API URL, credentials, P12 cert path, project ID, currency, amount limits, webhook path, payment methods) - Service: overpay_service.py with httpx mTLS (P12 cert) + Basic Auth, create_payment, get_payment, refund_payment methods - Payment mixin: OverpayPaymentMixin with create, webhook processing, finalize (balance credit + notifications), status check - Model: OverpayPayment table + OVERPAY enum value - CRUD: 9 standard operations (create, get_by_*, for_update, link) - Bot handler: start_overpay_topup + process_overpay_payment_amount - Handler registration: route_payment_by_method + callback registration - Webhook: POST /overpay-webhook with DB-based anti-spoofing validation - Cabinet: balance topup + guest payment dispatch - Keyboard: payment method button in bot - Admin: settings category + test payment button - Infrastructure: payment_method_config_service, system_settings_service, payment_verification_service, payment_search_service - Migration 0064: create overpay_payments table Overpay API specifics: amount as string "100.00" (not kopeks), success status "charged", HPP redirect via resultUrl
This commit is contained in:
@@ -854,6 +854,35 @@ async def create_topup(
|
||||
detail='Failed to create RollyPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Overpay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_overpay_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 Overpay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
raise HTTPException(
|
||||
|
||||
@@ -629,6 +629,23 @@ class Settings(BaseSettings):
|
||||
ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook'
|
||||
ROLLYPAY_RETURN_URL: str | None = None
|
||||
|
||||
# Overpay (pay.overpay.io)
|
||||
OVERPAY_ENABLED: bool = False
|
||||
OVERPAY_API_URL: str = 'https://api.overpay.io'
|
||||
OVERPAY_USERNAME: str | None = None
|
||||
OVERPAY_PASSWORD: str | None = None
|
||||
OVERPAY_PROJECT_ID: str | None = None
|
||||
OVERPAY_P12_PATH: str | None = None
|
||||
OVERPAY_P12_PASSPHRASE: str | None = None
|
||||
OVERPAY_DISPLAY_NAME: str = 'Overpay'
|
||||
OVERPAY_CURRENCY: str = 'RUB'
|
||||
OVERPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
OVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
OVERPAY_WEBHOOK_PATH: str = '/overpay-webhook'
|
||||
OVERPAY_RETURN_URL: str | None = None
|
||||
OVERPAY_LIFETIME_MINUTES: int = 1440
|
||||
OVERPAY_PAYMENT_METHODS: str = 'card,fps'
|
||||
|
||||
# AuraPay (aurapay.tech)
|
||||
AURAPAY_ENABLED: bool = False
|
||||
AURAPAY_API_KEY: str | None = None # X-ApiKey header
|
||||
@@ -2054,6 +2071,21 @@ class Settings(BaseSettings):
|
||||
def get_rollypay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_rollypay_display_name())
|
||||
|
||||
def is_overpay_enabled(self) -> bool:
|
||||
return (
|
||||
self.OVERPAY_ENABLED
|
||||
and self.OVERPAY_USERNAME is not None
|
||||
and self.OVERPAY_PASSWORD is not None
|
||||
and self.OVERPAY_PROJECT_ID is not None
|
||||
)
|
||||
|
||||
def get_overpay_display_name(self) -> str:
|
||||
name = (self.OVERPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Overpay'
|
||||
|
||||
def get_overpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_overpay_display_name())
|
||||
|
||||
def is_aurapay_enabled(self) -> bool:
|
||||
return (
|
||||
self.AURAPAY_ENABLED
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей Overpay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import OverpayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_overpay_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,
|
||||
overpay_payment_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> OverpayPayment:
|
||||
"""Создает запись о платеже Overpay."""
|
||||
payment = OverpayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
overpay_payment_id=overpay_payment_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('Создан платеж Overpay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_overpay_payment_by_order_id(db: AsyncSession, order_id: str) -> OverpayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_overpay_id(db: AsyncSession, overpay_payment_id: str) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID от Overpay."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.overpay_payment_id == overpay_payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(OverpayPayment).where(OverpayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> OverpayPayment | None:
|
||||
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(OverpayPayment)
|
||||
.where(OverpayPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_overpay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: OverpayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
overpay_payment_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> OverpayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
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 overpay_payment_id is not None:
|
||||
payment.overpay_payment_id = overpay_payment_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(
|
||||
'Обновлен статус платежа Overpay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_overpay_payments(db: AsyncSession, user_id: int) -> list[OverpayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(OverpayPayment).where(
|
||||
OverpayPayment.user_id == user_id,
|
||||
OverpayPayment.status == 'pending',
|
||||
OverpayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_overpay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[OverpayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(OverpayPayment).where(
|
||||
OverpayPayment.status == 'pending',
|
||||
OverpayPayment.is_paid == False,
|
||||
OverpayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_overpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: OverpayPayment,
|
||||
transaction_id: int,
|
||||
) -> OverpayPayment:
|
||||
"""Связывает платеж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -164,6 +164,7 @@ class PaymentMethod(Enum):
|
||||
SEVERPAY = 'severpay'
|
||||
PAYPEAR = 'paypear'
|
||||
ROLLYPAY = 'rollypay'
|
||||
OVERPAY = 'overpay'
|
||||
AURAPAY = 'aurapay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
@@ -1005,6 +1006,68 @@ class RollyPayPayment(Base):
|
||||
return f'<RollyPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class OverpayPayment(Base):
|
||||
"""Платежи через Overpay (pay.overpay.io)."""
|
||||
|
||||
__tablename__ = 'overpay_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
|
||||
overpay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Overpay
|
||||
|
||||
# Суммы
|
||||
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='overpay_payments')
|
||||
transaction = relationship('Transaction', backref='overpay_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', 'chargeback', 'amount_mismatch']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<OverpayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class AuraPayPayment(Base):
|
||||
"""Платежи через AuraPay (aurapay.tech)."""
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'SEVERPAY',
|
||||
'PAYPEAR',
|
||||
'ROLLYPAY',
|
||||
'OVERPAY',
|
||||
'AURAPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
@@ -1269,6 +1270,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'ROLLYPAY':
|
||||
label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')])
|
||||
elif category_key == 'OVERPAY':
|
||||
label = texts.t('PAYMENT_OVERPAY', f'💳 {settings.get_overpay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'overpay')])
|
||||
elif category_key == 'AURAPAY':
|
||||
label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')])
|
||||
|
||||
@@ -163,6 +163,13 @@ async def route_payment_by_method(
|
||||
await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'overpay':
|
||||
from .overpay import process_overpay_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'aurapay':
|
||||
from .aurapay import process_aurapay_payment_amount
|
||||
|
||||
@@ -757,6 +764,10 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay')
|
||||
|
||||
from .overpay import start_overpay_topup
|
||||
|
||||
dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay')
|
||||
|
||||
from .aurapay import start_aurapay_topup
|
||||
|
||||
dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay')
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Handler for Overpay 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_overpay_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Overpay 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_overpay_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_overpay_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(
|
||||
'OVERPAY_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('Overpay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_overpay_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.OVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.OVERPAY_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_overpay_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_overpay_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start Overpay 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='overpay')
|
||||
|
||||
min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_overpay_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(
|
||||
'OVERPAY_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,
|
||||
)
|
||||
@@ -1831,6 +1831,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_overpay_enabled():
|
||||
overpay_name = settings.get_overpay_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_OVERPAY', f'💳 {overpay_name}'),
|
||||
callback_data=_build_callback('overpay'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_aurapay_enabled():
|
||||
aurapay_name = settings.get_aurapay_display_name()
|
||||
keyboard.append(
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Сервис для работы с API Overpay (pay.overpay.io)."""
|
||||
|
||||
import ssl
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
BestAvailableEncryption,
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
pkcs12,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OverpayAPIError(Exception):
|
||||
"""Ошибка API Overpay."""
|
||||
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f'Overpay API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class OverpayService:
|
||||
"""Сервис для работы с API Overpay.
|
||||
|
||||
Overpay использует HTTP Basic Auth + mTLS (P12 сертификат).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._ssl_context: ssl.SSLContext | None = None
|
||||
self._temp_cert_file: str | None = None
|
||||
self._temp_key_file: str | None = None
|
||||
|
||||
@property
|
||||
def api_url(self) -> str:
|
||||
return (settings.OVERPAY_API_URL or 'https://api.overpay.io').rstrip('/')
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return settings.OVERPAY_USERNAME or ''
|
||||
|
||||
@property
|
||||
def password(self) -> str:
|
||||
return settings.OVERPAY_PASSWORD or ''
|
||||
|
||||
@property
|
||||
def project_id(self) -> str:
|
||||
return settings.OVERPAY_PROJECT_ID or ''
|
||||
|
||||
def _build_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Создает SSL контекст с P12 сертификатом для mTLS."""
|
||||
p12_path = settings.OVERPAY_P12_PATH
|
||||
if not p12_path:
|
||||
return None
|
||||
|
||||
if self._ssl_context is not None:
|
||||
return self._ssl_context
|
||||
|
||||
try:
|
||||
passphrase = settings.OVERPAY_P12_PASSPHRASE
|
||||
passphrase_bytes = passphrase.encode('utf-8') if passphrase else None
|
||||
|
||||
with open(p12_path, 'rb') as f:
|
||||
p12_data = f.read()
|
||||
|
||||
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(p12_data, passphrase_bytes)
|
||||
|
||||
# Write PEM files to temp files for ssl.SSLContext
|
||||
cert_pem = certificate.public_bytes(Encoding.PEM)
|
||||
if additional_certs:
|
||||
for cert in additional_certs:
|
||||
cert_pem += cert.public_bytes(Encoding.PEM)
|
||||
|
||||
if passphrase_bytes:
|
||||
key_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL,
|
||||
BestAvailableEncryption(passphrase_bytes),
|
||||
)
|
||||
else:
|
||||
key_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.TraditionalOpenSSL,
|
||||
NoEncryption(),
|
||||
)
|
||||
|
||||
# Write to temp files
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as cert_file:
|
||||
cert_file.write(cert_pem)
|
||||
cert_file.flush()
|
||||
self._temp_cert_file = cert_file.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as key_file:
|
||||
key_file.write(key_pem)
|
||||
key_file.flush()
|
||||
self._temp_key_file = key_file.name
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.load_cert_chain(
|
||||
certfile=self._temp_cert_file,
|
||||
keyfile=self._temp_key_file,
|
||||
password=passphrase,
|
||||
)
|
||||
ctx.load_default_certs()
|
||||
self._ssl_context = ctx
|
||||
logger.info('Overpay: SSL контекст с P12 сертификатом создан')
|
||||
return ctx
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay: ошибка загрузки P12 сертификата', error=e)
|
||||
return None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Возвращает переиспользуемый HTTP-клиент с mTLS."""
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
return self._client
|
||||
|
||||
ssl_context = self._build_ssl_context()
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
auth=httpx.BasicAuth(self.username, self.password),
|
||||
verify=ssl_context if ssl_context else True,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Закрывает HTTP-клиент."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# Clean up temp files
|
||||
from pathlib import Path
|
||||
|
||||
for path in (self._temp_cert_file, self._temp_key_file):
|
||||
if path:
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._temp_cert_file = None
|
||||
self._temp_key_file = None
|
||||
self._ssl_context = None
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
amount: str,
|
||||
currency: str = 'RUB',
|
||||
lifetime_minutes: int = 1440,
|
||||
merchant_transaction_id: str,
|
||||
description: str = '',
|
||||
return_url: str | None = None,
|
||||
payment_methods: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает платеж через API Overpay.
|
||||
POST {API_URL}/orders/
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'livetimeMinutes': lifetime_minutes,
|
||||
'projectId': self.project_id,
|
||||
'merchantTransactionId': merchant_transaction_id,
|
||||
}
|
||||
|
||||
if description:
|
||||
payload['description'] = description
|
||||
if return_url:
|
||||
payload['returnUrl'] = return_url
|
||||
if payment_methods:
|
||||
payload['paymentMethods'] = payment_methods
|
||||
|
||||
logger.info(
|
||||
'Overpay API create_payment',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
f'{self.api_url}/orders/',
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
logger.info(
|
||||
'Overpay API payment created',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
overpay_id=data.get('id'),
|
||||
result_url=data.get('resultUrl'),
|
||||
)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay create_payment error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
response_data=data,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def get_payment(self, order_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о платеже по ID.
|
||||
GET {API_URL}/orders/{id}
|
||||
"""
|
||||
logger.info('Overpay get_payment', order_id=order_id)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(
|
||||
f'{self.api_url}/orders/{order_id}',
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay get_payment error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
async def refund_payment(self, order_id: str, amount: str) -> dict[str, Any]:
|
||||
"""
|
||||
Возврат платежа.
|
||||
PUT {API_URL}/orders/{id}/refund
|
||||
"""
|
||||
logger.info('Overpay refund_payment', order_id=order_id, amount=amount)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.put(
|
||||
f'{self.api_url}/orders/{order_id}/refund',
|
||||
json={'amount': amount},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info('Overpay refund successful', order_id=order_id, amount=amount)
|
||||
return data
|
||||
|
||||
error_msg = data.get('message') or data.get('error') or str(data)
|
||||
logger.error(
|
||||
'Overpay refund error',
|
||||
status_code=response.status_code,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
raise OverpayAPIError(response.status_code, error_msg)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception('Overpay API connection error', error=e)
|
||||
raise
|
||||
|
||||
|
||||
# Singleton instance
|
||||
overpay_service = OverpayService()
|
||||
@@ -12,6 +12,7 @@ from .freekassa import FreekassaPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .kassa_ai import KassaAiPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .overpay import OverpayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .paypear import PayPearPaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
@@ -32,6 +33,7 @@ __all__ = [
|
||||
'HeleketPaymentMixin',
|
||||
'KassaAiPaymentMixin',
|
||||
'MulenPayPaymentMixin',
|
||||
'OverpayPaymentMixin',
|
||||
'Pal24PaymentMixin',
|
||||
'PayPearPaymentMixin',
|
||||
'PaymentCommonMixin',
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Mixin для интеграции с Overpay (pay.overpay.io)."""
|
||||
|
||||
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.overpay_service import overpay_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг статусов Overpay -> internal
|
||||
OVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'charged': ('success', True),
|
||||
'authorized': ('authorized', False),
|
||||
'preflight': ('pending', False),
|
||||
'new': ('pending', False),
|
||||
'processing': ('processing', False),
|
||||
'prepared': ('processing', False),
|
||||
'rejected': ('rejected', False),
|
||||
'declined': ('declined', False),
|
||||
'reversed': ('reversed', False),
|
||||
'refunded': ('refunded', False),
|
||||
'chargeback': ('chargeback', False),
|
||||
'error': ('error', False),
|
||||
}
|
||||
|
||||
|
||||
class OverpayPaymentMixin:
|
||||
"""Mixin для работы с платежами Overpay."""
|
||||
|
||||
async def create_overpay_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:
|
||||
"""
|
||||
Создает платеж Overpay.
|
||||
|
||||
Returns:
|
||||
Словарь с данными платежа или None при ошибке
|
||||
"""
|
||||
if not settings.is_overpay_enabled():
|
||||
logger.error('Overpay не настроен')
|
||||
return None
|
||||
|
||||
# Валидация лимитов
|
||||
if amount_kopeks < settings.OVERPAY_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Overpay: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
OVERPAY_MIN_AMOUNT_KOPEKS=settings.OVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.OVERPAY_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Overpay: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
OVERPAY_MAX_AMOUNT_KOPEKS=settings.OVERPAY_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'op{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
amount_value = f'{amount_rubles:.2f}'
|
||||
currency = settings.OVERPAY_CURRENCY
|
||||
|
||||
# Метаданные
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
}
|
||||
|
||||
# Методы оплаты из настроек
|
||||
payment_methods_str = settings.OVERPAY_PAYMENT_METHODS
|
||||
payment_methods = (
|
||||
[m.strip() for m in payment_methods_str.split(',') if m.strip()] if payment_methods_str else None
|
||||
)
|
||||
|
||||
try:
|
||||
# Используем API для создания платежа
|
||||
result = await overpay_service.create_payment(
|
||||
amount=amount_value,
|
||||
currency=currency,
|
||||
lifetime_minutes=settings.OVERPAY_LIFETIME_MINUTES,
|
||||
merchant_transaction_id=order_id,
|
||||
description=description,
|
||||
return_url=return_url or settings.OVERPAY_RETURN_URL,
|
||||
payment_methods=payment_methods,
|
||||
)
|
||||
|
||||
payment_url = result.get('resultUrl')
|
||||
overpay_payment_id = str(result.get('id', '')) if result.get('id') else None
|
||||
|
||||
if not payment_url:
|
||||
logger.error('Overpay API не вернул URL платежа', result=result)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Overpay API: создан платеж',
|
||||
order_id=order_id,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
# Срок действия
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=settings.OVERPAY_LIFETIME_MINUTES)
|
||||
|
||||
# Сохраняем в БД
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
local_payment = await overpay_crud.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Overpay: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'overpay_payment_id': overpay_payment_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('Overpay: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
async def process_overpay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Обрабатывает webhook от Overpay.
|
||||
|
||||
mTLS обеспечивает аутентификацию; дополнительно проверяем наличие платежа в БД.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
payload: JSON тело webhook
|
||||
|
||||
Returns:
|
||||
True если платеж успешно обработан
|
||||
"""
|
||||
try:
|
||||
overpay_payment_id = str(payload.get('id', '')) if payload.get('id') else None
|
||||
merchant_transaction_id = payload.get('merchantTransactionId')
|
||||
overpay_status = payload.get('status')
|
||||
|
||||
if not overpay_payment_id or not overpay_status:
|
||||
logger.warning('Overpay webhook: отсутствуют обязательные поля', payload=payload)
|
||||
return False
|
||||
|
||||
# Ищем платеж по order_id (наш merchantTransactionId) или overpay_payment_id
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
payment = None
|
||||
if merchant_transaction_id:
|
||||
payment = await overpay_crud.get_overpay_payment_by_order_id(db, merchant_transaction_id)
|
||||
if not payment and overpay_payment_id:
|
||||
payment = await overpay_crud.get_overpay_payment_by_overpay_id(db, overpay_payment_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
'Overpay webhook: платеж не найден',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
# Проверка дублирования (re-check from locked row)
|
||||
if payment.is_paid:
|
||||
logger.info('Overpay webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Маппинг статуса
|
||||
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
callback_payload = {
|
||||
'overpay_payment_id': overpay_payment_id,
|
||||
'merchant_transaction_id': merchant_transaction_id,
|
||||
'status': overpay_status,
|
||||
}
|
||||
|
||||
# Финализируем платеж если оплачен — без промежуточного 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.overpay_payment_id = overpay_payment_id or payment.overpay_payment_id
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_overpay_payment(
|
||||
db, payment, overpay_payment_id=overpay_payment_id, trigger='webhook'
|
||||
)
|
||||
|
||||
# Для не-success статусов можно безопасно коммитить
|
||||
payment = await overpay_crud.update_overpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
overpay_payment_id=overpay_payment_id,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Overpay webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_overpay_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
overpay_payment_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')
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
|
||||
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Overpay платеж уже связан с транзакцией',
|
||||
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(overpay_payment_id) if overpay_payment_id else payment.order_id,
|
||||
provider_name='overpay',
|
||||
)
|
||||
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('Пользователь не найден для Overpay', 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(overpay_payment_id) if overpay_payment_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.OVERPAY,
|
||||
)
|
||||
|
||||
display_name = settings.get_overpay_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.OVERPAY,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await overpay_crud.link_overpay_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('Overpay платеж уже зачислил баланс ранее', 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.OVERPAY,
|
||||
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('Ошибка обработки реферального пополнения Overpay', 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('Ошибка отправки админ уведомления Overpay', 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('Ошибка отправки уведомления пользователю Overpay', 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(
|
||||
'Обработан Overpay платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_overpay_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Проверяет статус платежа через API."""
|
||||
try:
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
payment = await overpay_crud.get_overpay_payment_by_order_id(db, order_id)
|
||||
if not payment:
|
||||
logger.warning('Overpay payment not found', order_id=order_id)
|
||||
return None
|
||||
|
||||
if payment.is_paid:
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
# Проверяем через API по overpay_payment_id
|
||||
if payment.overpay_payment_id:
|
||||
try:
|
||||
order_data = await overpay_service.get_payment(payment.overpay_payment_id)
|
||||
overpay_status = order_data.get('status')
|
||||
|
||||
if overpay_status:
|
||||
status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False))
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Acquire FOR UPDATE lock before finalization
|
||||
locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return None
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Overpay платеж уже обработан (api_check)', order_id=payment.order_id)
|
||||
return {
|
||||
'payment': payment,
|
||||
'status': 'success',
|
||||
'is_paid': True,
|
||||
}
|
||||
|
||||
logger.info('Overpay 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',
|
||||
'overpay_order_data': order_data,
|
||||
}
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
await self._finalize_overpay_payment(
|
||||
db,
|
||||
payment,
|
||||
overpay_payment_id=payment.overpay_payment_id,
|
||||
trigger='api_check',
|
||||
)
|
||||
elif internal_status != payment.status:
|
||||
# Обновляем статус если изменился
|
||||
payment = await overpay_crud.update_overpay_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error checking Overpay 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('Overpay: ошибка проверки статуса', error=e)
|
||||
return None
|
||||
@@ -169,6 +169,16 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'crypto', 'name': 'Криптовалюта'},
|
||||
],
|
||||
},
|
||||
'overpay': {
|
||||
'default_display_name': settings.get_overpay_display_name(),
|
||||
'is_configured': settings.is_overpay_enabled(),
|
||||
'default_min': settings.OVERPAY_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.OVERPAY_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'fps', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'aurapay': {
|
||||
'default_display_name': settings.get_aurapay_display_name(),
|
||||
'is_configured': settings.is_aurapay_enabled(),
|
||||
@@ -223,6 +233,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'severpay',
|
||||
'paypear',
|
||||
'rollypay',
|
||||
'overpay',
|
||||
'aurapay',
|
||||
]
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database.models import (
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethod,
|
||||
PlategaPayment,
|
||||
@@ -649,6 +650,39 @@ async def _search_severpay(db: AsyncSession, params: SearchParams) -> list[Pendi
|
||||
return records
|
||||
|
||||
|
||||
async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = select(OverpayPayment).options(selectinload(OverpayPayment.user)).order_by(desc(OverpayPayment.created_at))
|
||||
stmt = _apply_date_filter(stmt, OverpayPayment.created_at, params.cutoff, params.upper_bound)
|
||||
|
||||
if params.search:
|
||||
kind = _detect_user_search_kind(params.search)
|
||||
if kind == _UserSearchKind.INVOICE:
|
||||
conditions = [
|
||||
OverpayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
OverpayPayment.overpay_payment_id.ilike(f'%{_escape_like(params.search)}%'),
|
||||
]
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
else:
|
||||
stmt = _apply_user_join_filter(stmt, OverpayPayment, kind, params.search)
|
||||
|
||||
stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER)
|
||||
result = await db.execute(stmt)
|
||||
records: list[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
record = _build_record(
|
||||
PaymentMethod.OVERPAY,
|
||||
payment,
|
||||
identifier=payment.order_id,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]:
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
@@ -702,6 +736,7 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = {
|
||||
PaymentMethod.KASSA_AI: _search_kassa_ai,
|
||||
PaymentMethod.RIOPAY: _search_riopay,
|
||||
PaymentMethod.SEVERPAY: _search_severpay,
|
||||
PaymentMethod.OVERPAY: _search_overpay,
|
||||
PaymentMethod.TELEGRAM_STARS: _search_stars,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||
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.overpay import OverpayPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
from app.services.payment.rollypay import RollyPayPaymentMixin
|
||||
@@ -408,6 +409,44 @@ async def link_rollypay_payment_to_transaction(*args, **kwargs):
|
||||
return await rollypay_crud.link_rollypay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# --- Overpay CRUD wrappers ---
|
||||
|
||||
|
||||
async def create_overpay_payment(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.create_overpay_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_order_id(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_overpay_id(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_overpay_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_overpay_payment_by_id_for_update(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.get_overpay_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_overpay_payment_status(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.update_overpay_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_overpay_payment_to_transaction(*args, **kwargs):
|
||||
overpay_crud = import_module('app.database.crud.overpay')
|
||||
return await overpay_crud.link_overpay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_aurapay_payment(*args, **kwargs):
|
||||
aurapay_crud = import_module('app.database.crud.aurapay')
|
||||
return await aurapay_crud.create_aurapay_payment(*args, **kwargs)
|
||||
@@ -468,6 +507,7 @@ class PaymentService(
|
||||
SeverPayPaymentMixin,
|
||||
PayPearPaymentMixin,
|
||||
RollyPayPaymentMixin,
|
||||
OverpayPaymentMixin,
|
||||
AuraPayPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
@@ -932,6 +972,28 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Overpay ----------------------------------------------------------
|
||||
if payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
logger.warning('Overpay is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_overpay_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'], 'overpay')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('overpay_payment_id') or result.get('order_id'),
|
||||
'provider': 'overpay',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- AuraPay ----------------------------------------------------------
|
||||
if payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
|
||||
@@ -76,6 +76,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -96,6 +97,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
PaymentMethod.SEVERPAY,
|
||||
PaymentMethod.OVERPAY,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -125,6 +127,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return settings.get_riopay_display_name()
|
||||
if method == PaymentMethod.SEVERPAY:
|
||||
return settings.get_severpay_display_name()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.get_overpay_display_name()
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
return method.value
|
||||
@@ -155,6 +159,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_riopay_enabled()
|
||||
if method == PaymentMethod.SEVERPAY:
|
||||
return settings.is_severpay_enabled()
|
||||
if method == PaymentMethod.OVERPAY:
|
||||
return settings.is_overpay_enabled()
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ class BotConfigurationService:
|
||||
'SEVERPAY': '💳 SeverPay',
|
||||
'PAYPEAR': '💳 PayPear',
|
||||
'ROLLYPAY': '💳 RollyPay',
|
||||
'OVERPAY': '💳 Overpay',
|
||||
'AURAPAY': '💳 AuraPay',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
@@ -157,6 +158,7 @@ class BotConfigurationService:
|
||||
'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.',
|
||||
'PAYPEAR': 'PayPear: платёжная система api.paypear.ru с поддержкой карт, СБП, SberPay и T-Pay.',
|
||||
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
|
||||
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
|
||||
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
@@ -371,6 +373,7 @@ class BotConfigurationService:
|
||||
'SEVERPAY_': 'SEVERPAY',
|
||||
'PAYPEAR_': 'PAYPEAR',
|
||||
'ROLLYPAY_': 'ROLLYPAY',
|
||||
'OVERPAY_': 'OVERPAY',
|
||||
'AURAPAY_': 'AURAPAY',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
|
||||
@@ -1340,6 +1340,81 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Overpay webhook
|
||||
if settings.is_overpay_enabled():
|
||||
|
||||
@router.get(settings.OVERPAY_WEBHOOK_PATH)
|
||||
async def overpay_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'overpay_webhook',
|
||||
'enabled': settings.is_overpay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.OVERPAY_WEBHOOK_PATH)
|
||||
async def overpay_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Overpay webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Overpay uses mTLS for authentication — verify payment exists in DB
|
||||
merchant_transaction_id = payload.get('merchantTransactionId')
|
||||
if not merchant_transaction_id:
|
||||
logger.warning('Overpay webhook: missing merchantTransactionId')
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate that the payment exists in our DB (basic anti-spoofing)
|
||||
from app.database.crud.overpay import get_overpay_payment_by_order_id
|
||||
|
||||
db_generator = get_db()
|
||||
try:
|
||||
check_db = await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
try:
|
||||
existing = await get_overpay_payment_by_order_id(check_db, merchant_transaction_id)
|
||||
if not existing:
|
||||
overpay_id = payload.get('id')
|
||||
if overpay_id:
|
||||
from app.database.crud.overpay import get_overpay_payment_by_overpay_id
|
||||
|
||||
existing = await get_overpay_payment_by_overpay_id(check_db, str(overpay_id))
|
||||
if not existing:
|
||||
logger.warning(
|
||||
'Overpay webhook: payment not found in DB',
|
||||
merchant_transaction_id=merchant_transaction_id,
|
||||
)
|
||||
return JSONResponse({'status': False}, status_code=status.HTTP_404_NOT_FOUND)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_overpay_webhook',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Overpay webhook processing failed',
|
||||
data=payload.get('id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Overpay webhook processing error', error=e)
|
||||
# Always return 200 — Overpay expects HTTP 200
|
||||
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# AuraPay webhook
|
||||
if settings.is_aurapay_enabled():
|
||||
|
||||
@@ -1411,6 +1486,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'severpay_enabled': settings.is_severpay_enabled(),
|
||||
'paypear_enabled': settings.is_paypear_enabled(),
|
||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||
'overpay_enabled': settings.is_overpay_enabled(),
|
||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""create overpay_payments table
|
||||
|
||||
Revision ID: 0064
|
||||
Revises: 0063
|
||||
Create Date: 2026-04-21
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0064'
|
||||
down_revision: Union[str, None] = '0063'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'overpay_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('overpay_payment_id', sa.String(128), 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('overpay_payments')
|
||||
Reference in New Issue
Block a user