diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py
index 2eef6061..af625732 100644
--- a/app/services/payment/pal24.py
+++ b/app/services/payment/pal24.py
@@ -247,15 +247,7 @@ class Pal24PaymentMixin:
return True
if status in {"PAID", "SUCCESS", "OVERPAID"}:
- user = await payment_module.get_user_by_id(db, payment.user_id)
- if not user:
- logger.error(
- "Пользователь %s не найден для Pal24 платежа",
- payment.user_id,
- )
- return False
-
- await payment_module.update_pal24_payment_status(
+ payment = await payment_module.update_pal24_payment_status(
db,
payment,
status=status,
@@ -270,154 +262,22 @@ class Pal24PaymentMixin:
or (payment.metadata_json or {}).get("selected_method")
or getattr(payment, "payment_method", None)
),
+ balance_amount=postback.get("BalanceAmount")
+ or postback.get("balance_amount"),
+ balance_currency=postback.get("BalanceCurrency")
+ or postback.get("balance_currency"),
+ payer_account=postback.get("AccountNumber")
+ or postback.get("account")
+ or postback.get("Account"),
)
- if payment.transaction_id:
- logger.info(
- "Для Pal24 платежа %s уже создана транзакция",
- payment.bill_id,
- )
- return True
-
- transaction = await payment_module.create_transaction(
+ return await self._finalize_pal24_payment(
db,
- user_id=payment.user_id,
- type=TransactionType.DEPOSIT,
- amount_kopeks=payment.amount_kopeks,
- description=f"Пополнение через Pal24 ({payment_id})",
- payment_method=PaymentMethod.PAL24,
- external_id=str(payment_id) if payment_id else payment.bill_id,
- is_completed=True,
+ payment,
+ payment_id=payment_id,
+ trigger="postback",
)
- 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 = getattr(user, "promo_group", None)
- 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)
-
- 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,
- (
- "✅ Пополнение успешно!\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)
- if has_saved_cart and getattr(self, "bot", None):
- # Если у пользователя есть сохраненная корзина,
- # отправляем ему уведомление с кнопкой вернуться к оформлению
- from app.localization.texts import get_texts
-
- texts = get_texts(user.language)
- cart_message = texts.t(
- "BALANCE_TOPUP_CART_REMINDER_DETAILED",
- "🛒 У вас есть неоформленный заказ.\n\n"
- "Вы можете продолжить оформление с теми же параметрами."
- )
-
- # Создаем клавиатуру с кнопками
- keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
- [types.InlineKeyboardButton(
- text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
- callback_data="subscription_resume_checkout"
- )],
- [types.InlineKeyboardButton(
- text="💰 Мой баланс",
- callback_data="menu_balance"
- )],
- [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{cart_message}",
- reply_markup=keyboard
- )
- logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
- except Exception as e:
- logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
-
- logger.info(
- "✅ Обработан Pal24 платеж %s для пользователя %s",
- payment.bill_id,
- payment.user_id,
- )
-
- return True
-
await payment_module.update_pal24_payment_status(
db,
payment,
@@ -431,6 +291,13 @@ class Pal24PaymentMixin:
or postback.get("PaymentMethod")
or getattr(payment, "payment_method", None)
),
+ balance_amount=postback.get("BalanceAmount")
+ or postback.get("balance_amount"),
+ balance_currency=postback.get("BalanceCurrency")
+ or postback.get("balance_currency"),
+ payer_account=postback.get("AccountNumber")
+ or postback.get("account")
+ or postback.get("Account"),
)
logger.info(
"Обновили Pal24 платеж %s до статуса %s",
@@ -443,6 +310,189 @@ class Pal24PaymentMixin:
logger.error("Ошибка обработки Pal24 postback: %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")
+
+ 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 = getattr(user, "promo_group", None)
+ 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,
+ (
+ "✅ Пополнение успешно!\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)
+ if has_saved_cart and getattr(self, "bot", None):
+ 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="resume_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{cart_message}"
+ ),
+ reply_markup=keyboard,
+ )
+ 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,
@@ -456,49 +506,72 @@ class Pal24PaymentMixin:
if not payment:
return None
- remote_status = None
- remote_data = None
+ remote_status: Optional[str] = None
+ remote_data: Optional[Dict[str, Any]] = None
service = getattr(self, "pal24_service", None)
if service and payment.bill_id:
try:
response = await service.get_bill_status(payment.bill_id)
remote_data = response
- remote_status = response.get("status") or response.get(
- "bill", {}
- ).get("status")
+ remote_status = response.get("status") or response.get("bill", {}).get("status")
+
+ payment_info = self._extract_remote_payment_info(response)
if remote_status:
normalized_remote = str(remote_status).upper()
- if normalized_remote != payment.status:
- update_kwargs: Dict[str, Any] = {
- "status": normalized_remote,
- "payment_status": remote_status,
- }
- if normalized_remote in getattr(
- service, "BILL_SUCCESS_STATES", {"SUCCESS"}
- ):
- update_kwargs["is_paid"] = True
- if not payment.paid_at:
- update_kwargs["paid_at"] = datetime.utcnow()
- elif normalized_remote in getattr(
- service, "BILL_FAILED_STATES", {"FAIL"}
- ):
- update_kwargs["is_paid"] = False
+ update_kwargs: Dict[str, Any] = {
+ "status": normalized_remote,
+ "payment_status": payment_info.get("status") or remote_status,
+ }
- await payment_module.update_pal24_payment_status(
- db,
- payment,
- **update_kwargs,
- )
- payment = await payment_module.get_pal24_payment_by_id(
- db, local_payment_id
+ if payment_info.get("id"):
+ update_kwargs["payment_id"] = payment_info["id"]
+ if payment_info.get("method"):
+ update_kwargs["payment_method"] = payment_info["method"]
+ if payment_info.get("balance_amount"):
+ update_kwargs["balance_amount"] = payment_info["balance_amount"]
+ if payment_info.get("balance_currency"):
+ update_kwargs["balance_currency"] = payment_info["balance_currency"]
+ if payment_info.get("account"):
+ update_kwargs["payer_account"] = payment_info["account"]
+
+ if normalized_remote in getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}):
+ update_kwargs["is_paid"] = True
+ if not payment.paid_at:
+ update_kwargs["paid_at"] = datetime.utcnow()
+ elif normalized_remote in getattr(service, "BILL_FAILED_STATES", {"FAIL"}):
+ update_kwargs["is_paid"] = False
+ elif normalized_remote in getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}):
+ update_kwargs.setdefault("is_paid", False)
+
+ payment = await payment_module.update_pal24_payment_status(
+ db,
+ payment,
+ **update_kwargs,
)
except Pal24APIError as error:
logger.error(
"Ошибка Pal24 API при получении статуса: %s", error
)
+ 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,
+ )
+
return {
"payment": payment,
"status": payment.status,
@@ -511,6 +584,65 @@ class Pal24PaymentMixin:
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")
+ ),
+ }
+
+ if not isinstance(remote_data, dict):
+ return {}
+
+ 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)
+
+ return {}
+
@staticmethod
def _normalize_payment_method(payment_method: Optional[str]) -> str:
mapping = {
diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py
index 47e9bf1c..36e1b746 100644
--- a/tests/services/test_payment_service_webhooks.py
+++ b/tests/services/test_payment_service_webhooks.py
@@ -372,6 +372,9 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
transaction_id=None,
is_paid=False,
status="NEW",
+ metadata_json={},
+ payment_method=None,
+ paid_at=None,
)
async def fake_get_by_order(db, order_id):
@@ -439,7 +442,30 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
async def send_balance_topup_notification(self, *args, **kwargs):
admin_calls.append((args, kwargs))
- monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)))
+ monkeypatch.setitem(
+ sys.modules,
+ "app.services.admin_notification_service",
+ SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)),
+ )
+
+ user_cart_stub = SimpleNamespace(
+ user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))
+ )
+ monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
+
+ class DummyTypes:
+ class InlineKeyboardMarkup:
+ def __init__(self, inline_keyboard=None, **kwargs):
+ self.inline_keyboard = inline_keyboard or []
+ self.kwargs = kwargs
+
+ class InlineKeyboardButton:
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
+
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
@@ -458,6 +484,141 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
assert admin_calls
+@pytest.mark.anyio("asyncio")
+async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None:
+ bot = DummyBot()
+ service = _make_service(bot)
+
+ class DummyPal24Service:
+ BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
+ BILL_FAILED_STATES = {"FAIL"}
+ BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
+
+ async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
+ return {
+ "status": "SUCCESS",
+ "bill": {
+ "status": "SUCCESS",
+ "payments": [
+ {
+ "id": "trs-auto-1",
+ "status": "SUCCESS",
+ "method": "SBP",
+ "balance_amount": "50.00",
+ "balance_currency": "RUB",
+ }
+ ],
+ },
+ }
+
+ service.pal24_service = DummyPal24Service()
+
+ fake_session = FakeSession()
+ payment = SimpleNamespace(
+ id=77,
+ bill_id="BILL-AUTO",
+ order_id="order-auto",
+ amount_kopeks=5000,
+ user_id=91,
+ transaction_id=None,
+ is_paid=False,
+ status="NEW",
+ metadata_json={},
+ payment_id=None,
+ payment_method=None,
+ paid_at=None,
+ )
+
+ async def fake_get_payment_by_id(db, local_id):
+ return payment
+
+ async def fake_update_payment(db, payment_obj, **kwargs):
+ for key, value in kwargs.items():
+ setattr(payment, key, value)
+ return payment
+
+ async def fake_link_payment(db, payment_obj, transaction_id):
+ payment.transaction_id = transaction_id
+ return payment
+
+ monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id)
+ monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment)
+ monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment)
+
+ transactions: list[Dict[str, Any]] = []
+
+ async def fake_create_transaction(db, **kwargs):
+ transactions.append(kwargs)
+ payment.transaction_id = 999
+ return SimpleNamespace(id=999, **kwargs)
+
+ monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
+
+ user = SimpleNamespace(
+ id=91,
+ telegram_id=9100,
+ balance_kopeks=0,
+ has_made_first_topup=False,
+ promo_group=None,
+ subscription=None,
+ referred_by_id=None,
+ referrer=None,
+ language="ru",
+ )
+
+ async def fake_get_user(db, user_id):
+ return user
+
+ monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
+ monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
+
+ referral_stub = SimpleNamespace(process_referral_topup=AsyncMock())
+ monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub)
+
+ admin_notifications: list[Any] = []
+
+ class DummyAdminService:
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def send_balance_topup_notification(self, *args, **kwargs):
+ admin_notifications.append((args, kwargs))
+
+ monkeypatch.setitem(
+ sys.modules,
+ "app.services.admin_notification_service",
+ SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
+ )
+
+ user_cart_stub = SimpleNamespace(
+ user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))
+ )
+ monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub)
+
+ class DummyTypes:
+ class InlineKeyboardMarkup:
+ def __init__(self, inline_keyboard=None, **kwargs):
+ self.inline_keyboard = inline_keyboard or []
+ self.kwargs = kwargs
+
+ class InlineKeyboardButton:
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes))
+
+ service.build_topup_success_keyboard = AsyncMock(return_value=None)
+
+ result = await service.get_pal24_payment_status(fake_session, payment.id)
+
+ assert result is not None
+ assert payment.transaction_id == 999
+ assert user.balance_kopeks == 5000
+ assert bot.sent_messages
+ assert admin_notifications
+ assert transactions and transactions[0]["user_id"] == 91
+
@pytest.mark.anyio("asyncio")
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()