mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-04 04:43:21 +00:00
Merge pull request #1442 from Fr1ngg/0opqnu-bedolaga/fix-balance-update-issue-with-heleket
Improve Heleket payment status synchronisation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Выберите удобный для вас способ оплаты:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}]
|
||||
|
||||
Reference in New Issue
Block a user