From 49ace78a682f65ecf442e1ead6d9c6027d1a5ffd Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 11:12:47 +0300 Subject: [PATCH] Polish Heleket payment integration --- .env.example | 16 ++ README.md | 13 +- app/handlers/balance/heleket.py | 21 +-- app/localization/locales/en.json | 5 + app/localization/locales/ru.json | 5 + docker-compose.local.yml | 4 + docker-compose.yml | 1 + docs/project_structure_reference.md | 12 ++ .../test_payment_service_modularity.py | 3 + .../services/test_payment_service_webhooks.py | 143 ++++++++++++++++++ 10 files changed, 211 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 6fb22015..a18ea009 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 4e685ff2..9a524d80 100644 --- a/README.md +++ b/README.md @@ -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 (коды, группы, предложения, скидки, кампании) | diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 22afc319..ea98858d 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -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: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e5acdda3..27f01504 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -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": "💳 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:", @@ -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": "🪙 Cryptocurrency", + "PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket", + "PAYMENT_METHOD_HELEKET_NAME": "🪙 Cryptocurrency (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Bank card ({mulenpay_name})", "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", @@ -999,6 +1002,8 @@ "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Bank card", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System", "PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 SBP (YooKassa)", + "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", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 627b1a8c..f2fb1a6d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -976,6 +976,7 @@ "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", + "PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)", "PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:", "PAYMENT_METHODS_ONLY_SUPPORT": "💳 Способы пополнения баланса\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": "🪙 Криптовалюта", + "PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket", + "PAYMENT_METHOD_HELEKET_NAME": "🪙 Криптовалюта (Heleket)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}", "PAYMENT_METHOD_MULENPAY_NAME": "💳 Банковская карта ({mulenpay_name})", "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", @@ -999,6 +1002,8 @@ "PAYMENT_METHOD_YOOKASSA_NAME": "💳 Банковская карта", "PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa", "PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 СБП (YooKassa)", + "PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера", + "PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера", "PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную", "PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)", "PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars", diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d7849b7a..5a4444c4 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 4fb43f64..5a4444c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/project_structure_reference.md b/docs/project_structure_reference.md index 4d20e7cb..4a464de2 100644 --- a/docs/project_structure_reference.md +++ b/docs/project_structure_reference.md @@ -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` diff --git a/tests/services/test_payment_service_modularity.py b/tests/services/test_payment_service_modularity.py index 0a20450e..af694020 100644 --- a/tests/services/test_payment_service_modularity.py +++ b/tests/services/test_payment_service_modularity.py @@ -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", diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index c47ef6a6..b33af870 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -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()