Files
Fringg 8d3cd50098 refactor: централизация всех расчётов цен в PricingEngine
- Мигрирован 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()
2026-03-16 03:10:22 +03:00

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