mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT
1040 lines
41 KiB
Python
1040 lines
41 KiB
Python
"""Mixin для интеграции с PayPalych (Pal24)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from importlib import import_module
|
||
import uuid
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.models import PaymentMethod, TransactionType
|
||
from app.services.pal24_service import Pal24APIError
|
||
from app.services.subscription_auto_purchase_service import (
|
||
auto_activate_subscription_after_topup,
|
||
auto_purchase_saved_cart_after_topup,
|
||
)
|
||
from app.utils.user_utils import format_referrer_info
|
||
from app.utils.payment_logger import payment_logger as logger
|
||
|
||
|
||
class Pal24PaymentMixin:
|
||
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
|
||
|
||
async def create_pal24_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: int,
|
||
amount_kopeks: int,
|
||
description: str,
|
||
language: str,
|
||
ttl_seconds: Optional[int] = None,
|
||
payer_email: Optional[str] = None,
|
||
payment_method: Optional[str] = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Создаёт счёт в Pal24 и сохраняет локальную запись."""
|
||
service = getattr(self, "pal24_service", None)
|
||
if not service or not service.is_configured:
|
||
logger.error("Pal24 сервис не инициализирован")
|
||
return None
|
||
|
||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||
logger.warning(
|
||
"Сумма Pal24 меньше минимальной: %s < %s",
|
||
amount_kopeks,
|
||
settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||
)
|
||
return None
|
||
|
||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||
logger.warning(
|
||
"Сумма Pal24 больше максимальной: %s > %s",
|
||
amount_kopeks,
|
||
settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||
)
|
||
return None
|
||
|
||
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
|
||
|
||
custom_payload = {
|
||
"user_id": user_id,
|
||
"amount_kopeks": amount_kopeks,
|
||
"language": language,
|
||
}
|
||
|
||
normalized_payment_method = self._normalize_payment_method(payment_method)
|
||
api_payment_method = self._map_api_payment_method(normalized_payment_method)
|
||
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
try:
|
||
response = await service.create_bill(
|
||
amount_kopeks=amount_kopeks,
|
||
user_id=user_id,
|
||
order_id=order_id,
|
||
description=description,
|
||
ttl_seconds=ttl_seconds,
|
||
custom_payload=custom_payload,
|
||
payer_email=payer_email,
|
||
payment_method=api_payment_method,
|
||
)
|
||
except Pal24APIError as error:
|
||
logger.error("Ошибка Pal24 API при создании счета: %s", error)
|
||
return None
|
||
|
||
if not response.get("success", True):
|
||
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
|
||
return None
|
||
|
||
bill_id = response.get("bill_id")
|
||
if not bill_id:
|
||
logger.error("Pal24 не вернул bill_id: %s", response)
|
||
return None
|
||
|
||
def _pick_url(*keys: str) -> Optional[str]:
|
||
for key in keys:
|
||
value = response.get(key)
|
||
if value:
|
||
return str(value)
|
||
return None
|
||
|
||
transfer_url = _pick_url(
|
||
"transfer_url",
|
||
"transferUrl",
|
||
"transfer_link",
|
||
"transferLink",
|
||
"transfer",
|
||
"sbp_url",
|
||
"sbpUrl",
|
||
"sbp_link",
|
||
"sbpLink",
|
||
)
|
||
card_url = _pick_url(
|
||
"link_url",
|
||
"linkUrl",
|
||
"link",
|
||
"card_url",
|
||
"cardUrl",
|
||
"card_link",
|
||
"cardLink",
|
||
"payment_url",
|
||
"paymentUrl",
|
||
"url",
|
||
)
|
||
link_page_url = _pick_url(
|
||
"link_page_url",
|
||
"linkPageUrl",
|
||
"page_url",
|
||
"pageUrl",
|
||
)
|
||
|
||
primary_link = transfer_url or link_page_url or card_url
|
||
secondary_link = link_page_url or card_url or transfer_url
|
||
|
||
metadata_links = {
|
||
key: value
|
||
for key, value in {
|
||
"sbp": transfer_url,
|
||
"card": card_url,
|
||
"page": link_page_url,
|
||
}.items()
|
||
if value
|
||
}
|
||
|
||
metadata_payload = {
|
||
"user_id": user_id,
|
||
"amount_kopeks": amount_kopeks,
|
||
"description": description,
|
||
"links": metadata_links,
|
||
"raw_response": response,
|
||
"selected_method": normalized_payment_method,
|
||
}
|
||
|
||
payment = await payment_module.create_pal24_payment(
|
||
db,
|
||
user_id=user_id,
|
||
bill_id=bill_id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=description,
|
||
status=response.get("status", "NEW"),
|
||
type_=response.get("type", "normal"),
|
||
currency=response.get("currency", "RUB"),
|
||
link_url=transfer_url or card_url,
|
||
link_page_url=link_page_url or primary_link,
|
||
order_id=order_id,
|
||
ttl=ttl_seconds,
|
||
metadata=metadata_payload,
|
||
)
|
||
|
||
logger.info(
|
||
"Создан Pal24 счет %s для пользователя %s (%s₽)",
|
||
bill_id,
|
||
user_id,
|
||
amount_kopeks / 100,
|
||
)
|
||
|
||
payment_status = getattr(payment, "status", response.get("status", "NEW"))
|
||
|
||
return {
|
||
"local_payment_id": payment.id,
|
||
"bill_id": bill_id,
|
||
"order_id": order_id,
|
||
"amount_kopeks": amount_kopeks,
|
||
"primary_url": primary_link,
|
||
"secondary_url": secondary_link,
|
||
"link_url": transfer_url,
|
||
"card_url": card_url,
|
||
"payment_method": normalized_payment_method,
|
||
"metadata_links": metadata_links,
|
||
"status": payment_status,
|
||
"sbp_url": transfer_url,
|
||
"transfer_url": transfer_url,
|
||
"link_page_url": link_page_url,
|
||
"payment_url": primary_link,
|
||
}
|
||
|
||
async def process_pal24_callback(
|
||
self,
|
||
db: AsyncSession,
|
||
callback: Dict[str, Any],
|
||
) -> bool:
|
||
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
|
||
try:
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
def _first_non_empty(*values: Optional[str]) -> Optional[str]:
|
||
for value in values:
|
||
if value:
|
||
return value
|
||
return None
|
||
|
||
payment_id = _first_non_empty(
|
||
callback.get("id"),
|
||
callback.get("TrsId"),
|
||
callback.get("TrsID"),
|
||
)
|
||
bill_id = _first_non_empty(
|
||
callback.get("bill_id"),
|
||
callback.get("billId"),
|
||
callback.get("BillId"),
|
||
callback.get("BillID"),
|
||
)
|
||
order_id = _first_non_empty(
|
||
callback.get("order_id"),
|
||
callback.get("orderId"),
|
||
callback.get("InvId"),
|
||
callback.get("InvID"),
|
||
)
|
||
status = (callback.get("status") or callback.get("Status") or "").upper()
|
||
|
||
if not bill_id and not order_id:
|
||
logger.error("Pal24 callback без идентификаторов: %s", callback)
|
||
return False
|
||
|
||
payment = None
|
||
if bill_id:
|
||
payment = await payment_module.get_pal24_payment_by_bill_id(db, bill_id)
|
||
if not payment and order_id:
|
||
payment = await payment_module.get_pal24_payment_by_order_id(db, order_id)
|
||
|
||
if not payment:
|
||
logger.error("Pal24 платеж не найден: %s / %s", bill_id, order_id)
|
||
return False
|
||
|
||
if payment.is_paid:
|
||
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
|
||
return True
|
||
|
||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
|
||
payment = await payment_module.update_pal24_payment_status(
|
||
db,
|
||
payment,
|
||
status=status,
|
||
is_paid=True,
|
||
paid_at=datetime.utcnow(),
|
||
callback_payload=callback,
|
||
payment_id=payment_id,
|
||
payment_status=callback.get("Status") or status,
|
||
payment_method=(
|
||
callback.get("payment_method")
|
||
or callback.get("PaymentMethod")
|
||
or metadata.get("selected_method")
|
||
or getattr(payment, "payment_method", None)
|
||
),
|
||
balance_amount=callback.get("BalanceAmount")
|
||
or callback.get("balance_amount"),
|
||
balance_currency=callback.get("BalanceCurrency")
|
||
or callback.get("balance_currency"),
|
||
payer_account=callback.get("AccountNumber")
|
||
or callback.get("account")
|
||
or callback.get("Account"),
|
||
)
|
||
|
||
return await self._finalize_pal24_payment(
|
||
db,
|
||
payment,
|
||
payment_id=payment_id,
|
||
trigger="callback",
|
||
)
|
||
|
||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
|
||
await payment_module.update_pal24_payment_status(
|
||
db,
|
||
payment,
|
||
status=status or "UNKNOWN",
|
||
is_paid=False,
|
||
callback_payload=callback,
|
||
payment_id=payment_id,
|
||
payment_status=callback.get("Status") or status,
|
||
payment_method=(
|
||
callback.get("payment_method")
|
||
or callback.get("PaymentMethod")
|
||
or getattr(payment, "payment_method", None)
|
||
),
|
||
balance_amount=callback.get("BalanceAmount")
|
||
or callback.get("balance_amount"),
|
||
balance_currency=callback.get("BalanceCurrency")
|
||
or callback.get("balance_currency"),
|
||
payer_account=callback.get("AccountNumber")
|
||
or callback.get("account")
|
||
or callback.get("Account"),
|
||
)
|
||
logger.info(
|
||
"Обновили Pal24 платеж %s до статуса %s",
|
||
payment.bill_id,
|
||
status,
|
||
)
|
||
return True
|
||
|
||
except Exception as error:
|
||
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
|
||
return False
|
||
|
||
async def _finalize_pal24_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
payment: Any,
|
||
*,
|
||
payment_id: Optional[str],
|
||
trigger: str,
|
||
) -> bool:
|
||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
|
||
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||
invoice_message = metadata.get("invoice_message") or {}
|
||
invoice_message_removed = False
|
||
|
||
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(
|
||
"Не удалось удалить счёт PayPalych %s: %s",
|
||
message_id,
|
||
delete_error,
|
||
)
|
||
else:
|
||
metadata.pop("invoice_message", None)
|
||
invoice_message_removed = True
|
||
|
||
if invoice_message_removed:
|
||
try:
|
||
await payment_module.update_pal24_payment_status(
|
||
db,
|
||
payment,
|
||
status=payment.status,
|
||
metadata=metadata,
|
||
)
|
||
payment.metadata_json = metadata
|
||
except Exception as error: # pragma: no cover - diagnostics
|
||
logger.warning(
|
||
"Не удалось обновить метаданные PayPalych после удаления счёта: %s",
|
||
error,
|
||
)
|
||
|
||
if payment.transaction_id:
|
||
logger.info(
|
||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||
payment.bill_id,
|
||
trigger,
|
||
)
|
||
return True
|
||
|
||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||
if not user:
|
||
logger.error(
|
||
"Пользователь %s не найден для Pal24 платежа %s (trigger=%s)",
|
||
payment.user_id,
|
||
payment.bill_id,
|
||
trigger,
|
||
)
|
||
return False
|
||
|
||
transaction = await payment_module.create_transaction(
|
||
db,
|
||
user_id=payment.user_id,
|
||
type=TransactionType.DEPOSIT,
|
||
amount_kopeks=payment.amount_kopeks,
|
||
description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})",
|
||
payment_method=PaymentMethod.PAL24,
|
||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||
is_completed=True,
|
||
)
|
||
|
||
await payment_module.link_pal24_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.utcnow()
|
||
|
||
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 "🔄 Пополнение"
|
||
|
||
await db.commit()
|
||
|
||
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(
|
||
"Ошибка обработки реферального пополнения Pal24: %s",
|
||
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)
|
||
await db.refresh(payment)
|
||
|
||
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(
|
||
"Ошибка отправки админ уведомления Pal24: %s",
|
||
error,
|
||
)
|
||
|
||
if getattr(self, "bot", None):
|
||
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"
|
||
"🦊 Способ: PayPalych\n"
|
||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||
"Баланс пополнен автоматически!"
|
||
),
|
||
parse_mode="HTML",
|
||
reply_markup=keyboard,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка отправки уведомления пользователю Pal24: %s",
|
||
error,
|
||
)
|
||
|
||
try:
|
||
from app.services.user_cart_service import user_cart_service
|
||
from aiogram import types
|
||
|
||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||
auto_purchase_success = False
|
||
if has_saved_cart:
|
||
try:
|
||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||
db,
|
||
user,
|
||
bot=getattr(self, "bot", None),
|
||
)
|
||
except Exception as auto_error:
|
||
logger.error(
|
||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||
user.id,
|
||
auto_error,
|
||
exc_info=True,
|
||
)
|
||
|
||
if auto_purchase_success:
|
||
has_saved_cart = False
|
||
|
||
# Умная автоактивация если автопокупка не сработала
|
||
activation_notification_sent = False
|
||
if not auto_purchase_success:
|
||
try:
|
||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
|
||
)
|
||
except Exception as auto_activate_error:
|
||
logger.error(
|
||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||
user.id,
|
||
auto_activate_error,
|
||
exc_info=True,
|
||
)
|
||
|
||
# Отправляем уведомление только если его ещё не отправили
|
||
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
|
||
from app.localization.texts import get_texts
|
||
|
||
texts = get_texts(user.language)
|
||
cart_message = texts.t(
|
||
"BALANCE_TOPUP_CART_REMINDER",
|
||
"У вас есть незавершенное оформление подписки. Вернуться?",
|
||
)
|
||
|
||
keyboard = types.InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text=texts.t(
|
||
"BALANCE_TOPUP_CART_BUTTON",
|
||
"🛒 Продолжить оформление",
|
||
),
|
||
callback_data="return_to_saved_cart",
|
||
)
|
||
],
|
||
[
|
||
types.InlineKeyboardButton(
|
||
text="🏠 Главное меню",
|
||
callback_data="back_to_menu",
|
||
)
|
||
],
|
||
]
|
||
)
|
||
|
||
await self.bot.send_message(
|
||
chat_id=user.telegram_id,
|
||
text=(
|
||
"✅ Баланс пополнен на "
|
||
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
|
||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||
),
|
||
reply_markup=keyboard,
|
||
)
|
||
logger.info(
|
||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||
user.id,
|
||
)
|
||
else:
|
||
logger.info(
|
||
"У пользователя %s нет сохраненной корзины или автопокупка выполнена",
|
||
user.id,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||
user.id,
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
|
||
logger.info(
|
||
"✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)",
|
||
payment.bill_id,
|
||
payment.user_id,
|
||
trigger,
|
||
)
|
||
|
||
return True
|
||
|
||
|
||
async def get_pal24_payment_status(
|
||
self,
|
||
db: AsyncSession,
|
||
local_payment_id: int,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Запрашивает актуальный статус платежа у Pal24 и синхронизирует локальную запись."""
|
||
try:
|
||
payment_module = import_module("app.services.payment_service")
|
||
|
||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||
if not payment:
|
||
return None
|
||
|
||
remote_status: Optional[str] = None
|
||
remote_payloads: Dict[str, Any] = {}
|
||
payment_info_candidates: List[Dict[str, Optional[str]]] = []
|
||
|
||
service = getattr(self, "pal24_service", None)
|
||
if service and payment.bill_id:
|
||
bill_id_str = str(payment.bill_id)
|
||
try:
|
||
response = await service.get_bill_status(bill_id_str)
|
||
except Pal24APIError as error:
|
||
logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error)
|
||
else:
|
||
if response:
|
||
remote_payloads["bill_status"] = response
|
||
status_value = response.get("status") or (response.get("bill") or {}).get("status")
|
||
if status_value:
|
||
remote_status = str(status_value).upper()
|
||
extracted = self._extract_remote_payment_info(response)
|
||
if extracted:
|
||
payment_info_candidates.append(extracted)
|
||
|
||
if payment.payment_id:
|
||
payment_id_str = str(payment.payment_id)
|
||
try:
|
||
payment_response = await service.get_payment_status(payment_id_str)
|
||
except Pal24APIError as error:
|
||
logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error)
|
||
else:
|
||
if payment_response:
|
||
remote_payloads["payment_status"] = payment_response
|
||
extracted = self._extract_remote_payment_info(payment_response)
|
||
if extracted:
|
||
payment_info_candidates.append(extracted)
|
||
|
||
try:
|
||
payments_response = await service.get_bill_payments(bill_id_str)
|
||
except Pal24APIError as error:
|
||
logger.error("Ошибка Pal24 API при получении списка платежей: %s", error)
|
||
else:
|
||
if payments_response:
|
||
remote_payloads["bill_payments"] = payments_response
|
||
for candidate in self._collect_payment_candidates(payments_response):
|
||
extracted = self._extract_remote_payment_info(candidate)
|
||
if extracted:
|
||
payment_info_candidates.append(extracted)
|
||
|
||
payment_info = self._select_best_payment_info(payment, payment_info_candidates)
|
||
if payment_info:
|
||
remote_payloads.setdefault("selected_payment", payment_info)
|
||
|
||
bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"}
|
||
bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"}
|
||
bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"}
|
||
|
||
update_status = payment.status or "NEW"
|
||
update_kwargs: Dict[str, Any] = {}
|
||
is_paid_update: Optional[bool] = None
|
||
|
||
if remote_status:
|
||
update_status = remote_status
|
||
if remote_status in bill_success:
|
||
is_paid_update = True
|
||
elif remote_status in bill_failed:
|
||
is_paid_update = False
|
||
elif remote_status in bill_pending and is_paid_update is None:
|
||
is_paid_update = False
|
||
|
||
payment_status_code: Optional[str] = None
|
||
if payment_info:
|
||
payment_status_code = (payment_info.get("status") or "").upper() or None
|
||
if payment_status_code:
|
||
existing_status = (getattr(payment, "payment_status", "") or "").upper()
|
||
if payment_status_code != existing_status:
|
||
update_kwargs["payment_status"] = payment_status_code
|
||
|
||
payment_id_value = payment_info.get("id")
|
||
if payment_id_value and payment_id_value != (payment.payment_id or ""):
|
||
update_kwargs["payment_id"] = payment_id_value
|
||
|
||
method_value = payment_info.get("method")
|
||
if method_value:
|
||
normalized_method = self._normalize_payment_method(method_value)
|
||
if normalized_method != (payment.payment_method or ""):
|
||
update_kwargs["payment_method"] = normalized_method
|
||
|
||
balance_amount = payment_info.get("balance_amount")
|
||
if balance_amount and balance_amount != (payment.balance_amount or ""):
|
||
update_kwargs["balance_amount"] = balance_amount
|
||
|
||
balance_currency = payment_info.get("balance_currency")
|
||
if balance_currency and balance_currency != (payment.balance_currency or ""):
|
||
update_kwargs["balance_currency"] = balance_currency
|
||
|
||
payer_account = payment_info.get("account")
|
||
if payer_account and payer_account != (payment.payer_account or ""):
|
||
update_kwargs["payer_account"] = payer_account
|
||
|
||
if payment_status_code:
|
||
success_states = {"SUCCESS", "OVERPAID"}
|
||
failed_states = {"FAIL"}
|
||
pending_states = {"NEW", "PROCESS", "UNDERPAID"}
|
||
if payment_status_code in success_states:
|
||
is_paid_update = True
|
||
elif payment_status_code in failed_states and is_paid_update is not True:
|
||
is_paid_update = False
|
||
elif payment_status_code in pending_states and is_paid_update is None:
|
||
is_paid_update = False
|
||
|
||
if not remote_status and payment_status_code:
|
||
update_status = payment_status_code
|
||
|
||
if is_paid_update is not None and is_paid_update != bool(payment.is_paid):
|
||
update_kwargs["is_paid"] = is_paid_update
|
||
if is_paid_update and not payment.paid_at:
|
||
update_kwargs.setdefault("paid_at", datetime.utcnow())
|
||
|
||
current_status = payment.status or ""
|
||
effective_status = update_status or current_status or "NEW"
|
||
needs_update = bool(update_kwargs) or effective_status != current_status
|
||
|
||
if needs_update:
|
||
payment = await payment_module.update_pal24_payment_status(
|
||
db,
|
||
payment,
|
||
status=effective_status,
|
||
**update_kwargs,
|
||
)
|
||
|
||
remote_status_for_return = remote_status or payment_status_code
|
||
remote_data = remote_payloads or None
|
||
|
||
if payment.is_paid and not payment.transaction_id:
|
||
try:
|
||
finalized = await self._finalize_pal24_payment(
|
||
db,
|
||
payment,
|
||
payment_id=getattr(payment, "payment_id", None),
|
||
trigger="status_check",
|
||
)
|
||
if finalized:
|
||
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка автоматического начисления по Pal24 статусу: %s",
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
|
||
links_map, selected_method = self._build_links_map(payment, remote_payloads)
|
||
primary_url = (
|
||
links_map.get(selected_method)
|
||
or links_map.get("sbp")
|
||
or links_map.get("page")
|
||
or links_map.get("card")
|
||
)
|
||
secondary_url = (
|
||
links_map.get("page")
|
||
or links_map.get("card")
|
||
or links_map.get("sbp")
|
||
)
|
||
|
||
return {
|
||
"payment": payment,
|
||
"status": payment.status,
|
||
"is_paid": payment.is_paid,
|
||
"remote_status": remote_status_for_return,
|
||
"remote_data": remote_data,
|
||
"links": links_map or None,
|
||
"primary_url": primary_url,
|
||
"secondary_url": secondary_url,
|
||
"sbp_url": links_map.get("sbp"),
|
||
"card_url": links_map.get("card"),
|
||
"link_page_url": links_map.get("page")
|
||
or getattr(payment, "link_page_url", None),
|
||
"link_url": getattr(payment, "link_url", None),
|
||
"selected_method": selected_method,
|
||
}
|
||
|
||
except Exception as error:
|
||
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
|
||
return None
|
||
|
||
|
||
@staticmethod
|
||
def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]:
|
||
"""Извлекает данные о платеже из ответа Pal24."""
|
||
|
||
def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]:
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, list):
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
return item
|
||
return None
|
||
|
||
def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||
def _stringify(value: Any) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
return str(value)
|
||
|
||
return {
|
||
"id": _stringify(candidate.get("id") or candidate.get("payment_id")),
|
||
"status": _stringify(candidate.get("status")),
|
||
"method": _stringify(candidate.get("method") or candidate.get("payment_method")),
|
||
"balance_amount": _stringify(
|
||
candidate.get("balance_amount")
|
||
or candidate.get("amount")
|
||
or candidate.get("BalanceAmount")
|
||
),
|
||
"balance_currency": _stringify(
|
||
candidate.get("balance_currency") or candidate.get("BalanceCurrency")
|
||
),
|
||
"account": _stringify(
|
||
candidate.get("account")
|
||
or candidate.get("payer_account")
|
||
or candidate.get("AccountNumber")
|
||
),
|
||
"bill_id": _stringify(
|
||
candidate.get("bill_id")
|
||
or candidate.get("BillId")
|
||
or candidate.get("billId")
|
||
),
|
||
}
|
||
|
||
if not isinstance(remote_data, dict):
|
||
return {}
|
||
|
||
lower_keys = {str(key).lower() for key in remote_data.keys()}
|
||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||
has_identifier = any(
|
||
key in lower_keys
|
||
for key in ("payment_id", "from_card", "account_amount", "id")
|
||
) or "bill_id" in lower_keys
|
||
|
||
if has_status and has_identifier and "bill" not in lower_keys:
|
||
return _normalize(remote_data)
|
||
|
||
search_spaces = [remote_data]
|
||
bill_section = remote_data.get("bill") or remote_data.get("Bill")
|
||
if isinstance(bill_section, dict):
|
||
search_spaces.append(bill_section)
|
||
|
||
for space in search_spaces:
|
||
for key in ("payment", "Payment", "payment_info", "PaymentInfo"):
|
||
candidate = _pick_candidate(space.get(key))
|
||
if candidate:
|
||
return _normalize(candidate)
|
||
for key in ("payments", "Payments"):
|
||
candidate = _pick_candidate(space.get(key))
|
||
if candidate:
|
||
return _normalize(candidate)
|
||
|
||
data_section = remote_data.get("data") or remote_data.get("Data")
|
||
candidate = _pick_candidate(data_section)
|
||
if candidate:
|
||
return _normalize(candidate)
|
||
|
||
return {}
|
||
|
||
@staticmethod
|
||
def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]:
|
||
candidates: List[Dict[str, Any]] = []
|
||
|
||
def _visit(value: Any) -> None:
|
||
if isinstance(value, dict):
|
||
lower_keys = {str(key).lower() for key in value.keys()}
|
||
has_status = any(key in lower_keys for key in ("status", "payment_status"))
|
||
has_identifier = any(
|
||
key in lower_keys
|
||
for key in ("id", "payment_id", "bill_id", "from_card", "account_amount")
|
||
)
|
||
if has_status and has_identifier and value not in candidates:
|
||
candidates.append(value)
|
||
for nested in value.values():
|
||
_visit(nested)
|
||
elif isinstance(value, list):
|
||
for item in value:
|
||
_visit(item)
|
||
|
||
_visit(remote_data)
|
||
return candidates
|
||
|
||
@staticmethod
|
||
def _select_best_payment_info(
|
||
payment: Any,
|
||
candidates: List[Dict[str, Optional[str]]],
|
||
) -> Dict[str, Optional[str]]:
|
||
if not candidates:
|
||
return {}
|
||
|
||
payment_id = str(getattr(payment, "payment_id", "") or "")
|
||
bill_id = str(getattr(payment, "bill_id", "") or "")
|
||
|
||
for candidate in candidates:
|
||
candidate_id = str(candidate.get("id") or "")
|
||
if payment_id and candidate_id == payment_id:
|
||
return candidate
|
||
|
||
for candidate in candidates:
|
||
candidate_bill = str(candidate.get("bill_id") or "")
|
||
if bill_id and candidate_bill == bill_id:
|
||
return candidate
|
||
|
||
return candidates[0]
|
||
|
||
@staticmethod
|
||
def _normalize_payment_method(payment_method: Optional[str]) -> str:
|
||
mapping = {
|
||
"sbp": "sbp",
|
||
"fast": "sbp",
|
||
"fastpay": "sbp",
|
||
"fast_payment": "sbp",
|
||
"card": "card",
|
||
"bank_card": "card",
|
||
"bankcard": "card",
|
||
"bank-card": "card",
|
||
}
|
||
|
||
if not payment_method:
|
||
return "sbp"
|
||
|
||
normalized = payment_method.strip().lower()
|
||
return mapping.get(normalized, "sbp")
|
||
|
||
@staticmethod
|
||
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
|
||
for key in keys:
|
||
value = mapping.get(key)
|
||
if value:
|
||
return str(value)
|
||
return None
|
||
|
||
@classmethod
|
||
def _build_links_map(
|
||
cls,
|
||
payment: Any,
|
||
remote_payloads: Dict[str, Any],
|
||
) -> tuple[Dict[str, str], str]:
|
||
links: Dict[str, str] = {}
|
||
|
||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
|
||
if metadata:
|
||
links_meta = metadata.get("links")
|
||
if isinstance(links_meta, dict):
|
||
for key, value in links_meta.items():
|
||
if value:
|
||
links[key] = str(value)
|
||
|
||
selected_method = cls._normalize_payment_method(
|
||
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
|
||
or getattr(payment, "payment_method", None)
|
||
)
|
||
|
||
def _visit(value: Any) -> List[Dict[str, Any]]:
|
||
stack: List[Any] = [value]
|
||
result: List[Dict[str, Any]] = []
|
||
while stack:
|
||
current = stack.pop()
|
||
if isinstance(current, dict):
|
||
result.append(current)
|
||
stack.extend(current.values())
|
||
elif isinstance(current, list):
|
||
stack.extend(current)
|
||
return result
|
||
|
||
payload_sources: List[Any] = []
|
||
if metadata:
|
||
payload_sources.append(metadata.get("raw_response"))
|
||
payload_sources.append(getattr(payment, "callback_payload", None))
|
||
payload_sources.extend(remote_payloads.values())
|
||
|
||
sbp_keys = (
|
||
"transfer_url",
|
||
"transferUrl",
|
||
"transfer_link",
|
||
"transferLink",
|
||
"transfer",
|
||
"sbp_url",
|
||
"sbpUrl",
|
||
"sbp_link",
|
||
"sbpLink",
|
||
)
|
||
card_keys = (
|
||
"link_url",
|
||
"linkUrl",
|
||
"link",
|
||
"card_url",
|
||
"cardUrl",
|
||
"card_link",
|
||
"cardLink",
|
||
"payment_url",
|
||
"paymentUrl",
|
||
"url",
|
||
)
|
||
page_keys = (
|
||
"link_page_url",
|
||
"linkPageUrl",
|
||
"page_url",
|
||
"pageUrl",
|
||
)
|
||
|
||
for source in payload_sources:
|
||
if not source:
|
||
continue
|
||
for candidate in _visit(source):
|
||
sbp_url = cls._pick_first(candidate, *sbp_keys)
|
||
if sbp_url and "sbp" not in links:
|
||
links["sbp"] = sbp_url
|
||
card_url = cls._pick_first(candidate, *card_keys)
|
||
if card_url and "card" not in links:
|
||
links["card"] = card_url
|
||
page_url = cls._pick_first(candidate, *page_keys)
|
||
if page_url and "page" not in links:
|
||
links["page"] = page_url
|
||
|
||
if getattr(payment, "link_page_url", None):
|
||
links.setdefault("page", str(payment.link_page_url))
|
||
|
||
if getattr(payment, "link_url", None):
|
||
link_url_value = str(payment.link_url)
|
||
if selected_method == "card":
|
||
links.setdefault("card", link_url_value)
|
||
else:
|
||
links.setdefault("sbp", link_url_value)
|
||
|
||
return links, selected_method
|
||
|
||
@staticmethod
|
||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||
|
||
api_mapping = {
|
||
"sbp": "SBP",
|
||
"card": "BANK_CARD",
|
||
}
|
||
|
||
return api_mapping.get(normalized_payment_method)
|