feat: integrate Lava Business payment provider

- Lava Business via gate.lava.ru (HMAC-SHA256 signed JSON requests)
- Sub-methods: card and SBP via includeService filter
- Webhook signature verified from raw bytes with secret_key_2
- Sticky terminal-status guard (success after amount_mismatch escalates to ERROR)
- Order ID with full uuid4 hex (128-bit entropy)
- Cross-row contamination guard: order_id assertion on invoice_id fallback
- Warning when hook URL cannot be derived from webhook/web_api/cabinet bases
- Explicit failure when Lava response lacks payment_url (no orphan rows)
- Adds LAVA settings category, /lava-webhook endpoint, cabinet topup branch
- Mirrors existing Antilopay/Jupiter/Donut mixin pattern
This commit is contained in:
Fringg
2026-05-04 20:14:31 +03:00
parent f321ded9c0
commit afea054c8f
14 changed files with 1652 additions and 0 deletions
+46
View File
@@ -976,6 +976,37 @@ async def create_topup(
detail='Failed to create Donut payment',
)
elif request.payment_method == 'lava':
if not settings.is_lava_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Lava payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_lava_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,
payment_method_type=payment_method_type,
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 Lava payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
@@ -1151,6 +1182,21 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.LAVA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancel': ('', 'Отменено'),
'cancelled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
'failed': ('', 'Ошибка'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
+54
View File
@@ -753,6 +753,25 @@ class Settings(BaseSettings):
DONUT_SBP_QR_ENABLED: bool = False
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
# Lava (Lava Business API, gate.lava.ru)
LAVA_ENABLED: bool = False
LAVA_BASE_URL: str = 'https://gate.lava.ru'
LAVA_SHOP_ID: str | None = None # UUID проекта
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
LAVA_DISPLAY_NAME: str = 'Lava'
LAVA_CURRENCY: str = 'RUB'
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
LAVA_RETURN_URL: str | None = None
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
LAVA_CARD_ENABLED: bool = False
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
LAVA_SBP_ENABLED: bool = False
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
# Etoplatezhi (paymentpage.etoplatezhi.ru)
ETOPLATEZHI_ENABLED: bool = False
ETOPLATEZHI_PROJECT_ID: int | None = None
@@ -2343,6 +2362,41 @@ class Settings(BaseSettings):
def get_donut_sbp_qr_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_qr_display_name())
def is_lava_enabled(self) -> bool:
return (
self.LAVA_ENABLED
and self.LAVA_SHOP_ID is not None
and self.LAVA_SECRET_KEY is not None
and self.LAVA_WEBHOOK_SECRET is not None
)
def get_lava_display_name(self) -> str:
name = (self.LAVA_DISPLAY_NAME or '').strip()
return name if name else 'Lava'
def get_lava_display_name_html(self) -> str:
return html.escape(self.get_lava_display_name())
def is_lava_card_enabled(self) -> bool:
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
def get_lava_card_display_name(self) -> str:
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Lava)'
def get_lava_card_display_name_html(self) -> str:
return html.escape(self.get_lava_card_display_name())
def is_lava_sbp_enabled(self) -> bool:
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
def get_lava_sbp_display_name(self) -> str:
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Lava)'
def get_lava_sbp_display_name_html(self) -> str:
return html.escape(self.get_lava_sbp_display_name())
def is_etoplatezhi_enabled(self) -> bool:
return (
self.ETOPLATEZHI_ENABLED
+157
View File
@@ -0,0 +1,157 @@
"""CRUD операции для платежей Lava (Lava Business)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import LavaPayment
logger = structlog.get_logger(__name__)
async def create_lava_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,
lava_invoice_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> LavaPayment:
"""Создаёт запись о платеже Lava."""
payment = LavaPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
lava_invoice_id=lava_invoice_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('Создан платеж Lava', order_id=order_id, user_id=user_id)
return payment
async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None:
"""Получает платёж по нашему orderId."""
result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None:
"""Получает платёж по invoice_id, выданному Lava."""
result = await db.execute(
select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id)
)
return result.scalar_one_or_none()
async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None:
"""Получает платёж по локальному ID."""
result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None:
"""Получает платёж с FOR UPDATE-блокировкой."""
result = await db.execute(
select(LavaPayment)
.where(LavaPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_lava_payment_status(
db: AsyncSession,
payment: LavaPayment,
*,
status: str,
is_paid: bool | None = None,
lava_invoice_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> LavaPayment:
"""Обновляет статус платежа."""
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 lava_invoice_id is not None:
payment.lava_invoice_id = lava_invoice_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(
'Обновлён статус платежа Lava',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(LavaPayment).where(
LavaPayment.user_id == user_id,
LavaPayment.status == 'pending',
LavaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(LavaPayment).where(
LavaPayment.status == 'pending',
LavaPayment.is_paid == False,
LavaPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_lava_payment_to_transaction(
db: AsyncSession,
*,
payment: LavaPayment,
transaction_id: int,
) -> LavaPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+63
View File
@@ -171,6 +171,7 @@ class PaymentMethod(Enum):
ANTILOPAY = 'antilopay'
JUPITER = 'jupiter'
DONUT = 'donut'
LAVA = 'lava'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -1418,6 +1419,68 @@ class DonutPayment(Base):
return f'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class LavaPayment(Base):
"""Платежи через Lava Business (gate.lava.ru)."""
__tablename__ = 'lava_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) # Наш orderId
lava_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # invoice_id (UUID) от Lava
# Суммы
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) # 'card', 'sbp' и т.д.
# Метаданные
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='lava_payments')
transaction = relationship('Transaction', backref='lava_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status in ('pending', 'created', 'processing')
@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', 'cancel', 'cancelled', 'amount_mismatch', 'error']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<LavaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+284
View File
@@ -0,0 +1,284 @@
"""Handler for Lava balance top-up (Lava Business, gate.lava.ru)."""
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__)
LAVA_PAYMENT_METHODS = {'lava', 'lava_card', 'lava_sbp'}
LAVA_SERVICE_MAP: dict[str, str | None] = {
'lava': None,
'lava_card': 'card',
'lava_sbp': 'sbp',
}
def _extract_service_type(payment_method: str) -> str | None:
return LAVA_SERVICE_MAP.get(payment_method)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | 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)
def _get_display_name(payment_method: str) -> str:
if payment_method == 'lava_card':
return settings.get_lava_card_display_name()
if payment_method == 'lava_sbp':
return settings.get_lava_sbp_display_name()
return settings.get_lava_display_name()
async def _create_lava_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
display_name: str | None = None,
):
"""Создаёт инвойс Lava и отправляет ссылку пользователю."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_lava_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')
name = display_name or settings.get_lava_display_name()
# Без URL mixin вернул бы None ещё до этого блока; здесь URL гарантирован.
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}').format(
amount=f'{amount_rub:.0f}',
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'LAVA_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для перехода к оплате.\n'
'После подтверждения платежа баланс будет пополнен автоматически.',
).format(name=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('Lava payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_lava_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""Обрабатывает сумму для Lava."""
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
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).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}',
).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', 'lava')
payment_method_type = _extract_service_type(payment_method)
display_name = _get_display_name(payment_method)
await state.clear()
await _create_lava_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,
display_name=display_name,
)
async def _start_lava_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Стартует FSM ввода суммы для Lava."""
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.LAVA_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS // 100
display_name = _get_display_name(payment_method)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'LAVA_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).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_lava_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_lava_topup_impl(callback, db_user, state, 'lava')
@error_handler
async def start_lava_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_lava_topup_impl(callback, db_user, state, 'lava_card')
@error_handler
async def start_lava_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_lava_topup_impl(callback, db_user, state, 'lava_sbp')
+13
View File
@@ -205,6 +205,13 @@ async def route_payment_by_method(
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
from .lava import process_lava_payment_amount
async with AsyncSessionLocal() as db:
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -837,6 +844,12 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+40
View File
@@ -2060,6 +2060,46 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_lava_card_enabled():
lava_card_name = settings.get_lava_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
callback_data=_build_callback('lava_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_lava_sbp_enabled():
lava_sbp_name = settings.get_lava_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
callback_data=_build_callback('lava_sbp'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_lava_enabled()
and not settings.is_lava_card_enabled()
and not settings.is_lava_sbp_enabled()
):
lava_name = settings.get_lava_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
callback_data=_build_callback('lava'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+234
View File
@@ -0,0 +1,234 @@
"""Сервис для работы с API Lava Business (gate.lava.ru)."""
import hashlib
import hmac
import json
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
class LavaAPIError(Exception):
"""Ошибка API Lava."""
def __init__(self, status_code: int, message: str, code: str | int | None = None) -> None:
self.status_code = status_code
self.message = message
self.api_code = code
super().__init__(f'Lava API error ({status_code}): {message}')
class LavaService:
"""Клиент для Lava Business API (gate.lava.ru).
Подпись запросов: HMAC-SHA256(json_body, secret_key) hex.
Передаётся в заголовке ``Signature``.
Ключи `secret_key` (запросы) и `secret_key_2` (webhook) выдаются мерчанту в личном кабинете.
Каноническая строка для подписи JSON в том же порядке, в котором отправляется в теле.
"""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def base_url(self) -> str:
return (settings.LAVA_BASE_URL or 'https://gate.lava.ru').rstrip('/')
@property
def shop_id(self) -> str:
return settings.LAVA_SHOP_ID or ''
@property
def secret_key(self) -> str:
return settings.LAVA_SECRET_KEY or ''
@property
def webhook_secret(self) -> str:
# secret_key_2 — для проверки подписи webhook'а
return settings.LAVA_WEBHOOK_SECRET or ''
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
@staticmethod
def _serialize(payload: dict[str, Any]) -> str:
"""Сериализация JSON для подписи и тела запроса.
Lava подписывает байт-в-байт ту же строку, что и отправляется в теле, поэтому
порядок ключей определяется порядком вставки в payload (Python 3.7 dict сохраняет
порядок). Используем компактный сепаратор и UTF-8 без экранирования юникода.
"""
return json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
def _hmac_hex(self, message: str | bytes, key: str | None = None) -> str:
secret = (key if key is not None else self.secret_key) or ''
msg_bytes = message if isinstance(message, (bytes, bytearray)) else message.encode('utf-8')
return hmac.new(
secret.encode('utf-8'),
msg=msg_bytes,
digestmod=hashlib.sha256,
).hexdigest()
def _build_headers(self, body: str) -> dict[str, str]:
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Signature': self._hmac_hex(body),
}
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = f'{self.base_url}/{path.lstrip("/")}'
body = self._serialize(payload)
try:
session = await self._get_session()
async with session.post(url, data=body, headers=self._build_headers(body)) as response:
try:
data = await response.json(content_type=None)
except Exception:
text = await response.text()
data = {'_raw': text}
if not isinstance(data, dict):
data = {'_raw': data}
if response.status >= 400:
error_msg = (
data.get('error')
or (data.get('data') or {}).get('error')
or data.get('message')
or 'Lava API HTTP error'
)
logger.warning(
'Lava API HTTP error',
url=url,
status=response.status,
error_msg=str(error_msg),
code=data.get('code'),
)
raise LavaAPIError(response.status, str(error_msg), data.get('code'))
return data
except aiohttp.ClientError as error:
logger.exception('Lava API connection error', url=url, error=error)
raise
async def create_invoice(
self,
*,
amount_rubles: float,
order_id: str,
success_url: str | None = None,
fail_url: str | None = None,
hook_url: str | None = None,
expire_minutes: int | None = None,
comment: str | None = None,
custom_fields: str | None = None,
include_service: list[str] | None = None,
exclude_service: list[str] | None = None,
) -> dict[str, Any]:
"""Создаёт инвойс через POST /api/v2/invoice/create.
Сумма передаётся в рублях с двумя знаками после запятой.
``orderId`` наш уникальный идентификатор платежа.
"""
# Порядок полей важен (этим же порядком сериализуется и подписывается)
payload: dict[str, Any] = {
'sum': round(float(amount_rubles), 2),
'orderId': str(order_id),
'shopId': self.shop_id,
}
if hook_url:
payload['hookUrl'] = hook_url[:500]
if success_url:
payload['successUrl'] = success_url[:500]
if fail_url:
payload['failUrl'] = fail_url[:500]
if expire_minutes is not None:
# Lava лимит: 1..7200 минут (5 дней)
payload['expire'] = max(1, min(7200, int(expire_minutes)))
if comment:
payload['comment'] = comment[:255]
if custom_fields:
payload['customFields'] = custom_fields[:500]
if include_service:
payload['includeService'] = list(include_service)
if exclude_service:
payload['excludeService'] = list(exclude_service)
logger.info('Lava API invoice/create', order_id=order_id, sum=payload['sum'])
data = await self._post('/api/v2/invoice/create', payload)
# Lava возвращает {"status": "success", "data": {...}} или {"status": "error", "error": "..."}
if isinstance(data.get('status'), str) and data['status'].lower() == 'error':
raise LavaAPIError(200, str(data.get('error') or data.get('message') or 'unknown'))
return data
async def get_invoice_status(
self,
*,
order_id: str | None = None,
invoice_id: str | None = None,
) -> dict[str, Any]:
"""POST /api/v2/invoice/status — статус инвойса по orderId или invoiceId."""
if not order_id and not invoice_id:
raise ValueError('Lava status: order_id or invoice_id required')
payload: dict[str, Any] = {'shopId': self.shop_id}
if invoice_id:
payload['invoiceId'] = str(invoice_id)
if order_id:
payload['orderId'] = str(order_id)
logger.info('Lava API invoice/status', order_id=order_id, invoice_id=invoice_id)
return await self._post('/api/v2/invoice/status', payload)
async def get_services(self) -> dict[str, Any]:
"""POST /api/v2/invoice/services — доступные методы оплаты для shopId."""
payload: dict[str, Any] = {'shopId': self.shop_id}
return await self._post('/api/v2/invoice/services', payload)
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи webhook (заголовок ``Authorization``).
Lava Business webhook подписан HMAC-SHA256 от raw JSON body ключом ``secret_key_2``.
"""
try:
if not received_signature:
logger.warning('Lava webhook: отсутствует Authorization header')
return False
if not self.webhook_secret:
logger.error('Lava webhook: LAVA_WEBHOOK_SECRET не настроен')
return False
# HMAC берётся напрямую от raw bytes — без decode/encode round-trip,
# чтобы не терять байты при некорректной кодировке payload.
expected = self._hmac_hex(raw_body, key=self.webhook_secret)
received = received_signature.strip()
if not hmac.compare_digest(expected.lower(), received.lower()):
logger.warning(
'Lava webhook: invalid signature',
received_prefix=received[:8],
)
return False
return True
except Exception as error:
logger.error('Lava webhook verify error', error=error)
return False
# Singleton instance
lava_service = LavaService()
+594
View File
@@ -0,0 +1,594 @@
"""Mixin для интеграции с Lava Business (gate.lava.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.lava_service import lava_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava
LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = {
None: None,
'card': ['card'],
'sbp': ['sbp'],
}
# Маппинг статусов Lava -> internal
LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = {
'created': ('pending', False),
'pending': ('pending', False),
'processing': ('pending', False), # на случай промежуточного статуса
'success': ('success', True),
'cancel': ('cancelled', False),
'cancelled': ('cancelled', False),
'expired': ('expired', False),
'error': ('error', False),
'failed': ('failed', False),
}
class LavaPaymentMixin:
"""Mixin для работы с платежами Lava Business."""
async def create_lava_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:
"""Создаёт инвойс Lava."""
if not settings.is_lava_enabled():
logger.error('Lava не настроен')
return None
if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS:
logger.warning(
'Lava: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS:
logger.warning(
'Lava: сумма больше максимальной',
amount_kopeks=amount_kopeks,
LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS,
)
return None
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'
# 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id
order_id = f'lava{tg_id}_{uuid.uuid4().hex}'
amount_rubles = amount_kopeks / 100
currency = settings.LAVA_CURRENCY
method_key = (payment_method_type or '').lower() or None
include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key)
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': method_key,
'email': email,
}
try:
hook_url = self._build_lava_hook_url()
if not hook_url:
logger.warning(
'Lava: hook_url не сконфигурирован — '
'платёж создаётся, но автоматическое подтверждение через webhook невозможно. '
'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.'
)
actual_return_url = return_url or settings.LAVA_RETURN_URL
api_result = await lava_service.create_invoice(
amount_rubles=amount_rubles,
order_id=order_id,
hook_url=hook_url,
success_url=actual_return_url,
fail_url=actual_return_url,
expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES,
comment=(description or '')[:255] or None,
custom_fields=str(user_id) if user_id is not None else None,
include_service=include_service,
)
data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {}
lava_invoice_id = data.get('id') or data.get('invoice_id')
payment_url = data.get('url') or data.get('payment_url')
expired_str = data.get('expired')
if not payment_url:
# Без URL у пользователя нет способа оплатить — это аномалия Lava API.
# Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов.
logger.error(
'Lava: ответ API без payment URL, инвойс не создан',
order_id=order_id,
lava_invoice_id=lava_invoice_id,
response_keys=list(data.keys()) if isinstance(data, dict) else None,
)
return None
logger.info(
'Lava: получен ответ API',
order_id=order_id,
lava_invoice_id=lava_invoice_id,
payment_url=payment_url,
)
lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES
expires_at = self._parse_lava_expired(expired_str) or (
datetime.now(UTC) + timedelta(minutes=lifetime)
)
lava_crud = import_module('app.database.crud.lava')
local_payment = await lava_crud.create_lava_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=method_key,
lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Lava: создан платеж',
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,
'payment_id': str(lava_invoice_id) if lava_invoice_id else None,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Lava: ошибка создания платежа', error=e)
return None
@staticmethod
def _parse_lava_expired(value: Any) -> datetime | None:
"""Парсит поле ``expired`` из ответа Lava.
Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp.
Naive-строки игнорируются TZ Lava в спеке не задокументирована,
а угадывание UTC может сместить срок жизни инвойса на несколько часов.
Если парсинг не удался caller использует fallback ``now + lifetime``.
"""
if value is None or value == '':
return None
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(float(value), tz=UTC)
except (ValueError, OSError):
return None
if isinstance(value, str):
try:
from dateutil.parser import isoparse # type: ignore[import-not-found]
parsed = isoparse(value)
if parsed.tzinfo is None:
return None # без TZ доверять не можем
return parsed
except Exception:
return None
return None
@staticmethod
def _build_lava_hook_url() -> str | None:
"""Собирает абсолютный URL вебхука для Lava."""
webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook'
base = (
getattr(settings, 'WEBHOOK_URL', None)
or getattr(settings, 'WEB_API_BASE_URL', None)
or getattr(settings, 'CABINET_URL', None)
)
if not base:
return None
suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}'
return f'{base.rstrip("/")}{suffix}'
async def process_lava_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает webhook от Lava (подпись уже проверена в webserver)."""
try:
lava_invoice_id = payload.get('invoice_id')
our_order_id = payload.get('order_id')
lava_status = (payload.get('status') or '').strip().lower()
pay_service = payload.get('pay_service')
if not our_order_id or not lava_status:
logger.warning('Lava webhook: отсутствуют обязательные поля')
return False
lava_crud = import_module('app.database.crud.lava')
payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id)
if not payment:
# Fallback по invoice_id, но строго проверяем совпадение order_id
if lava_invoice_id:
payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id))
if payment and payment.order_id != our_order_id:
logger.error(
'Lava webhook: order_id mismatch',
webhook_order_id=our_order_id,
record_order_id=payment.order_id,
invoice_id=lava_invoice_id,
)
return False
if not payment:
logger.warning('Lava webhook: платеж не найден', order_id=our_order_id)
return False
locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы — стики, защита от повторного успеха
if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}:
# Если внезапно пришёл success после терминальной неудачи — это сигнал
# подделки или ошибки на стороне Lava, эскалируем.
if lava_status == 'success':
logger.error(
'Lava webhook: success на терминально-неуспешном платеже, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
)
else:
logger.warning(
'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=lava_status,
)
return True
if lava_status not in LAVA_STATUS_MAP:
logger.warning(
'Lava webhook: неизвестный статус, обрабатываем как pending',
order_id=payment.order_id,
incoming_status=lava_status,
)
internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False))
callback_payload = {
'lava_invoice_id': lava_invoice_id,
'status': lava_status,
'amount': payload.get('amount'),
'credited': payload.get('credited'),
'pay_service': pay_service,
'pay_time': payload.get('pay_time'),
'payer_details': payload.get('payer_details'),
'custom_fields': payload.get('custom_fields'),
}
# Сверяем сумму ДО зачисления
if is_paid:
# Lava webhook содержит amount (сумма счёта в рублях, float).
# Сверяем с тем, что мы отправляли на создание.
received_amount = payload.get('amount')
if received_amount is not None:
try:
received_kopeks = round(float(received_amount) * 100)
except (TypeError, ValueError):
received_kopeks = None
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Lava amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await lava_crud.update_lava_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
if is_paid:
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
if lava_invoice_id and not payment.lava_invoice_id:
payment.lava_invoice_id = str(lava_invoice_id)
# Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method
if pay_service:
metadata_now = dict(getattr(payment, 'metadata_json', {}) or {})
metadata_now['actual_pay_service'] = str(pay_service).lower()
payment.metadata_json = metadata_now
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_lava_payment(db, payment, trigger='webhook')
payment = await lava_crud.update_lava_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Lava webhook: ошибка обработки', error=e)
return False
async def _finalize_lava_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE-lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
lava_crud = import_module('app.database.crud.lava')
if payment.transaction_id:
logger.info(
'Lava платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
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='lava',
)
if guest_result is not None:
return True
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('Пользователь не найден для Lava', 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.LAVA,
)
display_name = settings.get_lava_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.LAVA,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await lava_crud.link_lava_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('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
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)
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.LAVA,
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('Ошибка обработки реферального пополнения Lava', 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('Ошибка отправки админ уведомления Lava', 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,
(
'✅ <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('Ошибка отправки уведомления пользователю Lava', 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(
'Обработан Lava платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_lava_payment_status(
self,
db: AsyncSession,
order_id: str | None = None,
invoice_id: str | None = None,
) -> dict[str, Any] | None:
"""Запрос статуса инвойса через API Lava."""
try:
return await lava_service.get_invoice_status(order_id=order_id, invoice_id=invoice_id)
except Exception as e:
logger.error(
'Lava: ошибка проверки статуса',
order_id=order_id,
invoice_id=invoice_id,
error=e,
)
return None
@@ -230,6 +230,16 @@ def _get_method_defaults() -> dict:
{'id': 'sbp_qr', 'name': 'СБП QR'},
],
},
'lava': {
'default_display_name': settings.get_lava_display_name(),
'is_configured': settings.is_lava_enabled(),
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
],
},
}
@@ -280,6 +290,7 @@ DEFAULT_METHOD_ORDER = [
'antilopay',
'jupiter',
'donut',
'lava',
]
+59
View File
@@ -38,6 +38,7 @@ from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.jupiter import JupiterPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.lava import LavaPaymentMixin
from app.services.payment.overpay import OverpayPaymentMixin
from app.services.payment.paypear import PayPearPaymentMixin
from app.services.payment.riopay import RioPayPaymentMixin
@@ -626,6 +627,41 @@ async def link_donut_payment_to_transaction(*args, **kwargs):
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
async def create_lava_payment(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.create_lava_payment(*args, **kwargs)
async def get_lava_payment_by_order_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
async def get_lava_payment_by_invoice_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
async def get_lava_payment_by_id(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
async def get_lava_payment_by_id_for_update(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
async def update_lava_payment_status(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.update_lava_payment_status(*args, **kwargs)
async def link_lava_payment_to_transaction(*args, **kwargs):
lava_crud = import_module('app.database.crud.lava')
return await lava_crud.link_lava_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] = {
@@ -657,6 +693,7 @@ class PaymentService(
AntilopayPaymentMixin,
JupiterPaymentMixin,
DonutPaymentMixin,
LavaPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -1252,6 +1289,28 @@ class PaymentService(
}
return None
# --- Lava -------------------------------------------------------------
if payment_method == 'lava':
if not settings.is_lava_enabled():
logger.warning('Lava is not enabled, cannot create guest payment')
return None
result = await self.create_lava_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'], 'lava')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'lava',
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
+3
View File
@@ -99,6 +99,7 @@ class BotConfigurationService:
'ETOPLATEZHI': '💳 Etoplatezhi',
'JUPITER': '🪐 Jupiter',
'DONUT': '🍩 Donut',
'LAVA': '🌋 Lava',
'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}',
'TRIBUTE': '🎁 Tribute',
@@ -167,6 +168,7 @@ class BotConfigurationService:
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
@@ -385,6 +387,7 @@ class BotConfigurationService:
'ETOPLATEZHI_': 'ETOPLATEZHI',
'JUPITER_': 'JUPITER',
'DONUT_': 'DONUT',
'LAVA_': 'LAVA',
'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY',
'PAL24_': 'PAL24',
+49
View File
@@ -1610,6 +1610,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# Lava webhook (Lava Business)
if settings.is_lava_enabled():
@router.get(settings.LAVA_WEBHOOK_PATH)
async def lava_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'lava_webhook',
'enabled': settings.is_lava_enabled(),
}
)
@router.post(settings.LAVA_WEBHOOK_PATH)
async def lava_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.lava_service import lava_service
received_signature = (request.headers.get('Authorization') or '').strip()
if not lava_service.verify_webhook_signature(raw_body, received_signature):
logger.warning('Lava webhook: invalid signature')
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_lava_callback',
)
if not success:
logger.error(
'Lava webhook processing failed',
order_id=payload.get('order_id'),
invoice_id=payload.get('invoice_id'),
)
except Exception as e:
logger.exception('Lava webhook processing error', error=e)
# Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
routes_registered = True
# Donut webhook (Donut P2P)
if settings.is_donut_enabled():
@@ -1684,6 +1732,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'antilopay_enabled': settings.is_antilopay_enabled(),
'jupiter_enabled': settings.is_jupiter_enabled(),
'donut_enabled': settings.is_donut_enabled(),
'lava_enabled': settings.is_lava_enabled(),
}
)
@@ -0,0 +1,45 @@
"""create lava_payments table
Revision ID: 0074
Revises: 0073
Create Date: 2026-05-04
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0074'
down_revision: Union[str, None] = '0073'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'lava_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('lava_invoice_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('lava_payments')