Files
remnawave-bedolaga-telegram…/app/services/payment/heleket.py
T
Fringg b85646af85 fix: pass return_url to all payment providers for guest purchases
Platega, Heleket, WATA, CloudPayments now accept optional return_url
and pass it to their APIs. Guest payments redirect back to cabinet
success page instead of Telegram bot.
2026-03-06 18:18:42 +03:00

589 lines
23 KiB
Python

"""Mixin with Heleket payment flow implementation."""
from __future__ import annotations
import secrets
import time
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.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
class HeleketPaymentMixin:
"""Provides helpers to create and process Heleket payments."""
async def create_heleket_payment(
self,
db: AsyncSession,
user_id: int | None,
amount_kopeks: int,
description: str,
*,
language: str | None = None,
return_url: str | None = None,
) -> dict[str, Any] | None:
if not getattr(self, 'heleket_service', None):
logger.error('Heleket сервис не инициализирован')
return None
if amount_kopeks <= 0:
logger.error('Сумма Heleket должна быть положительной', amount_kopeks=amount_kopeks)
return None
amount_rubles = amount_kopeks / 100
amount_str = f'{amount_rubles:.2f}'
order_id = f'heleket_{user_id or "guest"}_{int(time.time())}_{secrets.token_hex(3)}'
markup_percent = settings.get_heleket_markup_percent()
discount_percent: int | None = None
if markup_percent:
try:
rounded = int(round(markup_percent))
if rounded != 0:
discount_percent = -rounded
except (TypeError, ValueError):
logger.warning('Некорректная наценка Heleket', markup_percent=markup_percent)
payload: dict[str, Any] = {
'amount': amount_str,
'currency': 'RUB',
'order_id': order_id,
'lifetime': settings.get_heleket_lifetime(),
}
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or '').strip()
if to_currency:
payload['to_currency'] = to_currency
network = (settings.HELEKET_DEFAULT_NETWORK or '').strip()
if network:
payload['network'] = network
callback_url = settings.get_heleket_callback_url()
if callback_url:
payload['url_callback'] = callback_url
effective_return = return_url or settings.HELEKET_RETURN_URL
effective_success = return_url or settings.HELEKET_SUCCESS_URL
if effective_return:
payload['url_return'] = effective_return
if effective_success:
payload['url_success'] = effective_success
if discount_percent is not None:
payload['discount_percent'] = discount_percent
metadata: dict[str, Any] = {
'language': language or settings.DEFAULT_LANGUAGE,
'created_at': datetime.now(UTC).isoformat(),
}
try:
response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr]
except Exception as error: # pragma: no cover - safety net
logger.exception('Ошибка создания Heleket платежа', error=error)
return None
if not response:
logger.error('Heleket API вернул пустой ответ при создании платежа')
return None
payment_result = response.get('result') if isinstance(response, dict) else None
if not payment_result:
logger.error('Некорректный ответ Heleket API', response=response)
return None
uuid = str(payment_result.get('uuid'))
response_order_id = payment_result.get('order_id')
if response_order_id:
order_id = str(response_order_id)
url = payment_result.get('url')
status = payment_result.get('status') or payment_result.get('payment_status') or 'check'
payer_amount = payment_result.get('payer_amount')
payer_currency = payment_result.get('payer_currency')
exchange_rate = payment_result.get('payer_amount_exchange_rate')
try:
exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None
except (TypeError, ValueError):
exchange_rate_value = None
if exchange_rate_value is None and payer_amount:
try:
exchange_rate_value = float(payer_amount) / amount_rubles if amount_rubles else None
except (TypeError, ValueError, ZeroDivisionError):
exchange_rate_value = None
expires_at_raw = payment_result.get('expired_at')
expires_at: datetime | None = None
if expires_at_raw:
try:
expires_at = datetime.fromtimestamp(int(expires_at_raw), tz=UTC)
except (TypeError, ValueError, OSError):
expires_at = None
heleket_crud = import_module('app.database.crud.heleket')
local_payment = await heleket_crud.create_heleket_payment(
db=db,
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount_str,
currency='RUB',
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate_value,
discount_percent=discount_percent,
payment_url=url,
expires_at=expires_at,
metadata={'raw_response': payment_result, **metadata},
)
logger.info('Создан Heleket платеж на ₽ для пользователя', uuid=uuid, amount_str=amount_str, user_id=user_id)
return {
'local_payment_id': local_payment.id,
'uuid': uuid,
'order_id': order_id,
'amount': amount_str,
'amount_kopeks': amount_kopeks,
'payment_url': url,
'status': status,
'payer_amount': payer_amount,
'payer_currency': payer_currency,
'exchange_rate': exchange_rate_value,
'discount_percent': discount_percent,
}
async def _process_heleket_payload(
self,
db: AsyncSession,
payload: dict[str, Any],
*,
metadata_key: str,
) -> HeleketPayment | None:
if not isinstance(payload, dict):
logger.error('Heleket webhook payload не является словарём', payload=payload)
return None
heleket_crud = import_module('app.database.crud.heleket')
payment_module = import_module('app.services.payment_service')
uuid = str(payload.get('uuid') or '').strip()
order_id = str(payload.get('order_id') or '').strip()
status = payload.get('status') or payload.get('payment_status')
if not uuid and not order_id:
logger.error('Heleket webhook без uuid/order_id', payload=payload)
return None
payment = None
if uuid:
payment = await heleket_crud.get_heleket_payment_by_uuid(db, uuid)
if payment is None and order_id:
payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id)
if not payment:
logger.error('Heleket платеж не найден (uuid= order_id=)', uuid=uuid, order_id=order_id)
return None
payer_amount = payload.get('payer_amount') or payload.get('payment_amount')
payer_currency = payload.get('payer_currency') or payload.get('currency')
discount_percent = payload.get('discount_percent')
exchange_rate_raw = payload.get('payer_amount_exchange_rate')
payment_url = payload.get('url')
exchange_rate: float | None = None
if exchange_rate_raw is not None:
try:
exchange_rate = float(exchange_rate_raw)
except (TypeError, ValueError):
exchange_rate = None
if exchange_rate is None and payer_amount:
try:
exchange_rate = float(payer_amount) / payment.amount_float if payment.amount_float else None
except (TypeError, ValueError, ZeroDivisionError):
exchange_rate = None
paid_at: datetime | None = None
paid_at_raw = payload.get('paid_at') or payload.get('updated_at')
if paid_at_raw:
try:
if isinstance(paid_at_raw, (int, float)):
paid_at = datetime.fromtimestamp(float(paid_at_raw), tz=UTC)
else:
paid_at = datetime.fromisoformat(str(paid_at_raw).replace('Z', '+00:00'))
except (ValueError, TypeError):
paid_at = None
if paid_at and paid_at.tzinfo is not None:
paid_at = paid_at.astimezone(UTC)
updated_payment = await heleket_crud.update_heleket_payment(
db,
payment.uuid,
status=status,
payer_amount=str(payer_amount) if payer_amount is not None else None,
payer_currency=str(payer_currency) if payer_currency is not None else None,
exchange_rate=exchange_rate,
discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None,
paid_at=paid_at,
payment_url=payment_url,
metadata={metadata_key: payload},
)
if updated_payment is None:
return None
metadata = dict(getattr(updated_payment, 'metadata_json', {}) or {})
invoice_message = metadata.get('invoice_message') or {}
invoice_message_removed = False
status_normalized = (status or '').lower()
is_final = status_normalized in {'paid', 'paid_over', 'cancel', 'fail', 'system_fail', 'refund_paid'}
if getattr(self, 'bot', None) and invoice_message and is_final:
chat_id = invoice_message.get('chat_id')
message_id = invoice_message.get('message_id')
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning('Не удалось удалить счёт Heleket', message_id=message_id, delete_error=delete_error)
else:
metadata.pop('invoice_message', None)
invoice_message_removed = True
if invoice_message_removed:
try:
from app.database.crud import heleket as heleket_crud
await heleket_crud.update_heleket_payment(
db,
updated_payment.uuid,
metadata=metadata,
)
updated_payment.metadata_json = metadata
except Exception as error: # pragma: no cover - diagnostics
logger.warning('Не удалось обновить метаданные Heleket после удаления счёта', error=error)
heleket_lock_crud = import_module('app.database.crud.heleket')
locked = await heleket_lock_crud.get_heleket_payment_by_id_for_update(db, updated_payment.id)
if not locked:
logger.error('Heleket: не удалось заблокировать платёж', payment_id=updated_payment.id)
return None
updated_payment = locked
if updated_payment.transaction_id:
logger.info(
'Heleket платеж уже связан с транзакцией',
uuid=updated_payment.uuid,
transaction_id=updated_payment.transaction_id,
)
return updated_payment
if status_normalized not in {'paid', 'paid_over'}:
logger.info('Heleket платеж в статусе , зачисление не требуется', uuid=updated_payment.uuid, status=status)
return updated_payment
amount_kopeks = updated_payment.amount_kopeks
if amount_kopeks <= 0:
logger.error(
'Heleket платеж имеет некорректную сумму', uuid=updated_payment.uuid, amount=updated_payment.amount
)
return None
# --- Guest purchase flow (landing page) ---
# Re-read metadata from the locked row to avoid stale data
locked_metadata = dict(getattr(updated_payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=locked_metadata,
payment_amount_kopeks=amount_kopeks,
provider_payment_id=updated_payment.uuid,
provider_name='heleket',
)
if guest_result is not None:
return updated_payment
transaction = await payment_module.create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=(
'Пополнение через Heleket'
if not updated_payment.payer_currency
else (f'Пополнение через Heleket ({updated_payment.payer_amount} {updated_payment.payer_currency})')
),
payment_method=PaymentMethod.HELEKET,
external_id=updated_payment.uuid,
is_completed=True,
created_at=getattr(updated_payment, 'created_at', None),
commit=False,
)
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
db,
updated_payment.uuid,
transaction.id,
)
if linked_payment:
updated_payment = linked_payment
get_user_by_id = payment_module.get_user_by_id
user = await get_user_by_id(db, updated_payment.user_id)
if not user:
logger.error('Пользователь не найден для Heleket платежа', user_id=updated_payment.user_id)
return None
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=amount_kopeks,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.HELEKET,
external_id=updated_payment.uuid,
)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error: # pragma: no cover - defensive
logger.error('Ошибка реферального начисления Heleket', error=error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
# Перезагружаем пользователя с зависимостями после коммита,
# чтобы избежать lazy load в async-контексте (MissingGreenlet)
user = await get_user_by_id(db, user.id) or user
if getattr(self, 'bot', None):
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
referrer_info = format_referrer_info(user)
subscription = getattr(user, 'subscription', None)
promo_group = user.get_primary_promo_group()
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: # pragma: no cover
logger.error('Ошибка отправки админ-уведомления Heleket', error=error)
# Отправляем уведомление только Telegram-пользователям
if user.telegram_id:
try:
keyboard = await self.build_topup_success_keyboard(user)
exchange_rate_value = updated_payment.exchange_rate or 0
rate_text = (
f'💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}'
if exchange_rate_value and updated_payment.payer_currency
else None
)
message_lines = [
'✅ <b>Пополнение успешно!</b>',
f'💰 Сумма: {settings.format_price(amount_kopeks)}',
'💳 Способ: Heleket',
]
if updated_payment.payer_amount and updated_payment.payer_currency:
message_lines.append(
f'🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}'
)
if rate_text:
message_lines.append(rate_text)
await self.bot.send_message(
chat_id=user.telegram_id,
text='\n'.join(message_lines),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error: # pragma: no cover
logger.error('Ошибка отправки уведомления пользователю Heleket', error=error)
else:
logger.info('Пропуск Telegram-уведомления Heleket для email-пользователя', user_id=user.id)
# Автопокупка из сохранённой корзины и уведомление о корзине
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(
user, updated_payment.amount_kopeks, db, getattr(self, 'bot', None)
)
except Exception as error:
logger.error(
'Ошибка при работе с автоактивацией для пользователя', user_id=user.id, error=error, exc_info=True
)
return updated_payment
async def process_heleket_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
result = await self._process_heleket_payload(
db,
payload,
metadata_key='last_webhook',
)
return result is not None
async def sync_heleket_payment_status(
self,
db: AsyncSession,
*,
local_payment_id: int,
) -> HeleketPayment | None:
if not getattr(self, 'heleket_service', None):
logger.error('Heleket сервис не инициализирован')
return None
heleket_crud = import_module('app.database.crud.heleket')
payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id)
if not payment:
logger.error('Heleket платеж с id= не найден', local_payment_id=local_payment_id)
return None
payload: dict[str, Any] | None = None
try:
response = await self.heleket_service.get_payment_info( # type: ignore[union-attr]
uuid=payment.uuid,
order_id=payment.order_id,
)
except Exception as error: # pragma: no cover - defensive
logger.exception('Ошибка получения статуса Heleket платежа', uuid=payment.uuid, error=error)
else:
if response:
result = response.get('result') if isinstance(response, dict) else None
if isinstance(result, dict):
payload = dict(result)
else:
logger.error(
'Некорректный ответ Heleket API при проверке платежа', uuid=payment.uuid, response=response
)
if payload is None:
fallback = await self._lookup_heleket_payment_history(payment)
if not fallback:
logger.warning('Heleket API не вернул информацию по платежу', uuid=payment.uuid)
return payment
payload = dict(fallback)
payload.setdefault('uuid', payment.uuid)
payload.setdefault('order_id', payment.order_id)
updated_payment = await self._process_heleket_payload(
db,
payload,
metadata_key='last_status_check',
)
return updated_payment or payment
async def _lookup_heleket_payment_history(
self,
payment: HeleketPayment,
) -> dict[str, Any] | None:
service = getattr(self, 'heleket_service', None)
if not service:
return None
created_at = getattr(payment, 'created_at', None)
date_from_str: str | None = None
date_to_str: str | None = None
if isinstance(created_at, datetime):
start = created_at - timedelta(days=2)
end = created_at + timedelta(days=2)
date_from_str = start.strftime('%Y-%m-%d %H:%M:%S')
date_to_str = end.strftime('%Y-%m-%d %H:%M:%S')
cursor: str | None = None
for _ in range(10):
response = await service.list_payments(
date_from=date_from_str,
date_to=date_to_str,
cursor=cursor,
)
if not response or not isinstance(response, dict):
return None
result = response.get('result')
if not isinstance(result, dict):
return None
items = result.get('items')
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
uuid = str(item.get('uuid') or '').strip()
order_id = str(item.get('order_id') or '').strip()
if uuid and uuid == str(payment.uuid):
return item
if order_id and order_id == str(payment.order_id):
return item
paginate = result.get('paginate')
cursor = None
if isinstance(paginate, dict):
next_cursor = paginate.get('nextCursor')
if isinstance(next_cursor, str) and next_cursor:
cursor = next_cursor
if not cursor:
break
return None