feat: integrate Etoplatezhi payment provider

- Add etoplatezhi_service.py with HMAC-SHA512+base64 signature algorithm,
  payment URL builder, and callback signature verification
- Add payment mixin with create/process/finalize flow, 12 status mappings
- Add CRUD operations with FOR UPDATE locking, idempotency checks
- Add Telegram handlers with SBP/Card sub-method selection
- Add Alembic migration for etoplatezhi_payments table
- Add config settings (ETOPLATEZHI_ENABLED, PROJECT_ID, SECRET_KEY,
  SBP_ENABLED, CARD_ENABLED, display names, min/max amounts)
- Add webhook endpoint with JSON-body signature verification
- Register in payment keyboard, router, utils, backup, method config
This commit is contained in:
Fringg
2026-05-04 07:17:54 +03:00
parent 17ac3da3c4
commit 6524f66da2
15 changed files with 1565 additions and 0 deletions
+50
View File
@@ -686,6 +686,22 @@ class Settings(BaseSettings):
AURAPAY_CARD_ENABLED: bool = False
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
# Etoplatezhi (paymentpage.etoplatezhi.ru)
ETOPLATEZHI_ENABLED: bool = False
ETOPLATEZHI_PROJECT_ID: int | None = None
ETOPLATEZHI_SECRET_KEY: str | None = None
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
ETOPLATEZHI_CURRENCY: str = 'RUB'
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
ETOPLATEZHI_RETURN_URL: str | None = None
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
ETOPLATEZHI_SBP_ENABLED: bool = False
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
ETOPLATEZHI_CARD_ENABLED: bool = False
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
@@ -2142,6 +2158,40 @@ class Settings(BaseSettings):
def get_aurapay_card_display_name_html(self) -> str:
return html.escape(self.get_aurapay_card_display_name())
def is_etoplatezhi_enabled(self) -> bool:
return (
self.ETOPLATEZHI_ENABLED
and self.ETOPLATEZHI_PROJECT_ID is not None
and self.ETOPLATEZHI_SECRET_KEY is not None
)
def get_etoplatezhi_display_name(self) -> str:
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
return name if name else 'Etoplatezhi'
def get_etoplatezhi_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_display_name())
def is_etoplatezhi_sbp_enabled(self) -> bool:
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
def get_etoplatezhi_sbp_display_name(self) -> str:
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Etoplatezhi)'
def get_etoplatezhi_sbp_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_sbp_display_name())
def is_etoplatezhi_card_enabled(self) -> bool:
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
def get_etoplatezhi_card_display_name(self) -> str:
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Etoplatezhi)'
def get_etoplatezhi_card_display_name_html(self) -> str:
return html.escape(self.get_etoplatezhi_card_display_name())
def is_kassa_ai_sbp_enabled(self) -> bool:
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
+161
View File
@@ -0,0 +1,161 @@
"""CRUD операции для платежей Etoplatezhi."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import EtoplatezhiPayment
logger = structlog.get_logger(__name__)
async def create_etoplatezhi_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,
etoplatezhi_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> EtoplatezhiPayment:
"""Создает запись о платеже Etoplatezhi."""
payment = EtoplatezhiPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
etoplatezhi_payment_id=etoplatezhi_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('Создан платеж Etoplatezhi', order_id=order_id, user_id=user_id)
return payment
async def get_etoplatezhi_payment_by_order_id(db: AsyncSession, order_id: str) -> EtoplatezhiPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_etoplatezhi_payment_by_invoice_id(
db: AsyncSession, etoplatezhi_payment_id: str
) -> EtoplatezhiPayment | None:
"""Получает платеж по ID от Etoplatezhi."""
result = await db.execute(
select(EtoplatezhiPayment).where(EtoplatezhiPayment.etoplatezhi_payment_id == etoplatezhi_payment_id)
)
return result.scalar_one_or_none()
async def get_etoplatezhi_payment_by_id(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_etoplatezhi_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(EtoplatezhiPayment)
.where(EtoplatezhiPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_etoplatezhi_payment_status(
db: AsyncSession,
payment: EtoplatezhiPayment,
*,
status: str,
is_paid: bool | None = None,
etoplatezhi_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> EtoplatezhiPayment:
"""Обновляет статус платежа."""
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 etoplatezhi_payment_id is not None:
payment.etoplatezhi_payment_id = etoplatezhi_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(
'Обновлен статус платежа Etoplatezhi',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_etoplatezhi_payments(db: AsyncSession, user_id: int) -> list[EtoplatezhiPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(EtoplatezhiPayment).where(
EtoplatezhiPayment.user_id == user_id,
EtoplatezhiPayment.status == 'pending',
EtoplatezhiPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_etoplatezhi_payments(
db: AsyncSession,
) -> list[EtoplatezhiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(EtoplatezhiPayment).where(
EtoplatezhiPayment.status == 'pending',
EtoplatezhiPayment.is_paid == False,
EtoplatezhiPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_etoplatezhi_payment_to_transaction(
db: AsyncSession,
*,
payment: EtoplatezhiPayment,
transaction_id: int,
) -> EtoplatezhiPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+1
View File
@@ -31,6 +31,7 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.PAYPEAR.value,
PaymentMethod.OVERPAY.value,
PaymentMethod.AURAPAY.value,
PaymentMethod.ETOPLATEZHI.value,
]
+63
View File
@@ -167,6 +167,7 @@ class PaymentMethod(Enum):
ROLLYPAY = 'rollypay'
OVERPAY = 'overpay'
AURAPAY = 'aurapay'
ETOPLATEZHI = 'etoplatezhi'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -1166,6 +1167,68 @@ class AuraPayPayment(Base):
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class EtoplatezhiPayment(Base):
"""Платежи через Etoplatezhi (paymentpage.etoplatezhi.ru)."""
__tablename__ = 'etoplatezhi_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
etoplatezhi_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Etoplatezhi
# Суммы
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='etoplatezhi_payments')
transaction = relationship('Transaction', backref='etoplatezhi_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+302
View File
@@ -0,0 +1,302 @@
"""Handler for Etoplatezhi 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_etoplatezhi_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""
Common logic for creating Etoplatezhi 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_etoplatezhi_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
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_etoplatezhi_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(
'ETOPLATEZHI_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('Etoplatezhi payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_etoplatezhi_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.ETOPLATEZHI_MIN_AMOUNT_KOPEKS
max_amount = settings.ETOPLATEZHI_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
data = await state.get_data()
payment_method = data.get('payment_method', 'etoplatezhi')
# etoplatezhi_sbp → 'sbp', etoplatezhi_card → 'card', etoplatezhi → None
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_etoplatezhi_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
ETOPLATEZHI_PAYMENT_METHODS = {'etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'}
ETOPLATEZHI_SERVICE_MAP: dict[str, str | None] = {
'etoplatezhi': None,
'etoplatezhi_sbp': 'sbp',
'etoplatezhi_card': 'card',
}
def _extract_service_type(payment_method: str) -> str | None:
return ETOPLATEZHI_SERVICE_MAP.get(payment_method)
async def _start_etoplatezhi_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Common logic for starting Etoplatezhi top-up (generic / SBP / card)."""
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=payment_method)
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS // 100
# Choose display name based on sub-method
if payment_method == 'etoplatezhi_sbp':
display_name = settings.get_etoplatezhi_sbp_display_name()
elif payment_method == 'etoplatezhi_card':
display_name = settings.get_etoplatezhi_card_display_name()
else:
display_name = settings.get_etoplatezhi_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(
'ETOPLATEZHI_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,
)
@error_handler
async def start_etoplatezhi_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi')
@error_handler
async def start_etoplatezhi_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_sbp')
@error_handler
async def start_etoplatezhi_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_card')
+13
View File
@@ -177,6 +177,13 @@ async def route_payment_by_method(
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
from .etoplatezhi import process_etoplatezhi_payment_amount
async with AsyncSessionLocal() as db:
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -774,6 +781,12 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+40
View File
@@ -1890,6 +1890,46 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_etoplatezhi_sbp_enabled():
sbp_name = settings.get_etoplatezhi_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('etoplatezhi_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_etoplatezhi_card_enabled():
card_name = settings.get_etoplatezhi_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
callback_data=_build_callback('etoplatezhi_card'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_etoplatezhi_enabled()
and not settings.is_etoplatezhi_sbp_enabled()
and not settings.is_etoplatezhi_card_enabled()
):
etoplatezhi_name = settings.get_etoplatezhi_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
callback_data=_build_callback('etoplatezhi'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+1
View File
@@ -1517,6 +1517,7 @@ class BackupService:
'rollypay_payments',
'overpay_payments',
'aurapay_payments',
'etoplatezhi_payments',
'apple_transactions',
'saved_payment_methods',
# --- Content/config ---
+202
View File
@@ -0,0 +1,202 @@
"""Сервис для работы с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
import base64
import hashlib
import hmac
from typing import Any
from urllib.parse import urlencode
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
PAYMENT_PAGE_BASE_URL = 'https://paymentpage.etoplatezhi.ru/payment'
class EtoplatezhiService:
"""Сервис для построения URL платежей и верификации callback-ов Etoplatezhi."""
@property
def project_id(self) -> int:
return settings.ETOPLATEZHI_PROJECT_ID or 0
@property
def secret_key(self) -> str:
return settings.ETOPLATEZHI_SECRET_KEY or ''
def _flatten_params(
self,
params: dict[str, Any],
prefix: str = '',
ignore: set[str] | None = None,
) -> list[str]:
"""Рекурсивно «сплющивает» вложенные словари в список 'key:value' строк.
Keys разделяются двоеточием. ``frame_mode`` и ``signature`` игнорируются.
Booleans приводятся к '1'/'0'.
Empty arrays (lists) are excluded entirely per Etoplatezhi spec.
"""
if ignore is None:
ignore = {'frame_mode', 'signature'}
entries: list[str] = []
for key, value in params.items():
full_key = f'{prefix}:{key}' if prefix else key
if full_key in ignore or key in ignore:
continue
if isinstance(value, dict):
entries.extend(self._flatten_params(value, prefix=full_key, ignore=ignore))
elif isinstance(value, list):
# Empty arrays are excluded entirely per spec
if not value:
continue
# Non-empty arrays: flatten each element with index as key
for idx, item in enumerate(value):
item_key = f'{full_key}:{idx}'
if isinstance(item, dict):
entries.extend(self._flatten_params(item, prefix=item_key, ignore=ignore))
elif isinstance(item, bool):
entries.append(f'{item_key}:{"1" if item else "0"}')
elif item is not None:
entries.append(f'{item_key}:{item}')
elif isinstance(value, bool):
entries.append(f'{full_key}:{"1" if value else "0"}')
elif value is not None:
entries.append(f'{full_key}:{value}')
return entries
def _sign(self, params: dict[str, Any]) -> str:
"""HMAC-SHA512 + base64 подпись параметров.
Algorithm:
1. Flatten nested dicts with ':' separator.
2. Each leaf "key:value".
3. Sort alphabetically by full key string.
4. Join with ';'.
5. HMAC-SHA512 with secret_key.
6. base64-encode the raw digest.
"""
entries = self._flatten_params(params)
entries.sort()
message = ';'.join(entries)
digest = hmac.new(
self.secret_key.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha512,
).digest()
return base64.b64encode(digest).decode('utf-8')
def build_payment_url(
self,
*,
project_id: int,
payment_id: str,
payment_amount: int,
payment_currency: str = 'RUB',
customer_id: str,
description: str | None = None,
callback_url: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
force_payment_method: str | None = None,
customer_email: str | None = None,
language_code: str | None = None,
) -> str:
"""Строит URL для редиректа на платёжную страницу Etoplatezhi.
Args:
project_id: ID проекта в Etoplatezhi.
payment_id: Наш internal order_id.
payment_amount: Сумма в минорных единицах (копейках).
payment_currency: ISO 4217 код валюты.
customer_id: Telegram ID или guest-идентификатор покупателя.
description: Описание платежа.
callback_url: URL для callback (POST JSON).
success_url: URL редиректа при успехе.
fail_url: URL редиректа при ошибке.
force_payment_method: 'sbp' или 'card' для принудительного выбора.
customer_email: Email покупателя.
language_code: Язык интерфейса ('ru', 'en').
Returns:
Полный URL с параметрами и подписью.
"""
params: dict[str, Any] = {
'project_id': project_id,
'payment_id': payment_id,
'payment_amount': payment_amount,
'payment_currency': payment_currency,
'customer_id': customer_id,
}
if description:
params['payment_description'] = description
if callback_url:
params['merchant_callback_url'] = callback_url
if success_url:
params['redirect_success_url'] = success_url
if fail_url:
params['redirect_fail_url'] = fail_url
if force_payment_method:
params['force_payment_method'] = force_payment_method
if customer_email:
params['customer_email'] = customer_email
if language_code:
params['language_code'] = language_code
params['signature'] = self._sign(params)
logger.info(
'Etoplatezhi: building payment URL',
payment_id=payment_id,
payment_amount=payment_amount,
customer_id=customer_id,
)
return f'{PAYMENT_PAGE_BASE_URL}?{urlencode(params)}'
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
"""Верифицирует подпись в callback-е Etoplatezhi.
Подпись находится внутри JSON body (поле ``signature``).
Для проверки: удаляем ``signature`` из всех уровней вложенности,
вычисляем подпись по оставшимся данным и сравниваем.
"""
try:
received_signature = payload.get('signature')
if not received_signature:
logger.warning('Etoplatezhi callback: отсутствует signature в payload')
return False
# Deep-copy payload and strip all 'signature' keys recursively
cleaned = self._strip_signature_keys(payload)
expected = self._sign(cleaned)
return hmac.compare_digest(expected, str(received_signature))
except Exception as e:
logger.error('Etoplatezhi callback verify error', error=e)
return False
def _strip_signature_keys(self, data: dict[str, Any]) -> dict[str, Any]:
"""Рекурсивно удаляет ключ ``signature`` из словаря и вложенных словарей."""
result: dict[str, Any] = {}
for key, value in data.items():
if key == 'signature':
continue
if isinstance(value, dict):
result[key] = self._strip_signature_keys(value)
else:
result[key] = value
return result
# Singleton instance
etoplatezhi_service = EtoplatezhiService()
+518
View File
@@ -0,0 +1,518 @@
"""Mixin для интеграции с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.etoplatezhi_service import etoplatezhi_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Etoplatezhi -> internal
ETOPLATEZHI_STATUS_MAP: dict[str, tuple[str, bool]] = {
'success': ('success', True),
'decline': ('declined', False),
'error': ('error', False),
'processing': ('pending', False),
'awaiting 3ds result': ('pending', False),
'awaiting redirect result': ('pending', False),
'awaiting clarification': ('pending', False),
'awaiting customer action': ('pending', False),
'cancelled': ('cancelled', False),
'refunded': ('refunded', False),
'partially refunded': ('partially_refunded', False),
'reversed': ('reversed', False),
}
class EtoplatezhiPaymentMixin:
"""Mixin для работы с платежами Etoplatezhi."""
async def create_etoplatezhi_payment(
self,
db: AsyncSession,
*,
user_id: int | None,
amount_kopeks: int,
description: str = 'Пополнение баланса',
email: str | None = None,
language: str = 'ru',
payment_method_type: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""
Создает платеж Etoplatezhi.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_etoplatezhi_enabled():
logger.error('Etoplatezhi не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS:
logger.warning(
'Etoplatezhi: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
ETOPLATEZHI_MIN_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS:
logger.warning(
'Etoplatezhi: сумма больше максимальной',
amount_kopeks=amount_kopeks,
ETOPLATEZHI_MAX_AMOUNT_KOPEKS=settings.ETOPLATEZHI_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'etp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.ETOPLATEZHI_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Формируем webhook URL
webhook_url = None
if settings.WEBHOOK_URL:
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.ETOPLATEZHI_WEBHOOK_PATH}'
lifetime = settings.ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES
# Определяем force_payment_method по типу подметода
force_method = None
if payment_method_type == 'sbp':
force_method = 'sbp'
elif payment_method_type == 'card':
force_method = 'card'
# Строим URL для редиректа на платёжную страницу
payment_url = etoplatezhi_service.build_payment_url(
project_id=settings.ETOPLATEZHI_PROJECT_ID or 0,
payment_id=order_id,
payment_amount=amount_kopeks,
payment_currency=currency,
customer_id=str(tg_id),
description=description,
callback_url=webhook_url,
success_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
fail_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
force_payment_method=force_method,
customer_email=email,
language_code=language,
)
logger.info(
'Etoplatezhi: сформирован URL платежа',
order_id=order_id,
payment_url=payment_url,
)
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Сохраняем в БД
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
local_payment = await etoplatezhi_crud.create_etoplatezhi_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method_type,
etoplatezhi_payment_id=None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Etoplatezhi: создан платеж',
order_id=order_id,
user_id=user_id,
amount_rubles=amount_rubles,
currency=currency,
)
return {
'order_id': order_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('Etoplatezhi: ошибка создания платежа', error=e)
return None
async def process_etoplatezhi_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает callback от Etoplatezhi.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело callback (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
# Etoplatezhi callback structure:
# {project_id, payment: {id, status, sum: {amount, currency}}, customer: {id}, signature}
payment_data = payload.get('payment', {})
etoplatezhi_payment_id = payment_data.get('id')
etoplatezhi_status = payment_data.get('status')
# payment.id в callback — это наш payment_id (order_id)
our_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
if not our_payment_id or not etoplatezhi_status:
logger.warning('Etoplatezhi callback: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по статусу
is_confirmed = etoplatezhi_status == 'success'
# Ищем платеж по order_id (наш payment_id = order_id)
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
payment = await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(db, our_payment_id)
if not payment:
logger.warning(
'Etoplatezhi callback: платеж не найден',
payment_id=our_payment_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Etoplatezhi: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('Etoplatezhi callback: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = ETOPLATEZHI_STATUS_MAP.get(etoplatezhi_status, ('pending', False))
internal_status, is_paid = status_info
# Если статус success, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
# Извлекаем сумму из callback: payment.sum.amount (в минорных единицах)
sum_data = payment_data.get('sum', {})
callback_payload = {
'etoplatezhi_payment_id': etoplatezhi_payment_id,
'status': etoplatezhi_status,
'sum': sum_data,
'customer': payload.get('customer'),
'project_id': payload.get('project_id'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
amount_value = sum_data.get('amount')
if amount_value is not None:
# amount в минорных единицах (копейках)
received_kopeks = int(amount_value)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Etoplatezhi amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await etoplatezhi_crud.update_etoplatezhi_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.etoplatezhi_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_etoplatezhi_payment(db, payment, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await etoplatezhi_crud.update_etoplatezhi_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Etoplatezhi callback: ошибка обработки', error=e)
return False
async def _finalize_etoplatezhi_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Etoplatezhi платеж уже связан с транзакцией',
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=payment.order_id,
provider_name='etoplatezhi',
)
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('Пользователь не найден для Etoplatezhi', 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 = 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.ETOPLATEZHI,
)
display_name = settings.get_etoplatezhi_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.ETOPLATEZHI,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await etoplatezhi_crud.link_etoplatezhi_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('Etoplatezhi платеж уже зачислил баланс ранее', 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.ETOPLATEZHI,
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('Ошибка обработки реферального пополнения Etoplatezhi', 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('Ошибка отправки админ уведомления Etoplatezhi', 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('Ошибка отправки уведомления пользователю Etoplatezhi', 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(
'Обработан Etoplatezhi платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
@@ -189,6 +189,16 @@ def _get_method_defaults() -> dict:
{'id': 'sbp', 'name': 'СБП'},
],
},
'etoplatezhi': {
'default_display_name': settings.get_etoplatezhi_display_name(),
'is_configured': settings.is_etoplatezhi_enabled(),
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
],
},
}
@@ -235,6 +245,7 @@ DEFAULT_METHOD_ORDER = [
'rollypay',
'overpay',
'aurapay',
'etoplatezhi',
]
+59
View File
@@ -32,6 +32,7 @@ from app.services.payment import (
)
from app.services.payment.aurapay import AuraPayPaymentMixin
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.overpay import OverpayPaymentMixin
@@ -482,6 +483,41 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
async def create_etoplatezhi_payment(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
async def update_etoplatezhi_payment_status(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = {
@@ -509,6 +545,7 @@ class PaymentService(
RollyPayPaymentMixin,
OverpayPaymentMixin,
AuraPayPaymentMixin,
EtoplatezhiPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -1016,6 +1053,28 @@ class PaymentService(
}
return None
# --- Etoplatezhi ------------------------------------------------------
if payment_method == 'etoplatezhi':
if not settings.is_etoplatezhi_enabled():
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
return None
result = await self.create_etoplatezhi_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'], 'etoplatezhi')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'etoplatezhi',
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
+51
View File
@@ -272,6 +272,46 @@ def get_available_payment_methods() -> list[dict[str, str]]:
}
)
if settings.is_etoplatezhi_sbp_enabled():
sbp_name = settings.get_etoplatezhi_sbp_display_name()
methods.append(
{
'id': 'etoplatezhi_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_etoplatezhi_sbp',
}
)
if settings.is_etoplatezhi_card_enabled():
card_name = settings.get_etoplatezhi_card_display_name()
methods.append(
{
'id': 'etoplatezhi_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_etoplatezhi_card',
}
)
if (
settings.is_etoplatezhi_enabled()
and not settings.is_etoplatezhi_sbp_enabled()
and not settings.is_etoplatezhi_card_enabled()
):
etoplatezhi_name = settings.get_etoplatezhi_display_name()
methods.append(
{
'id': 'etoplatezhi',
'name': etoplatezhi_name,
'icon': '💳',
'description': f'через {etoplatezhi_name}',
'callback': 'topup_etoplatezhi',
}
)
if settings.is_support_topup_enabled():
methods.append(
{
@@ -413,6 +453,12 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_aurapay_sbp_enabled()
if method_id == 'aurapay_card':
return settings.is_aurapay_card_enabled()
if method_id == 'etoplatezhi':
return settings.is_etoplatezhi_enabled()
if method_id == 'etoplatezhi_sbp':
return settings.is_etoplatezhi_sbp_enabled()
if method_id == 'etoplatezhi_card':
return settings.is_etoplatezhi_card_enabled()
if method_id == 'support':
return settings.is_support_topup_enabled()
return False
@@ -443,6 +489,9 @@ def get_payment_method_status() -> dict[str, bool]:
'aurapay': settings.is_aurapay_enabled(),
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
'aurapay_card': settings.is_aurapay_card_enabled(),
'etoplatezhi': settings.is_etoplatezhi_enabled(),
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
'support': settings.is_support_topup_enabled(),
}
@@ -488,4 +537,6 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_aurapay_enabled():
count += 1
if settings.is_etoplatezhi_enabled():
count += 1
return count
+48
View File
@@ -1469,6 +1469,53 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# Etoplatezhi webhook
if settings.is_etoplatezhi_enabled():
@router.get(settings.ETOPLATEZHI_WEBHOOK_PATH)
async def etoplatezhi_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'etoplatezhi_webhook',
'enabled': settings.is_etoplatezhi_enabled(),
}
)
@router.post(settings.ETOPLATEZHI_WEBHOOK_PATH)
async def etoplatezhi_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Подпись внутри JSON body (поле signature)
from app.services.etoplatezhi_service import etoplatezhi_service
if not etoplatezhi_service.verify_callback_signature(payload):
logger.warning('Etoplatezhi webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_etoplatezhi_callback',
)
if not success:
logger.error(
'Etoplatezhi webhook processing failed',
data=payload.get('payment', {}).get('id'),
)
except Exception as e:
logger.exception('Etoplatezhi webhook processing error', error=e)
# Always return 200 — Etoplatezhi expects 200 for valid signature
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
if routes_registered:
@router.get('/health/payment-webhooks')
@@ -1493,6 +1540,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'rollypay_enabled': settings.is_rollypay_enabled(),
'overpay_enabled': settings.is_overpay_enabled(),
'aurapay_enabled': settings.is_aurapay_enabled(),
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
}
)
@@ -0,0 +1,45 @@
"""create etoplatezhi_payments table
Revision ID: 0069
Revises: 0068
Create Date: 2026-05-04
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0069'
down_revision: Union[str, None] = '0068'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'etoplatezhi_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('etoplatezhi_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('etoplatezhi_payments')