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:
Fringg
2026-04-23 04:36:56 +03:00
parent 6f87563789
commit 2c3ffc8c8a
19 changed files with 1654 additions and 1 deletions
+29
View File
@@ -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(
+32
View File
@@ -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
+157
View File
@@ -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
+63
View File
@@ -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)."""
+4
View File
@@ -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')])
+11
View File
@@ -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')
+247
View File
@@ -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,
)
+12
View File
@@ -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(
+291
View File
@@ -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()
+2
View File
@@ -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',
+567
View File
@@ -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',
]
+35
View File
@@ -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,
}
+62
View File
@@ -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
+3
View File
@@ -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',
+76
View File
@@ -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')
Generated
+1 -1
View File
@@ -1142,7 +1142,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.46.1"
version = "3.49.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },