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}]