Files
remnawave-bedolaga-telegram…/app/services/yookassa_service.py
T
Fringg 5e404cc082 feat: публичные лендинг-страницы для быстрой покупки VPN-подписок
- Модели LandingPage и GuestPurchase + миграции 0018/0019
- CRUD для лендингов и гостевых покупок
- Публичные роуты: GET /{slug}, POST /{slug}/purchase, GET /purchase/{token}
- Админ-роуты: CRUD лендингов с RBAC (manage_landings)
- Сервис guest_purchase_service: валидация, создание, фулфилмент
- Интеграция с PaymentService (YooKassa card/SBP) для гостевых платежей
- Webhook-обработка с идемпотентностью и атомарными транзакциями
- Rate limiting на публичных эндпоинтах
- YooKassaPayment.user_id теперь nullable для гостевых платежей
2026-03-06 07:02:42 +03:00

329 lines
15 KiB
Python

import asyncio
import uuid
from typing import Any
import structlog
from yookassa import Configuration, Payment as YooKassaPayment
from yookassa.domain.common.confirmation_type import ConfirmationType
from yookassa.domain.exceptions.not_found_error import NotFoundError as YooKassaNotFoundError
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from app.config import settings
logger = structlog.get_logger(__name__)
class YooKassaService:
def __init__(
self,
shop_id: str | None = None,
secret_key: str | None = None,
configured_return_url: str | None = None,
bot_username_for_default_return: str | None = None,
):
shop_id = shop_id or getattr(settings, 'YOOKASSA_SHOP_ID', None)
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
self.configured = False
if not shop_id or not secret_key:
logger.warning(
'YooKassa SHOP_ID или SECRET_KEY не настроены в settings. Функционал платежей будет ОТКЛЮЧЕН.'
)
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logger.info('YooKassa SDK сконфигурирован для shop_id: ...', shop_id=shop_id[:5])
except Exception as error:
logger.error('Ошибка конфигурации YooKassa SDK', error=error, exc_info=True)
self.configured = False
if not self.configured:
self.return_url = 'https://t.me/'
logger.warning('YooKassa не активна, используем заглушку return_url', return_url=self.return_url)
elif configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default_return:
self.return_url = f'https://t.me/{bot_username_for_default_return}'
logger.info('YOOKASSA_RETURN_URL не установлен, используем бота', return_url=self.return_url)
else:
self.return_url = 'https://t.me/'
logger.warning(
'КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. Используем заглушку: . Платежи могут работать некорректно.',
return_url=self.return_url,
)
logger.info('YooKassa Service return_url', return_url=self.return_url)
async def create_payment(
self,
amount: float,
currency: str,
description: str,
metadata: dict[str, Any],
receipt_email: str | None = None,
receipt_phone: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
"""Создает платеж в YooKassa"""
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно создать платеж.')
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt['email'] = receipt_email
elif receipt_phone:
customer_contact_for_receipt['phone'] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
'КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.'
)
return {
'error': True,
'internal_message': 'Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию.',
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
builder.set_capture(True)
builder.set_confirmation({'type': ConfirmationType.REDIRECT, 'return_url': return_url or self.return_url})
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items_list: list[dict[str, Any]] = [
{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': int(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
]
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
'Создание платежа YooKassa (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
idempotence_key=idempotence_key,
amount=amount,
currency=currency,
metadata=metadata,
receipt_data_dict=receipt_data_dict,
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info(
'Ответ YooKassa Payment.create: ID=, Status=, Paid',
response_id=response.id,
status=response.status,
paid=response.paid,
)
return {
'id': response.id,
'confirmation_url': response.confirmation.confirmation_url if response.confirmation else None,
'status': response.status,
'metadata': response.metadata,
'amount_value': float(response.amount.value),
'amount_currency': response.amount.currency,
'idempotence_key_used': idempotence_key,
'paid': response.paid,
'refundable': response.refundable,
'created_at': response.created_at.isoformat()
if hasattr(response.created_at, 'isoformat')
else str(response.created_at),
'description_from_yk': response.description,
'test_mode': response.test if hasattr(response, 'test') else None,
}
except Exception as e:
logger.error('Ошибка создания платежа YooKassa', error=e, exc_info=True)
return None
async def create_sbp_payment(
self,
amount: float,
currency: str,
description: str,
metadata: dict[str, Any],
receipt_email: str | None = None,
receipt_phone: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно создать платеж через СБП.')
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt['email'] = receipt_email
elif receipt_phone:
customer_contact_for_receipt['phone'] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
'КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.'
)
return {
'error': True,
'internal_message': 'Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию.',
}
try:
# Создаем один платеж с подтверждением через QR
# Это позволит получить QR-код для пользователя
builder = PaymentRequestBuilder()
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
builder.set_capture(True)
# Устанавливаем подтверждение через redirect для получения вебхуков
builder.set_confirmation({'type': 'redirect', 'return_url': return_url or self.return_url})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({'type': 'sbp'})
receipt_items_list: list[dict[str, Any]] = [
{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': int(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
]
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
'Создание платежа YooKassa СБП с подтверждением redirect (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
idempotence_key=idempotence_key,
amount=amount,
currency=currency,
metadata=metadata,
receipt_data_dict=receipt_data_dict,
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info(
'Ответ YooKassa Payment.create (СБП, redirect): ID=, Status=, Paid',
response_id=response.id,
status=response.status,
paid=response.paid,
)
# Возвращаем данные платежа с redirect-подтверждением
# YooKassa покажет QR на десктопе или список банков на мобильном
return {
'id': response.id,
'qr_confirmation_data': response.confirmation.confirmation_data
if response.confirmation and hasattr(response.confirmation, 'confirmation_data')
else None,
'confirmation_url': response.confirmation.confirmation_url
if response.confirmation and hasattr(response.confirmation, 'confirmation_url')
else None,
'status': response.status,
'metadata': response.metadata,
'amount_value': float(response.amount.value),
'amount_currency': response.amount.currency,
'idempotence_key_used': idempotence_key,
'paid': response.paid,
'refundable': response.refundable,
'created_at': response.created_at.isoformat()
if hasattr(response.created_at, 'isoformat')
else str(response.created_at),
'description_from_yk': response.description,
'test_mode': response.test if hasattr(response, 'test') else None,
}
except Exception as e:
logger.error('Ошибка создания платежа YooKassa СБП', error=e, exc_info=True)
return None
async def get_payment_info(self, payment_id_in_yookassa: str) -> dict[str, Any] | None:
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно получить информацию о платеже.')
return None
try:
logger.info('Получение информации о платеже YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
loop = asyncio.get_running_loop()
payment_info_yk = await loop.run_in_executor(None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
if payment_info_yk:
logger.info(
'Информация о платеже YooKassa Status=, Paid',
payment_id_in_yookassa=payment_id_in_yookassa,
status=payment_info_yk.status,
paid=payment_info_yk.paid,
)
return {
'id': payment_info_yk.id,
'status': payment_info_yk.status,
'paid': payment_info_yk.paid,
'amount_value': float(payment_info_yk.amount.value),
'amount_currency': payment_info_yk.amount.currency,
'metadata': payment_info_yk.metadata,
'description': payment_info_yk.description,
'refundable': payment_info_yk.refundable,
'created_at': payment_info_yk.created_at.isoformat()
if hasattr(payment_info_yk.created_at, 'isoformat')
else str(payment_info_yk.created_at),
'captured_at': payment_info_yk.captured_at.isoformat()
if payment_info_yk.captured_at and hasattr(payment_info_yk.captured_at, 'isoformat')
else None,
'payment_method_type': payment_info_yk.payment_method.type
if payment_info_yk.payment_method
else None,
'test_mode': payment_info_yk.test if hasattr(payment_info_yk, 'test') else None,
}
logger.warning('Платеж не найден в YooKassa ID', payment_id_in_yookassa=payment_id_in_yookassa)
return None
except YooKassaNotFoundError:
logger.warning(
'Платеж не найден в YooKassa (404)',
payment_id_in_yookassa=payment_id_in_yookassa,
)
return None
except Exception as e:
logger.error(
'Ошибка получения информации о платеже YooKassa',
payment_id_in_yookassa=payment_id_in_yookassa,
error=e,
exc_info=True,
)
return None