Merge pull request #1427 from Fr1ngg/kbxpuv-bedolaga/add-heleket-balance-top-up-method

Polish Heleket payment integration
This commit is contained in:
Egor
2025-10-21 11:13:09 +03:00
committed by GitHub
10 changed files with 211 additions and 12 deletions

View File

@@ -261,6 +261,22 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# HELEKET
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_BASE_URL=https://api.heleket.com/v1
HELEKET_DEFAULT_CURRENCY=USDT
HELEKET_DEFAULT_NETWORK=
HELEKET_INVOICE_LIFETIME=3600
HELEKET_MARKUP_PERCENT=0
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_HOST=0.0.0.0
HELEKET_WEBHOOK_PORT=8086
HELEKET_CALLBACK_URL=
HELEKET_RETURN_URL=
HELEKET_SUCCESS_URL=
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=

View File

@@ -35,7 +35,7 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
@@ -605,6 +605,13 @@ CRYPTOBOT_ENABLED=false
CRYPTOBOT_API_TOKEN=
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Heleket
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_PORT=8086
# MulenPay
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
@@ -710,6 +717,7 @@ LOG_FILE=logs/bot.log
- 💳 Tribute
- 💳 YooKassa (СБП + банковские карты)
- 💰 CryptoBot (USDT, TON, BTC, ETH и др.)
- 🪙 Heleket (криптовалюта с наценкой)
- 💳 MulenPay (СБП)
- 🏦 PayPalych/Pal24 (СБП + карты)
- 💳 **WATA**
@@ -907,6 +915,7 @@ LOG_FILE=logs/bot.log
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
- **CryptoBot**: Настрой webhook на `https://your-domain.com/cryptobot-webhook`
- **Heleket**: Настрой webhook на `https://your-domain.com/heleket-webhook`
- **MulenPay**: Настрой webhook на `https://your-domain.com/mulenpay-webhook`
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
- **WATA**: Настрой webhook на `https://your-domain.com/wata-webhook`
@@ -1310,7 +1319,7 @@ docker stats
| Метрика | Значение |
|---------|----------|
| 💳 **Платёжных систем** | 7 (Stars, YooKassa, Tribute, CryptoBot, MulenPay, Pal24, WATA) |
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |

View File

@@ -31,15 +31,11 @@ async def start_heleket_payment(
markup = settings.get_heleket_markup_percent()
markup_text: Optional[str]
if markup > 0:
markup_text = texts.t(
"PAYMENT_HELEKET_MARKUP",
f"Наценка провайдера: {markup:.0f}%", # fallback
)
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
markup_text = f"{label}: {markup:.0f}%"
elif markup < 0:
markup_text = texts.t(
"PAYMENT_HELEKET_DISCOUNT",
f"Скидка провайдера: {abs(markup):.0f}%",
)
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
markup_text = f"{label}: {abs(markup):.0f}%"
else:
markup_text = None
@@ -144,8 +140,13 @@ async def process_heleket_payment_amount(
markup_percent = None
if markup_percent:
sign = "+" if markup_percent > 0 else ""
details.append(f"📈 Наценка: {sign}{markup_percent}%")
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f"📈 {label_markup}: +{absolute}%")
else:
details.append(f"📉 {label_discount}: {absolute}%")
if payer_amount and payer_currency:
try:

View File

@@ -976,6 +976,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
"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:",
@@ -983,6 +984,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
@@ -999,6 +1002,8 @@
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",

View File

@@ -976,6 +976,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
@@ -983,6 +984,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
@@ -999,6 +1002,8 @@
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",

View File

@@ -75,6 +75,10 @@ services:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:

View File

@@ -78,6 +78,7 @@ services:
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:

View File

@@ -74,6 +74,9 @@
- `app/database/crud/cryptobot.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/heleket.py` — Python-модуль
Классы: нет
Функции: нет
- `app/database/crud/discount_offer.py` — Python-модуль
Классы: нет
Функции: нет
@@ -158,6 +161,12 @@
- `app/external/cryptobot.py` — Python-модуль
Классы: `CryptoBotService` (2 методов)
Функции: нет
- `app/external/heleket.py` — Python-модуль
Классы: `HeleketService` (3 методов)
Функции: нет
- `app/external/heleket_webhook.py` — Python-модуль
Классы: `HeleketWebhookHandler` (3 методов)
Функции: `create_heleket_app`, `start_heleket_webhook_server`
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
Функции: нет
@@ -726,6 +735,9 @@
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_heleket.py` — Тесты сценариев Heleket в PaymentService.
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubHeleketService` (1 методов)
Функции: `anyio_backend`, `_make_service`
- `tests/services/test_payment_service_mulenpay.py` — Тесты для сценариев MulenPay в PaymentService.
Классы: `DummySession`, `DummyLocalPayment` (1 методов), `StubMulenPayService` (1 методов)
Функции: `anyio_backend`, `_make_service`

View File

@@ -11,6 +11,7 @@ if str(ROOT_DIR) not in sys.path:
from app.services.payment import ( # noqa: E402
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
PaymentCommonMixin,
@@ -30,6 +31,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
YooKassaPaymentMixin,
TributePaymentMixin,
CryptoBotPaymentMixin,
HeleketPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
WataPaymentMixin,
@@ -46,6 +48,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
"create_yookassa_payment",
"create_tribute_payment",
"create_cryptobot_payment",
"create_heleket_payment",
"create_mulenpay_payment",
"create_pal24_payment",
"create_wata_payment",

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace, ModuleType
from typing import Any, Dict
@@ -16,6 +18,7 @@ if str(ROOT_DIR) not in sys.path:
import app.services.payment_service as payment_service_module # noqa: E402
from app.services.payment_service import PaymentService # noqa: E402
from app.database.models import PaymentMethod # noqa: E402
from app.config import settings # noqa: E402
@@ -256,6 +259,146 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
assert admin_calls
@pytest.mark.anyio("asyncio")
async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()
service = _make_service(bot)
fake_session = FakeSession()
payment = SimpleNamespace(
uuid="heleket-uuid",
order_id="heleket-order",
user_id=77,
amount="150.00",
amount_float=150.0,
amount_kopeks=15000,
status="check",
payer_amount=None,
payer_currency=None,
exchange_rate=None,
discount_percent=None,
payment_url=None,
transaction_id=None,
)
async def fake_get_by_uuid(db, uuid):
return payment if uuid == payment.uuid else None
async def fake_get_by_order(db, order_id):
return payment if order_id == payment.order_id else None
async def fake_update(
db,
uuid,
*,
status=None,
payer_amount=None,
payer_currency=None,
exchange_rate=None,
discount_percent=None,
paid_at=None,
payment_url=None,
metadata=None,
):
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
payment.paid_at = paid_at
if metadata:
payment.metadata_json = metadata
return payment
async def fake_link(db, uuid, transaction_id):
payment.transaction_id = transaction_id
return payment
heleket_module = ModuleType("app.database.crud.heleket")
heleket_module.get_heleket_payment_by_uuid = fake_get_by_uuid
heleket_module.get_heleket_payment_by_order_id = fake_get_by_order
heleket_module.update_heleket_payment = fake_update
heleket_module.link_heleket_payment_to_transaction = fake_link
monkeypatch.setitem(sys.modules, "app.database.crud.heleket", heleket_module)
transactions: list[Dict[str, Any]] = []
async def fake_create_transaction(db, **kwargs):
transactions.append(kwargs)
return SimpleNamespace(id=321, **kwargs)
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
user = SimpleNamespace(
id=77,
telegram_id=7700,
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 if user_id == user.id else None
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
monkeypatch.setattr("app.services.payment.heleket.format_referrer_info", lambda u: "")
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_calls: list[Any] = []
class DummyAdminService:
def __init__(self, bot):
self.bot = bot
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: DummyAdminService(bot)),
)
service.build_topup_success_keyboard = AsyncMock(return_value=None)
payload = {
"uuid": "heleket-uuid",
"status": "paid",
"payer_amount": "2.50",
"payer_currency": "USDT",
"discount_percent": -5,
"payer_amount_exchange_rate": "0.0166",
"paid_at": "2024-01-02T12:00:00Z",
"url": "https://pay.example",
}
result = await service.process_heleket_webhook(fake_session, payload)
assert result is True
assert transactions and transactions[0]["payment_method"] == PaymentMethod.HELEKET
assert payment.transaction_id == 321
assert user.balance_kopeks == 15000
assert user.has_made_first_topup is True
assert fake_session.commits >= 1
assert bot.sent_messages
assert admin_calls
referral_stub.process_referral_topup.assert_awaited_once()
@pytest.mark.anyio("asyncio")
async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
bot = DummyBot()