8d3cd50098
- Мигрирован confirm_purchase() на calculate_classic_new_subscription_price() - Мигрирован compute_simple_subscription_price на делегацию в PricingEngine - Мигрирован handle_custom_confirm на calculate_tariff_purchase_price() - Мигрированы daily confirm handlers (confirm_daily_tariff_purchase, confirm_daily_tariff_switch, confirm_instant_switch daily path) - Мигрирован gift.py на calculate_tariff_purchase_price() - Мигрированы FSM cache prices (select_period, select_devices, toggle_country) - Добавлен lock_user_for_pricing в admin_buy_tariff_execute (TOCTOU fix) - Добавлен lock + recompute в _auto_add_devices и _auto_add_traffic - Исправлено двойное применение promo-offer в simple_subscription (критический баг) - Унифицирован daily price display (group+offer) на всех 6 поверхностях - PricingEngine.get_addon_discount_percent: добавлен promo_group= kwarg - PricingEngine._calculate_switch_to/from_daily: добавлен promo-offer discount - Удалён мёртвый код из common.py (_get_addon_discount_percent_for_user) - Miniapp period_discounts: исправлен доступ через get_discount_percent()
458 lines
21 KiB
Python
458 lines
21 KiB
Python
import asyncio
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
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)
|
|
|
|
# Рекуррентные платежи: сохранение карты
|
|
if settings.YOOKASSA_RECURRENT_ENABLED:
|
|
if settings.YOOKASSA_RECURRENT_REQUIRED:
|
|
builder.set_save_payment_method(True)
|
|
# Если не required — не устанавливаем, YooKassa покажет чекбокс
|
|
|
|
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()
|
|
async with asyncio.timeout(30):
|
|
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()
|
|
async with asyncio.timeout(30):
|
|
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()
|
|
async with asyncio.timeout(30):
|
|
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,
|
|
'payment_method_id': payment_info_yk.payment_method.id if payment_info_yk.payment_method else None,
|
|
'payment_method_saved': payment_info_yk.payment_method.saved
|
|
if payment_info_yk.payment_method and hasattr(payment_info_yk.payment_method, 'saved')
|
|
else False,
|
|
'payment_method_card': {
|
|
'first6': payment_info_yk.payment_method.card.first6,
|
|
'last4': payment_info_yk.payment_method.card.last4,
|
|
'card_type': payment_info_yk.payment_method.card.card_type,
|
|
'expiry_month': payment_info_yk.payment_method.card.expiry_month,
|
|
'expiry_year': payment_info_yk.payment_method.card.expiry_year,
|
|
}
|
|
if payment_info_yk.payment_method
|
|
and hasattr(payment_info_yk.payment_method, 'card')
|
|
and payment_info_yk.payment_method.card
|
|
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
|
|
|
|
async def create_autopayment(
|
|
self,
|
|
amount: float,
|
|
currency: str,
|
|
description: str,
|
|
payment_method_id: str,
|
|
metadata: dict[str, Any],
|
|
receipt_email: str | None = None,
|
|
receipt_phone: str | None = None,
|
|
idempotence_key: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Создаёт рекуррентный автоплатёж через сохранённый payment_method_id (без confirmation)."""
|
|
|
|
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_DEFAULT_RECEIPT_EMAIL не установлен.'
|
|
)
|
|
return None
|
|
|
|
try:
|
|
builder = PaymentRequestBuilder()
|
|
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
|
|
builder.set_capture(True)
|
|
builder.set_payment_method_id(payment_method_id)
|
|
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': str(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)
|
|
|
|
if not idempotence_key:
|
|
sub_id = metadata.get('subscription_id', uuid.uuid4())
|
|
idempotence_key = f'autopay_{sub_id}_{datetime.now(UTC).strftime("%Y-%m-%d")}'
|
|
payment_request = builder.build()
|
|
|
|
logger.info(
|
|
'Создание автоплатежа YooKassa',
|
|
amount=amount,
|
|
currency=currency,
|
|
payment_method_id=payment_method_id,
|
|
metadata=metadata,
|
|
idempotence_key=idempotence_key,
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
async with asyncio.timeout(30):
|
|
response = await loop.run_in_executor(
|
|
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
|
|
)
|
|
|
|
logger.info(
|
|
'Ответ YooKassa автоплатёж',
|
|
response_id=response.id,
|
|
status=response.status,
|
|
paid=response.paid,
|
|
)
|
|
|
|
return {
|
|
'id': response.id,
|
|
'status': response.status,
|
|
'paid': response.paid,
|
|
'metadata': response.metadata,
|
|
'amount_value': float(response.amount.value),
|
|
'amount_currency': response.amount.currency,
|
|
'idempotence_key_used': idempotence_key,
|
|
'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',
|
|
payment_method_id=payment_method_id,
|
|
error=e,
|
|
exc_info=True,
|
|
)
|
|
return None
|