Merge pull request #1442 from Fr1ngg/0opqnu-bedolaga/fix-balance-update-issue-with-heleket

Improve Heleket payment status synchronisation
This commit is contained in:
Egor
2025-10-21 20:05:17 +03:00
committed by GitHub
5 changed files with 261 additions and 16 deletions

View File

@@ -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)

View File

@@ -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": "💳 <b>Balance top-up methods</b>\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:",

View File

@@ -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": "💳 <b>Способы пополнения баланса</b>\n\n⚠ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",

View File

@@ -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

View File

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