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
+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