From 5bde7196dc486467d9a50d820b609919ac6fba1c Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 20:04:59 +0300 Subject: [PATCH] Improve Heleket payment status synchronisation --- app/handlers/balance/heleket.py | 52 +++++++++- app/localization/locales/en.json | 17 ++++ app/localization/locales/ru.json | 17 ++++ app/services/payment/heleket.py | 96 ++++++++++++++++--- .../services/test_payment_service_heleket.py | 95 +++++++++++++++++- 5 files changed, 261 insertions(+), 16 deletions(-) diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index ea98858d..30af3682 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -203,8 +203,56 @@ async def check_heleket_payment_status( await callback.answer("Платёж не найден", show_alert=True) return + language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + if payment.is_paid: - await callback.answer("✅ Платёж уже оплачен", show_alert=True) + message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен") + await callback.answer(message, show_alert=True) return - await callback.answer("Платёж ещё не оплачен", show_alert=True) + payment_service = PaymentService(callback.bot) + updated_payment = await payment_service.sync_heleket_payment_status( + db, + local_payment_id=local_payment_id, + ) + + if updated_payment: + payment = updated_payment + + if payment.is_paid: + message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс") + await callback.answer(message, show_alert=True) + return + + status_normalized = (payment.status or "").lower() + status_messages = { + "check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"), + "process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"), + "confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"), + "wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"), + "wrong_amount_waiting": texts.t( + "HELEKET_STATUS_WRONG_AMOUNT_WAITING", + "❗️ Недостаточная сумма, ожидаем доплату", + ), + "paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"), + "paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"), + "cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"), + "fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"), + "system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"), + "refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"), + "refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"), + "refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"), + "locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"), + } + + message = status_messages.get(status_normalized) + if message is None: + template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}") + status_value = payment.status or status_normalized or "—" + try: + message = template.format(status=status_value) + except Exception: # pragma: no cover - defensive formatting + message = f"ℹ️ Статус платежа: {status_value}" + + await callback.answer(message, show_alert=True) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 27f01504..eee72fe3 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -977,6 +977,23 @@ "PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", "PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)", + "HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited", + "HELEKET_PAYMENT_SUCCESS": "✅ Payment credited to your balance", + "HELEKET_STATUS_CHECK": "⏳ Waiting for payment", + "HELEKET_STATUS_PROCESS": "⚙️ Payment is being processed", + "HELEKET_STATUS_CONFIRM_CHECK": "⛓ Waiting for network confirmations", + "HELEKET_STATUS_WRONG_AMOUNT": "❗️ Incorrect amount paid", + "HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Not enough amount, waiting for additional payment", + "HELEKET_STATUS_PAID_OVER": "✅ Payment credited (overpaid)", + "HELEKET_STATUS_PAID": "✅ Payment credited", + "HELEKET_STATUS_CANCEL": "🚫 Payment was cancelled", + "HELEKET_STATUS_FAIL": "❌ Payment failed", + "HELEKET_STATUS_SYSTEM_FAIL": "❌ Heleket system error", + "HELEKET_STATUS_REFUND_PROCESS": "↩️ Refund in progress", + "HELEKET_STATUS_REFUND_FAIL": "⚠️ Refund failed", + "HELEKET_STATUS_REFUND_PAID": "✅ Refund completed", + "HELEKET_STATUS_LOCKED": "🔒 Funds are locked", + "HELEKET_STATUS_UNKNOWN": "ℹ️ Payment status: {status}", "PAYMENT_METHODS_FOOTER": "Choose a top-up method:", "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Balance top-up methods\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:", "PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index f2fb1a6d..ca6da8ca 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -977,6 +977,23 @@ "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)", + "HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен", + "HELEKET_PAYMENT_SUCCESS": "✅ Платёж зачислен на баланс", + "HELEKET_STATUS_CHECK": "⏳ Ожидаем оплату", + "HELEKET_STATUS_PROCESS": "⚙️ Платёж обрабатывается", + "HELEKET_STATUS_CONFIRM_CHECK": "⛓ Ожидание подтверждений сети", + "HELEKET_STATUS_WRONG_AMOUNT": "❗️ Оплачена неверная сумма", + "HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Недостаточная сумма, ожидаем доплату", + "HELEKET_STATUS_PAID_OVER": "✅ Платёж зачислен (с переплатой)", + "HELEKET_STATUS_PAID": "✅ Платёж зачислен", + "HELEKET_STATUS_CANCEL": "🚫 Платёж отменён", + "HELEKET_STATUS_FAIL": "❌ Ошибка при оплате", + "HELEKET_STATUS_SYSTEM_FAIL": "❌ Системная ошибка Heleket", + "HELEKET_STATUS_REFUND_PROCESS": "↩️ Возврат обрабатывается", + "HELEKET_STATUS_REFUND_FAIL": "⚠️ Ошибка возврата", + "HELEKET_STATUS_REFUND_PAID": "✅ Возврат выполнен", + "HELEKET_STATUS_LOCKED": "🔒 Средства заблокированы", + "HELEKET_STATUS_UNKNOWN": "ℹ️ Статус платежа: {status}", "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:", "PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:", diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 8d7ae5de..12bda996 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -170,14 +170,16 @@ class HeleketPaymentMixin: "discount_percent": discount_percent, } - async def process_heleket_webhook( + async def _process_heleket_payload( self, db: AsyncSession, payload: Dict[str, Any], - ) -> bool: + *, + metadata_key: str, + ) -> Optional["HeleketPayment"]: if not isinstance(payload, dict): logger.error("Heleket webhook payload не является словарём: %s", payload) - return False + return None heleket_crud = import_module("app.database.crud.heleket") payment_module = import_module("app.services.payment_service") @@ -188,7 +190,7 @@ class HeleketPaymentMixin: if not uuid and not order_id: logger.error("Heleket webhook без uuid/order_id: %s", payload) - return False + return None payment = None if uuid: @@ -202,7 +204,7 @@ class HeleketPaymentMixin: uuid, order_id, ) - return False + return None payer_amount = payload.get("payer_amount") or payload.get("payment_amount") payer_currency = payload.get("payer_currency") or payload.get("currency") @@ -244,11 +246,11 @@ class HeleketPaymentMixin: discount_percent=int(discount_percent) if isinstance(discount_percent, (int, float)) else None, paid_at=paid_at, payment_url=payment_url, - metadata={"last_webhook": payload}, + metadata={metadata_key: payload}, ) if updated_payment is None: - return False + return None if updated_payment.transaction_id: logger.info( @@ -256,17 +258,17 @@ class HeleketPaymentMixin: updated_payment.uuid, updated_payment.transaction_id, ) - return True + return updated_payment status_normalized = (status or "").lower() if status_normalized not in {"paid", "paid_over"}: logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status) - return True + return updated_payment amount_kopeks = updated_payment.amount_kopeks if amount_kopeks <= 0: logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount) - return False + return None transaction = await payment_module.create_transaction( db, @@ -286,13 +288,19 @@ class HeleketPaymentMixin: is_completed=True, ) - await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id) + 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("Пользователь %s не найден для Heleket платежа", updated_payment.user_id) - return False + return None old_balance = user.balance_kopeks was_first_topup = not user.has_made_first_topup @@ -374,4 +382,66 @@ class HeleketPaymentMixin: except Exception as error: # pragma: no cover logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error) - return 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, + ) -> Optional["HeleketPayment"]: + 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=%s не найден", local_payment_id) + return 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 платежа %s: %s", payment.uuid, error) + return payment + + if not response: + logger.warning( + "Heleket API вернул пустой ответ при проверке платежа %s", payment.uuid + ) + return payment + + result = response.get("result") if isinstance(response, dict) else None + if not isinstance(result, dict): + logger.error("Некорректный ответ Heleket API при проверке платежа %s: %s", payment.uuid, response) + return payment + + payload: Dict[str, Any] = dict(result) + 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 diff --git a/tests/services/test_payment_service_heleket.py b/tests/services/test_payment_service_heleket.py index d5e1e1f6..26c58100 100644 --- a/tests/services/test_payment_service_heleket.py +++ b/tests/services/test_payment_service_heleket.py @@ -1,6 +1,7 @@ import sys from datetime import datetime from pathlib import Path +from types import SimpleNamespace from typing import Any, Dict, Optional import pytest @@ -39,14 +40,30 @@ class DummyLocalPayment: class StubHeleketService: - def __init__(self, response: Optional[Dict[str, Any]]) -> None: + def __init__( + self, + response: Optional[Dict[str, Any]], + *, + info_response: Optional[Dict[str, Any]] = None, + ) -> None: self.response = response + self.info_response = info_response self.calls: list[Dict[str, Any]] = [] + self.info_calls: list[Dict[str, Optional[str]]] = [] async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: self.calls.append(payload) return self.response + async def get_payment_info( + self, + *, + uuid: Optional[str] = None, + order_id: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + self.info_calls.append({"uuid": uuid, "order_id": order_id}) + return self.info_response + def _make_service(stub: Optional[StubHeleketService]) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] @@ -156,3 +173,79 @@ async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest assert result is None assert called is False + + +@pytest.mark.anyio("asyncio") +async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPatch) -> None: + info_response = { + "state": 0, + "result": { + "uuid": "heleket-uuid", + "order_id": "order-123", + "status": "paid", + "payment_amount": "100.00", + }, + } + stub = StubHeleketService(response=None, info_response=info_response) + service = _make_service(stub) + db = DummySession() + + payment = SimpleNamespace( + id=55, + uuid="heleket-uuid", + order_id="order-123", + status="check", + user_id=7, + ) + + async def fake_get_by_id(db, payment_id): + assert payment_id == payment.id + return payment + + captured: Dict[str, Any] = {} + + async def fake_process(self, db, payload, *, metadata_key): + captured["payload"] = payload + captured["metadata_key"] = metadata_key + return SimpleNamespace(transaction_id=999, **payload) + + monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False) + monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False) + + result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id) + + assert result is not None + assert result.transaction_id == 999 + assert captured["metadata_key"] == "last_status_check" + assert captured["payload"]["uuid"] == payment.uuid + assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}] + + +@pytest.mark.anyio("asyncio") +async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubHeleketService(response=None, info_response=None) + service = _make_service(stub) + db = DummySession() + + payment = SimpleNamespace( + id=12, + uuid="heleket-uuid", + order_id="order-123", + status="check", + user_id=5, + ) + + async def fake_get_by_id(db, payment_id): + assert payment_id == payment.id + return payment + + async def fake_process(*args, **kwargs): # pragma: no cover - ensure not called + raise AssertionError("_process_heleket_payload should not be called") + + monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False) + monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False) + + result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id) + + assert result is payment + assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}]