Files
remnawave-bedolaga-telegram…/app/services/payment/wata.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

574 lines
24 KiB
Python

"""Mixin for integrating WATA payment links into the payment service."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
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.wata_service import WataAPIError, WataService
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
def _extract_transaction_id(payment: Any, remote_link: dict[str, Any] | None = None) -> str | None:
"""Try to find the remote WATA transaction identifier from stored payloads."""
def _from_mapping(mapping: Any) -> str | None:
if isinstance(mapping, str):
try:
import json
mapping = json.loads(mapping)
except Exception: # pragma: no cover - defensive parsing
return None
if not isinstance(mapping, dict):
return None
for key in ('id', 'transaction_id', 'transactionId'):
value = mapping.get(key)
if not value:
continue
value_str = str(value)
if '-' in value_str:
return value_str
return None
candidate = None
if hasattr(payment, 'callback_payload'):
candidate = _from_mapping(payment.callback_payload)
if candidate:
return candidate
metadata = getattr(payment, 'metadata_json', None)
if isinstance(metadata, dict):
if 'transaction' in metadata:
candidate = _from_mapping(metadata.get('transaction'))
if candidate:
return candidate
candidate = _from_mapping(metadata)
if candidate:
return candidate
candidate = _from_mapping(remote_link)
if candidate:
return candidate
return None
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
async def create_wata_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, 'wata_service', None):
logger.error('WATA service is not initialised')
return None
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
logger.warning(
'Сумма WATA меньше минимальной: <',
amount_kopeks=amount_kopeks,
WATA_MIN_AMOUNT_KOPEKS=settings.WATA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
logger.warning(
'Сумма WATA больше максимальной: >',
amount_kopeks=amount_kopeks,
WATA_MAX_AMOUNT_KOPEKS=settings.WATA_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
# Добавляем идентификатор плательщика (telegram_id или email) в описание
try:
if user_id is not None:
user = await payment_module.get_user_by_id(db, user_id)
else:
user = None
if user:
if user.telegram_id:
description = f'{description} | ID: {user.telegram_id}'
elif user.email:
description = f'{description} | {user.email}'
except Exception as error:
logger.debug('Не удалось получить данные пользователя для описания WATA', error=error)
order_id = f'wata_{user_id or "guest"}_{uuid.uuid4().hex[:12]}'
try:
response = await self.wata_service.create_payment_link( # type: ignore[union-attr]
amount_kopeks=amount_kopeks,
currency='RUB',
description=description,
order_id=order_id,
success_url=return_url,
)
except WataAPIError as error:
logger.error('Ошибка создания WATA платежа', error=error)
return None
except Exception as error: # pragma: no cover - safety net
logger.exception('Непредвиденная ошибка при создании WATA платежа', error=error)
return None
payment_link_id = response.get('id')
payment_url = response.get('url') or response.get('paymentUrl')
status = response.get('status') or 'Opened'
terminal_public_id = response.get('terminalPublicId')
success_url = response.get('successRedirectUrl')
fail_url = response.get('failRedirectUrl')
if not payment_link_id:
logger.error('WATA API не вернула идентификатор платежной ссылки', response=response)
return None
expiration_raw = response.get('expirationDateTime')
expires_at = WataService._parse_datetime(expiration_raw)
metadata = {
'response': response,
'language': language or settings.DEFAULT_LANGUAGE,
}
local_payment = await payment_module.create_wata_payment(
db=db,
user_id=user_id,
payment_link_id=payment_link_id,
amount_kopeks=amount_kopeks,
currency='RUB',
description=description,
status=status,
type_=response.get('type'),
url=payment_url,
order_id=order_id,
metadata=metadata,
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_url,
fail_redirect_url=fail_url,
)
logger.info(
'Создан WATA платеж на ₽ для пользователя',
payment_link_id=payment_link_id,
amount_kopeks=amount_kopeks / 100,
user_id=user_id,
)
return {
'local_payment_id': local_payment.id,
'payment_link_id': payment_link_id,
'payment_url': payment_url,
'status': status,
'order_id': order_id,
}
async def process_wata_webhook(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Handles asynchronous webhook notifications from WATA."""
payment_module = import_module('app.services.payment_service')
if not isinstance(payload, dict):
logger.error('WATA webhook payload не является словарём', payload=payload)
return False
order_id_raw = payload.get('orderId')
payment_link_raw = payload.get('paymentLinkId') or payload.get('id')
transaction_status_raw = payload.get('transactionStatus')
order_id = str(order_id_raw) if order_id_raw else None
payment_link_id = str(payment_link_raw) if payment_link_raw else None
transaction_status = (transaction_status_raw or '').strip()
if not order_id and not payment_link_id:
logger.error('WATA webhook без orderId и paymentLinkId', payload=payload)
return False
if not transaction_status:
logger.error('WATA webhook без статуса транзакции', payload=payload)
return False
payment = None
if order_id:
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
if not payment and payment_link_id:
payment = await payment_module.get_wata_payment_by_link_id(db, payment_link_id)
if not payment:
logger.error(
'WATA платеж не найден (order_id payment_link_id=)', order_id=order_id, payment_link_id=payment_link_id
)
return False
status_lower = transaction_status.lower()
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
metadata['last_webhook'] = payload
terminal_public_id = (
payload.get('terminalPublicId') or payload.get('terminal_public_id') or payload.get('terminalPublicID')
)
update_kwargs: dict[str, Any] = {
'metadata': metadata,
'callback_payload': payload,
'terminal_public_id': terminal_public_id,
}
if transaction_status:
update_kwargs['status'] = transaction_status
update_kwargs['last_status'] = transaction_status
if status_lower != 'paid' and not payment.is_paid:
update_kwargs['is_paid'] = False
payment = await payment_module.update_wata_payment_status(
db,
payment=payment,
**update_kwargs,
)
if status_lower == 'paid':
if payment.is_paid:
logger.info('WATA платеж уже помечен как оплачен', payment_link_id=payment.payment_link_id)
return True
await self._finalize_wata_payment(db, payment, payload)
return True
if status_lower == 'declined':
logger.info('WATA платеж отклонён', payment_link_id=payment.payment_link_id)
return True
async def get_wata_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> dict[str, Any] | None:
payment_module = import_module('app.services.payment_service')
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_link: dict[str, Any] | None = None
transaction_payload: dict[str, Any] | None = None
transaction_id: str | None = None
if getattr(self, 'wata_service', None) and payment.payment_link_id:
try:
remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr]
except WataAPIError as error:
logger.error('Ошибка получения WATA ссылки', payment_link_id=payment.payment_link_id, error=error)
except Exception as error: # pragma: no cover - safety net
logger.exception('Непредвиденная ошибка при запросе WATA ссылки', error=error)
if remote_link:
remote_status = remote_link.get('status') or payment.status
if remote_status != payment.status:
existing_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
existing_metadata['link'] = remote_link
await payment_module.update_wata_payment_status(
db,
payment=payment,
status=remote_status,
last_status=remote_status,
url=remote_link.get('url') or remote_link.get('paymentUrl'),
metadata=existing_metadata,
terminal_public_id=remote_link.get('terminalPublicId'),
)
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
remote_status_normalized = (remote_status or '').lower()
if remote_status_normalized in {'closed', 'paid'} and not payment.is_paid:
transaction_id = _extract_transaction_id(payment, remote_link)
if transaction_id:
try:
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
transaction_id
)
except WataAPIError as error:
logger.error('Ошибка получения WATA транзакции', transaction_id=transaction_id, error=error)
except Exception as error: # pragma: no cover - safety net
logger.exception(
'Непредвиденная ошибка при запросе WATA транзакции',
transaction_id=transaction_id,
error=error,
)
if not transaction_payload:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status='Paid',
limit=5,
)
items = tx_response.get('items') or []
for item in items:
if (item or {}).get('status') == 'Paid':
transaction_payload = item
break
except WataAPIError as error:
logger.error(
'Ошибка поиска WATA транзакций для', payment_link_id=payment.payment_link_id, error=error
)
except Exception as error: # pragma: no cover - safety net
logger.exception('Непредвиденная ошибка при поиске WATA транзакции', error=error)
if not transaction_payload and not payment.is_paid and getattr(self, 'wata_service', None):
fallback_transaction_id = transaction_id or _extract_transaction_id(payment)
if fallback_transaction_id:
try:
transaction_payload = await self.wata_service.get_transaction( # type: ignore[union-attr]
fallback_transaction_id
)
except WataAPIError as error:
logger.error(
'Ошибка повторного запроса WATA транзакции',
fallback_transaction_id=fallback_transaction_id,
error=error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception(
'Непредвиденная ошибка при повторном запросе WATA транзакции',
fallback_transaction_id=fallback_transaction_id,
error=error,
)
if transaction_payload and not payment.is_paid:
normalized_status = None
if isinstance(transaction_payload, dict):
raw_status = transaction_payload.get('status') or transaction_payload.get('statusName')
if raw_status:
normalized_status = str(raw_status).lower()
if normalized_status == 'paid':
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
else:
logger.debug(
'WATA транзакция в статусе , повторная обработка не требуется',
transaction_id=transaction_id or getattr(payment, 'payment_link_id', ''),
normalized_status=normalized_status or 'unknown',
)
return {
'payment': payment,
'status': payment.status,
'is_paid': payment.is_paid,
'remote_link': remote_link,
'transaction': transaction_payload,
}
async def _finalize_wata_payment(
self,
db: AsyncSession,
payment: Any,
transaction_payload: dict[str, Any],
) -> Any:
payment_module = import_module('app.services.payment_service')
if isinstance(transaction_payload, dict):
paid_status = transaction_payload.get('status') or transaction_payload.get('statusName')
else:
paid_status = None
if paid_status and str(paid_status).lower() not in {'paid', 'declined', 'pending'}:
logger.debug(
'Неизвестный статус WATA транзакции',
getattr=getattr(payment, 'payment_link_id', ''),
paid_status=paid_status,
)
paid_at = None
if isinstance(transaction_payload, dict):
paid_at = WataService._parse_datetime(transaction_payload.get('paymentTime'))
if not paid_at and getattr(payment, 'paid_at', None):
paid_at = payment.paid_at
existing_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
invoice_message = existing_metadata.get('invoice_message') or {}
if getattr(self, 'bot', None) and invoice_message:
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('Не удалось удалить счёт WATA', message_id=message_id, delete_error=delete_error)
else:
existing_metadata.pop('invoice_message', None)
existing_metadata['transaction'] = transaction_payload
await payment_module.update_wata_payment_status(
db,
payment=payment,
status='Paid',
is_paid=True,
paid_at=paid_at,
callback_payload=transaction_payload,
metadata=existing_metadata,
)
wata_lock_crud = import_module('app.database.crud.wata')
locked = await wata_lock_crud.get_wata_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('WATA: не удалось заблокировать платёж', payment_id=payment.id)
return None
payment = locked
if payment.transaction_id:
logger.info(
'WATA платеж уже привязан к транзакции',
payment_link_id=payment.payment_link_id,
transaction_id=payment.transaction_id,
)
return payment
# --- Guest purchase flow (landing page) ---
wata_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=wata_metadata,
payment_amount_kopeks=payment.amount_kopeks,
provider_payment_id=payment.payment_link_id,
provider_name='wata',
)
if guest_result is not None:
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден при обработке WATA', user_id=payment.user_id)
return payment
transaction_external_id = str(transaction_payload.get('id') or transaction_payload.get('transactionId') or '')
description = f'Пополнение через WATA ({payment.payment_link_id})'
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.WATA,
external_id=transaction_external_id or payment.payment_link_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
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()
# 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=payment.amount_kopeks,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.WATA,
external_id=transaction_external_id or payment.payment_link_id,
)
user = await payment_module.get_user_by_id(db, user.id)
if not user:
logger.error('Пользователь не найден после коммита WATA', user_id=payment.user_id)
return payment
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
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('Ошибка обработки реферального пополнения WATA', 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)
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('Ошибка отправки админ уведомления WATA', 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'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
'🦊 Способ: WATA\n'
f'🆔 Транзакция: {transaction.id}\n\n'
'Баланс пополнен автоматически!'
),
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.error('Ошибка отправки уведомления пользователю WATA', 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.debug('Не удалось отправить напоминание о корзине после WATA', error=error)
return payment