From 2f2d9bc1affa28277039eed98068c0acfc01b6a4 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 21 Oct 2025 10:59:19 +0300 Subject: [PATCH] Add Heleket payment provider integration --- app/config.py | 42 ++ app/database/crud/heleket.py | 176 ++++++++ app/database/models.py | 69 +++- app/database/universal_migration.py | 125 ++++++ app/external/heleket.py | 129 ++++++ app/external/heleket_webhook.py | 111 ++++++ app/handlers/balance/heleket.py | 209 ++++++++++ app/handlers/balance/main.py | 30 ++ app/handlers/simple_subscription.py | 215 +++++++++- app/keyboards/inline.py | 8 + app/services/payment/__init__.py | 2 + app/services/payment/heleket.py | 377 ++++++++++++++++++ app/services/payment_service.py | 34 +- app/utils/payment_utils.py | 14 + app/webapi/routes/miniapp.py | 132 +++++- main.py | 37 ++ .../services/test_payment_service_heleket.py | 158 ++++++++ .../services/test_payment_service_mulenpay.py | 1 + tests/services/test_payment_service_pal24.py | 1 + .../services/test_payment_service_tribute.py | 1 + tests/services/test_payment_service_wata.py | 1 + .../services/test_payment_service_webhooks.py | 1 + .../services/test_payment_service_yookassa.py | 1 + 23 files changed, 1870 insertions(+), 4 deletions(-) create mode 100644 app/database/crud/heleket.py create mode 100644 app/external/heleket.py create mode 100644 app/external/heleket_webhook.py create mode 100644 app/handlers/balance/heleket.py create mode 100644 app/services/payment/heleket.py create mode 100644 tests/services/test_payment_service_heleket.py diff --git a/app/config.py b/app/config.py index 5e913589..6a132550 100644 --- a/app/config.py +++ b/app/config.py @@ -206,6 +206,21 @@ class Settings(BaseSettings): CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH" CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24 + HELEKET_ENABLED: bool = False + HELEKET_MERCHANT_ID: Optional[str] = None + HELEKET_API_KEY: Optional[str] = None + HELEKET_BASE_URL: str = "https://api.heleket.com/v1" + HELEKET_DEFAULT_CURRENCY: str = "USDT" + HELEKET_DEFAULT_NETWORK: Optional[str] = None + HELEKET_INVOICE_LIFETIME: int = 3600 + HELEKET_MARKUP_PERCENT: float = 0.0 + HELEKET_WEBHOOK_PATH: str = "/heleket-webhook" + HELEKET_WEBHOOK_HOST: str = "0.0.0.0" + HELEKET_WEBHOOK_PORT: int = 8086 + HELEKET_CALLBACK_URL: Optional[str] = None + HELEKET_RETURN_URL: Optional[str] = None + HELEKET_SUCCESS_URL: Optional[str] = None + MULENPAY_ENABLED: bool = False MULENPAY_API_KEY: Optional[str] = None MULENPAY_SECRET_KEY: Optional[str] = None @@ -786,6 +801,13 @@ class Settings(BaseSettings): return (self.CRYPTOBOT_ENABLED and self.CRYPTOBOT_API_TOKEN is not None) + def is_heleket_enabled(self) -> bool: + return ( + self.HELEKET_ENABLED + and self.HELEKET_MERCHANT_ID is not None + and self.HELEKET_API_KEY is not None + ) + def is_mulenpay_enabled(self) -> bool: return ( self.MULENPAY_ENABLED @@ -834,6 +856,26 @@ class Settings(BaseSettings): def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 + def get_heleket_markup_percent(self) -> float: + try: + return float(self.HELEKET_MARKUP_PERCENT) + except (TypeError, ValueError): + return 0.0 + + def get_heleket_lifetime(self) -> int: + try: + value = int(self.HELEKET_INVOICE_LIFETIME) + except (TypeError, ValueError): + value = 3600 + return max(300, min(43200, value)) + + def get_heleket_callback_url(self) -> Optional[str]: + if self.HELEKET_CALLBACK_URL: + return self.HELEKET_CALLBACK_URL + if self.WEBHOOK_URL: + return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}" + return None + def is_happ_cryptolink_mode(self) -> bool: return self.CONNECT_BUTTON_MODE == "happ_cryptolink" diff --git a/app/database/crud/heleket.py b/app/database/crud/heleket.py new file mode 100644 index 00000000..d02d5e83 --- /dev/null +++ b/app/database/crud/heleket.py @@ -0,0 +1,176 @@ +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import HeleketPayment + +logger = logging.getLogger(__name__) + + +async def create_heleket_payment( + db: AsyncSession, + *, + user_id: int, + uuid: str, + order_id: str, + amount: str, + currency: str, + status: str, + payer_amount: Optional[str] = None, + payer_currency: Optional[str] = None, + exchange_rate: Optional[float] = None, + discount_percent: Optional[int] = None, + payment_url: Optional[str] = None, + expires_at: Optional[datetime] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> HeleketPayment: + payment = HeleketPayment( + user_id=user_id, + uuid=uuid, + order_id=order_id, + amount=amount, + currency=currency, + status=status, + payer_amount=payer_amount, + payer_currency=payer_currency, + exchange_rate=exchange_rate, + discount_percent=discount_percent, + payment_url=payment_url, + expires_at=expires_at, + metadata_json=metadata or {}, + ) + + db.add(payment) + await db.commit() + await db.refresh(payment) + + logger.info( + "Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s", + uuid, + order_id, + amount, + currency, + user_id, + ) + + return payment + + +async def get_heleket_payment_by_uuid( + db: AsyncSession, + uuid: str, +) -> Optional[HeleketPayment]: + result = await db.execute( + select(HeleketPayment) + .options(selectinload(HeleketPayment.user)) + .where(HeleketPayment.uuid == uuid) + ) + return result.scalar_one_or_none() + + +async def get_heleket_payment_by_order_id( + db: AsyncSession, + order_id: str, +) -> Optional[HeleketPayment]: + result = await db.execute( + select(HeleketPayment) + .options(selectinload(HeleketPayment.user)) + .where(HeleketPayment.order_id == order_id) + ) + return result.scalar_one_or_none() + + +async def get_heleket_payment_by_id( + db: AsyncSession, + payment_id: int, +) -> Optional[HeleketPayment]: + result = await db.execute( + select(HeleketPayment) + .options(selectinload(HeleketPayment.user)) + .where(HeleketPayment.id == payment_id) + ) + return result.scalar_one_or_none() + + +async def update_heleket_payment( + db: AsyncSession, + uuid: str, + *, + status: Optional[str] = None, + payer_amount: Optional[str] = None, + payer_currency: Optional[str] = None, + exchange_rate: Optional[float] = None, + discount_percent: Optional[int] = None, + paid_at: Optional[datetime] = None, + payment_url: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Optional[HeleketPayment]: + payment = await get_heleket_payment_by_uuid(db, uuid) + + if not payment: + logger.error("Heleket платеж с uuid=%s не найден", uuid) + return 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 + if metadata is not None: + existing = dict(payment.metadata_json or {}) + existing.update(metadata) + payment.metadata_json = existing + if paid_at is not None: + payment.paid_at = paid_at + + payment.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(payment) + + logger.info( + "Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s", + uuid, + payment.status, + payment.payer_amount, + payment.payer_currency, + ) + + return payment + + +async def link_heleket_payment_to_transaction( + db: AsyncSession, + uuid: str, + transaction_id: int, +) -> Optional[HeleketPayment]: + payment = await get_heleket_payment_by_uuid(db, uuid) + + if not payment: + logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid) + return None + + payment.transaction_id = transaction_id + payment.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(payment) + + logger.info( + "Heleket платеж %s связан с транзакцией %s", + uuid, + transaction_id, + ) + + return payment diff --git a/app/database/models.py b/app/database/models.py index ff79a16f..d296aba9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Any from enum import Enum from sqlalchemy import ( @@ -76,6 +76,7 @@ class PaymentMethod(Enum): TRIBUTE = "tribute" YOOKASSA = "yookassa" CRYPTOBOT = "cryptobot" + HELEKET = "heleket" MULENPAY = "mulenpay" PAL24 = "pal24" WATA = "wata" @@ -190,6 +191,72 @@ class CryptoBotPayment(Base): return f"" +class HeleketPayment(Base): + __tablename__ = "heleket_payments" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + uuid = Column(String(255), unique=True, nullable=False, index=True) + order_id = Column(String(128), unique=True, nullable=False, index=True) + + amount = Column(String(50), nullable=False) + currency = Column(String(10), nullable=False) + payer_amount = Column(String(50), nullable=True) + payer_currency = Column(String(10), nullable=True) + exchange_rate = Column(Float, nullable=True) + discount_percent = Column(Integer, nullable=True) + + status = Column(String(50), nullable=False) + payment_url = Column(Text, nullable=True) + metadata_json = Column(JSON, nullable=True) + + paid_at = Column(DateTime, nullable=True) + expires_at = Column(DateTime, nullable=True) + transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", backref="heleket_payments") + transaction = relationship("Transaction", backref="heleket_payment") + + @property + def amount_float(self) -> float: + try: + return float(self.amount) + except (TypeError, ValueError): + return 0.0 + + @property + def amount_kopeks(self) -> int: + return int(round(self.amount_float * 100)) + + @property + def payer_amount_float(self) -> float: + try: + return float(self.payer_amount) if self.payer_amount is not None else 0.0 + except (TypeError, ValueError): + return 0.0 + + @property + def is_paid(self) -> bool: + return self.status in {"paid", "paid_over"} + + def __repr__(self): + return ( + "" + ).format( + id=self.id, + uuid=self.uuid, + order_id=self.order_id, + amount=self.amount, + currency=self.currency, + status=self.status, + ) + + class MulenPayPayment(Base): __tablename__ = "mulenpay_payments" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index db03e84d..0e140442 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -576,6 +576,120 @@ async def create_cryptobot_payments_table(): return False +async def create_heleket_payments_table(): + table_exists = await check_table_exists('heleket_payments') + if table_exists: + logger.info("Таблица heleket_payments уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE heleket_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + uuid VARCHAR(255) UNIQUE NOT NULL, + order_id VARCHAR(128) UNIQUE NOT NULL, + amount VARCHAR(50) NOT NULL, + currency VARCHAR(10) NOT NULL, + payer_amount VARCHAR(50) NULL, + payer_currency VARCHAR(10) NULL, + exchange_rate DOUBLE PRECISION NULL, + discount_percent INTEGER NULL, + status VARCHAR(50) NOT NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + paid_at DATETIME NULL, + expires_at DATETIME NULL, + transaction_id INTEGER NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id); + CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid); + CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id); + CREATE INDEX idx_heleket_payments_status ON heleket_payments(status); + """ + + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE heleket_payments ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + uuid VARCHAR(255) UNIQUE NOT NULL, + order_id VARCHAR(128) UNIQUE NOT NULL, + amount VARCHAR(50) NOT NULL, + currency VARCHAR(10) NOT NULL, + payer_amount VARCHAR(50) NULL, + payer_currency VARCHAR(10) NULL, + exchange_rate DOUBLE PRECISION NULL, + discount_percent INTEGER NULL, + status VARCHAR(50) NOT NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + paid_at TIMESTAMP NULL, + expires_at TIMESTAMP NULL, + transaction_id INTEGER NULL REFERENCES transactions(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id); + CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid); + CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id); + CREATE INDEX idx_heleket_payments_status ON heleket_payments(status); + """ + + elif db_type == 'mysql': + create_sql = """ + CREATE TABLE heleket_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + uuid VARCHAR(255) UNIQUE NOT NULL, + order_id VARCHAR(128) UNIQUE NOT NULL, + amount VARCHAR(50) NOT NULL, + currency VARCHAR(10) NOT NULL, + payer_amount VARCHAR(50) NULL, + payer_currency VARCHAR(10) NULL, + exchange_rate DOUBLE NULL, + discount_percent INT NULL, + status VARCHAR(50) NOT NULL, + payment_url TEXT NULL, + metadata_json JSON NULL, + paid_at DATETIME NULL, + expires_at DATETIME NULL, + transaction_id INT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) + ); + + CREATE INDEX idx_heleket_payments_user_id ON heleket_payments(user_id); + CREATE INDEX idx_heleket_payments_uuid ON heleket_payments(uuid); + CREATE INDEX idx_heleket_payments_order_id ON heleket_payments(order_id); + CREATE INDEX idx_heleket_payments_status ON heleket_payments(status); + """ + + else: + logger.error(f"Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}") + return False + + await conn.execute(text(create_sql)) + logger.info("Таблица heleket_payments успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы heleket_payments: {e}") + return False + + async def create_mulenpay_payments_table(): table_exists = await check_table_exists('mulenpay_payments') if table_exists: @@ -3427,6 +3541,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей CryptoBot payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===") + heleket_created = await create_heleket_payments_table() + if heleket_created: + logger.info("✅ Таблица Heleket payments готова") + else: + logger.warning("⚠️ Проблемы с таблицей Heleket payments") + mulenpay_name = settings.get_mulenpay_display_name() logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name) mulenpay_created = await create_mulenpay_payments_table() @@ -3695,6 +3816,7 @@ async def run_universal_migration(): logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===") logger.info("✅ Реферальная система обновлена") logger.info("✅ CryptoBot таблица готова") + logger.info("✅ Heleket таблица готова") logger.info("✅ Таблица конверсий подписок создана") logger.info("✅ Таблица welcome_texts с полем is_enabled готова") logger.info("✅ Медиа поля в broadcast_history добавлены") @@ -3712,6 +3834,7 @@ async def check_migration_status(): status = { "has_made_first_topup_column": False, "cryptobot_table": False, + "heleket_table": False, "user_messages_table": False, "welcome_texts_table": False, "welcome_texts_is_enabled_column": False, @@ -3745,6 +3868,7 @@ async def check_migration_status(): status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') status["cryptobot_table"] = await check_table_exists('cryptobot_payments') + status["heleket_table"] = await check_table_exists('heleket_payments') status["user_messages_table"] = await check_table_exists('user_messages') status["welcome_texts_table"] = await check_table_exists('welcome_texts') status["privacy_policies_table"] = await check_table_exists('privacy_policies') @@ -3797,6 +3921,7 @@ async def check_migration_status(): check_names = { "has_made_first_topup_column": "Колонка реферальной системы", "cryptobot_table": "Таблица CryptoBot payments", + "heleket_table": "Таблица Heleket payments", "user_messages_table": "Таблица пользовательских сообщений", "welcome_texts_table": "Таблица приветственных текстов", "privacy_policies_table": "Таблица политик конфиденциальности", diff --git a/app/external/heleket.py b/app/external/heleket.py new file mode 100644 index 00000000..4b11299c --- /dev/null +++ b/app/external/heleket.py @@ -0,0 +1,129 @@ +"""HTTP client for Heleket payment API.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +from typing import Any, Dict, Optional + +import aiohttp + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class HeleketService: + """Minimal wrapper around Heleket API endpoints.""" + + def __init__(self) -> None: + self.base_url = settings.HELEKET_BASE_URL.rstrip("/") + self.merchant_id = settings.HELEKET_MERCHANT_ID + self.api_key = settings.HELEKET_API_KEY + + @property + def is_configured(self) -> bool: + return bool(self.merchant_id and self.api_key) + + def _prepare_body(self, payload: Dict[str, Any]) -> str: + cleaned = {key: value for key, value in payload.items() if value is not None} + serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + if "/" in serialized: + serialized = serialized.replace("/", "\\/") + return serialized + + def _generate_signature(self, body: str) -> str: + api_key = self.api_key or "" + encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8") + raw = f"{encoded}{api_key}" + return hashlib.md5(raw.encode("utf-8")).hexdigest() + + async def _request(self, endpoint: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not self.is_configured: + logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют") + return None + + body = self._prepare_body(payload) + signature = self._generate_signature(body) + + url = f"{self.base_url}/{endpoint.lstrip('/')}" + headers = { + "merchant": self.merchant_id or "", + "sign": signature, + "Content-Type": "application/json", + } + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, data=body.encode("utf-8"), headers=headers) as response: + text = await response.text() + if response.content_type != "application/json": + logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text) + return None + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.error("Ошибка парсинга Heleket JSON: %s", text) + return None + + if response.status >= 400: + logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data) + return None + + if isinstance(data, dict) and data.get("state") == 0: + return data + + logger.error("Heleket API вернул ошибку: %s", data) + return None + except Exception as error: # pragma: no cover - defensive + logger.error("Ошибка запроса к Heleket API: %s", error) + return None + + async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return await self._request("payment", payload) + + async def get_payment_info( + self, + *, + uuid: Optional[str] = None, + order_id: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + if not uuid and not order_id: + raise ValueError("Нужно указать uuid или order_id для Heleket payment/info") + + payload: Dict[str, Any] = {} + if uuid: + payload["uuid"] = uuid + if order_id: + payload["order_id"] = order_id + + return await self._request("payment/info", payload) + + def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool: + if not self.is_configured: + logger.warning("Heleket сервис не настроен, подпись пропускается") + return True + + if not isinstance(payload, dict): + logger.error("Heleket webhook payload не dict: %s", payload) + return False + + signature = payload.get("sign") + if not signature: + logger.error("Heleket webhook без подписи") + return False + + data = {key: value for key, value in payload.items() if key != "sign"} + body = self._prepare_body(data) + expected = self._generate_signature(body) + + is_valid = hmac.compare_digest(expected, str(signature)) + + if not is_valid: + logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature) + return is_valid diff --git a/app/external/heleket_webhook.py b/app/external/heleket_webhook.py new file mode 100644 index 00000000..ace0fd90 --- /dev/null +++ b/app/external/heleket_webhook.py @@ -0,0 +1,111 @@ +import asyncio +import json +import logging +from typing import Any, Dict, Optional + +from aiohttp import web + +from app.config import settings +from app.database.database import get_db +from app.external.heleket import HeleketService +from app.services.payment_service import PaymentService + +logger = logging.getLogger(__name__) + + +class HeleketWebhookHandler: + def __init__(self, payment_service: PaymentService) -> None: + self.payment_service = payment_service + self.service = HeleketService() + + async def handle(self, request: web.Request) -> web.Response: + if not settings.is_heleket_enabled(): + logger.warning("Получен Heleket webhook, но сервис отключен") + return web.json_response({"status": "error", "reason": "disabled"}, status=503) + + try: + payload: Dict[str, Any] = await request.json() + except json.JSONDecodeError: + logger.error("Некорректный JSON Heleket webhook") + return web.json_response({"status": "error", "reason": "invalid_json"}, status=400) + + if not self.service.verify_webhook_signature(payload): + return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) + + processed: Optional[bool] = None + async for db in get_db(): + processed = await self.payment_service.process_heleket_webhook(db, payload) + + if processed: + return web.json_response({"status": "ok"}, status=200) + + return web.json_response({"status": "error", "reason": "not_processed"}, status=400) + + async def health_check(self, _: web.Request) -> web.Response: + return web.json_response( + { + "status": "ok", + "service": "heleket_webhook", + "enabled": settings.is_heleket_enabled(), + "path": settings.HELEKET_WEBHOOK_PATH, + } + ) + + async def options_handler(self, _: web.Request) -> web.Response: + return web.Response( + status=200, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + ) + + +def create_heleket_app(payment_service: PaymentService) -> web.Application: + handler = HeleketWebhookHandler(payment_service) + app = web.Application() + app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle) + app.router.add_get("/heleket/health", handler.health_check) + app.router.add_get("/health", handler.health_check) + app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler) + return app + + +async def start_heleket_webhook_server(payment_service: PaymentService) -> None: + if not settings.is_heleket_enabled(): + logger.info("Heleket отключен, webhook сервер не запускается") + return + + app = create_heleket_app(payment_service) + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite( + runner, + host=settings.HELEKET_WEBHOOK_HOST, + port=settings.HELEKET_WEBHOOK_PORT, + ) + + try: + await site.start() + logger.info( + "Heleket webhook сервер запущен на %s:%s", + settings.HELEKET_WEBHOOK_HOST, + settings.HELEKET_WEBHOOK_PORT, + ) + logger.info( + "Heleket webhook URL: http://%s:%s%s", + settings.HELEKET_WEBHOOK_HOST, + settings.HELEKET_WEBHOOK_PORT, + settings.HELEKET_WEBHOOK_PATH, + ) + + while True: + await asyncio.sleep(1) + except asyncio.CancelledError: + logger.info("Heleket webhook сервер остановлен по запросу") + finally: + await site.stop() + await runner.cleanup() + logger.info("Heleket webhook сервер корректно остановлен") diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py new file mode 100644 index 00000000..22afc319 --- /dev/null +++ b/app/handlers/balance/heleket.py @@ -0,0 +1,209 @@ +import logging +from typing import Optional + +from aiogram import types +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_texts +from app.services.payment_service import PaymentService +from app.states import BalanceStates +from app.utils.decorators import error_handler + +logger = logging.getLogger(__name__) + + +@error_handler +async def start_heleket_payment( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +) -> None: + texts = get_texts(db_user.language) + + if not settings.is_heleket_enabled(): + await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True) + return + + markup = settings.get_heleket_markup_percent() + markup_text: Optional[str] + if markup > 0: + markup_text = texts.t( + "PAYMENT_HELEKET_MARKUP", + f"Наценка провайдера: {markup:.0f}%", # fallback + ) + elif markup < 0: + markup_text = texts.t( + "PAYMENT_HELEKET_DISCOUNT", + f"Скидка провайдера: {abs(markup):.0f}%", + ) + else: + markup_text = None + + message_lines = [ + "🪙 Пополнение через Heleket", + "\n", + "Введите сумму пополнения от 100 до 100,000 ₽:", + "", + "⚡ Мгновенное зачисление", "🔒 Безопасная оплата", + ] + + if markup_text: + message_lines.extend(["", markup_text]) + + keyboard = get_back_keyboard(db_user.language) + + if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS: + from .main import get_quick_amount_buttons + + quick_buttons = get_quick_amount_buttons(db_user.language) + if quick_buttons: + keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard + + await callback.message.edit_text( + "\n".join(filter(None, message_lines)), + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method="heleket") + await callback.answer() + + +@error_handler +async def process_heleket_payment_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + state: FSMContext, +) -> None: + texts = get_texts(db_user.language) + + if not settings.is_heleket_enabled(): + await message.answer("❌ Оплата через Heleket недоступна") + return + + amount_rubles = amount_kopeks / 100 + + if amount_rubles < 100: + await message.answer("Минимальная сумма пополнения: 100 ₽") + return + + if amount_rubles > 100000: + await message.answer("Максимальная сумма пополнения: 100,000 ₽") + return + + payment_service = PaymentService(message.bot) + + result = await payment_service.create_heleket_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=f"Пополнение баланса на {amount_rubles:.0f} ₽", + language=db_user.language, + ) + + if not result: + await message.answer( + "❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку." + ) + await state.clear() + return + + payment_url = result.get("payment_url") + if not payment_url: + await message.answer("❌ Не удалось получить ссылку для оплаты Heleket") + await state.clear() + return + + payer_amount = result.get("payer_amount") + payer_currency = result.get("payer_currency") + exchange_rate = result.get("exchange_rate") + discount_percent = result.get("discount_percent") + + details = [ + "🪙 Оплата через Heleket", + "", + f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽", + ] + + if payer_amount and payer_currency: + details.append(f"🪙 К оплате: {payer_amount} {payer_currency}") + + markup_percent: Optional[float] = None + if discount_percent is not None: + try: + discount_int = int(discount_percent) + markup_percent = -discount_int + except (TypeError, ValueError): + markup_percent = None + + if markup_percent: + sign = "+" if markup_percent > 0 else "" + details.append(f"📈 Наценка: {sign}{markup_percent}%") + + if payer_amount and payer_currency: + try: + payer_amount_float = float(payer_amount) + if payer_amount_float > 0: + rub_per_currency = amount_rubles / payer_amount_float + details.append( + f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽" + ) + except (TypeError, ValueError, ZeroDivisionError): + pass + + details.extend( + [ + "", + "📱 Инструкция:", + "1. Нажмите кнопку 'Оплатить'", + "2. Перейдите на страницу Heleket", + "3. Оплатите указанную сумму", + "4. Баланс пополнится автоматически", + ] + ) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)], + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_heleket_{result['local_payment_id']}" + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + ]) + + await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard) + await state.clear() + + +@error_handler +async def check_heleket_payment_status( + callback: types.CallbackQuery, + db: AsyncSession, +) -> None: + try: + local_payment_id = int(callback.data.split("_")[-1]) + except (ValueError, IndexError): + await callback.answer("Некорректный идентификатор платежа", show_alert=True) + return + + from app.database.crud.heleket import get_heleket_payment_by_id + + payment = await get_heleket_payment_by_id(db, local_payment_id) + if not payment: + await callback.answer("Платёж не найден", show_alert=True) + return + + if payment.is_paid: + await callback.answer("✅ Платёж уже оплачен", show_alert=True) + return + + await callback.answer("Платёж ещё не оплачен", show_alert=True) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 09ef6948..a1d113ba 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -406,6 +406,11 @@ async def process_topup_amount( from .cryptobot import process_cryptobot_payment_amount async with AsyncSessionLocal() as db: await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state) + elif payment_method == "heleket": + from app.database.database import AsyncSessionLocal + from .heleket import process_heleket_payment_amount + async with AsyncSessionLocal() as db: + await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state) else: await message.answer("Неизвестный способ оплаты") @@ -519,6 +524,14 @@ async def handle_quick_amount_selection( await process_cryptobot_payment_amount( callback.message, db_user, db, amount_kopeks, state ) + elif payment_method == "heleket": + from app.database.database import AsyncSessionLocal + from .heleket import process_heleket_payment_amount + + async with AsyncSessionLocal() as db: + await process_heleket_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) elif payment_method == "stars": from .stars import process_stars_payment_amount @@ -589,6 +602,13 @@ async def handle_topup_amount_callback( await process_cryptobot_payment_amount( callback.message, db_user, db, amount_kopeks, state ) + elif method == "heleket": + from app.database.database import AsyncSessionLocal + from .heleket import process_heleket_payment_amount + async with AsyncSessionLocal() as db: + await process_heleket_payment_amount( + callback.message, db_user, db, amount_kopeks, state + ) elif method == "stars": from .stars import process_stars_payment_amount await process_stars_payment_amount( @@ -712,6 +732,16 @@ def register_balance_handlers(dp: Dispatcher): F.data.startswith("check_cryptobot_") ) + from .heleket import start_heleket_payment, check_heleket_payment_status + dp.callback_query.register( + start_heleket_payment, + F.data == "topup_heleket" + ) + dp.callback_query.register( + check_heleket_payment_status, + F.data.startswith("check_heleket_") + ) + from .mulenpay import check_mulenpay_payment_status dp.callback_query.register( check_mulenpay_payment_status, diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 36e42c9a..99028723 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -211,6 +211,12 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb text="🪙 CryptoBot", callback_data="simple_subscription_cryptobot" )]) + + if settings.is_heleket_enabled(): + keyboard.append([types.InlineKeyboardButton( + text="🪙 Heleket", + callback_data="simple_subscription_heleket" + )]) if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() @@ -983,7 +989,121 @@ async def handle_simple_subscription_payment_method( await state.clear() await callback.answer() return - + + elif payment_method == "heleket": + if not settings.is_heleket_enabled(): + await callback.answer("❌ Оплата через Heleket временно недоступна", show_alert=True) + return + + amount_rubles = price_kopeks / 100 + if amount_rubles < 100 or amount_rubles > 100000: + await callback.answer( + "❌ Сумма должна быть от 100 до 100 000 ₽ для оплаты через Heleket", + show_alert=True, + ) + return + + heleket_result = await payment_service.create_heleket_payment( + db=db, + user_id=db_user.id, + amount_kopeks=price_kopeks, + description=settings.get_subscription_payment_description( + subscription_params["period_days"], + price_kopeks, + ), + language=db_user.language, + ) + + if not heleket_result: + await callback.answer( + "❌ Ошибка создания платежа Heleket. Попробуйте позже или обратитесь в поддержку.", + show_alert=True, + ) + return + + payment_url = heleket_result.get("payment_url") + if not payment_url: + await callback.answer( + "❌ Не удалось получить ссылку для оплаты Heleket. Обратитесь в поддержку.", + show_alert=True, + ) + return + + local_payment_id = heleket_result.get("local_payment_id") + payer_amount = heleket_result.get("payer_amount") + payer_currency = heleket_result.get("payer_currency") + discount_percent = heleket_result.get("discount_percent") + + markup_percent = None + if discount_percent is not None: + try: + markup_percent = -int(discount_percent) + except (TypeError, ValueError): + markup_percent = None + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🪙 Оплатить через Heleket", + url=payment_url, + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_simple_heleket_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + ] + ) + + message_lines = [ + "🪙 Оплата через Heleket", + "", + f"💰 Сумма: {settings.format_price(price_kopeks)}", + ] + + if payer_amount and payer_currency: + message_lines.append(f"🪙 К оплате: {payer_amount} {payer_currency}") + try: + payer_amount_float = float(payer_amount) + if payer_amount_float > 0: + rub_per_currency = amount_rubles / payer_amount_float + message_lines.append( + f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽" + ) + except (TypeError, ValueError, ZeroDivisionError): + pass + + if markup_percent: + sign = "+" if markup_percent > 0 else "" + message_lines.append(f"📈 Наценка: {sign}{markup_percent}%") + + message_lines.extend( + [ + "", + "📱 Инструкция:", + "1. Нажмите кнопку 'Оплатить через Heleket'", + "2. Следуйте подсказкам на странице оплаты", + "3. Подтвердите перевод", + "4. Средства зачислятся автоматически", + "", + f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", + ] + ) + + await callback.message.edit_text( + "\n".join(message_lines), + reply_markup=keyboard, + parse_mode="HTML", + ) + + await state.clear() + await callback.answer() + return + elif payment_method == "mulenpay": # Оплата через MulenPay mulenpay_name = settings.get_mulenpay_display_name() @@ -1598,6 +1718,94 @@ async def check_simple_cryptobot_payment_status( ) +@error_handler +async def check_simple_heleket_payment_status( + callback: types.CallbackQuery, + db: AsyncSession, +): + try: + local_payment_id = int(callback.data.rsplit('_', 1)[-1]) + except (ValueError, IndexError): + await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + return + + from app.database.crud.heleket import get_heleket_payment_by_id + + payment = await get_heleket_payment_by_id(db, local_payment_id) + if not payment: + await callback.answer("❌ Платеж не найден", show_alert=True) + return + + status_labels = { + "check": ("⏳", "Ожидает оплаты"), + "paid": ("✅", "Оплачен"), + "paid_over": ("✅", "Оплачен (переплата)"), + "wrong_amount": ("⚠️", "Неверная сумма"), + "cancel": ("❌", "Отменен"), + "fail": ("❌", "Ошибка"), + "process": ("⌛", "Обрабатывается"), + "confirm_check": ("⌛", "Ожидает подтверждения"), + } + + emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + + language = settings.DEFAULT_LANGUAGE + try: + from app.services.payment_service import get_user_by_id as fetch_user_by_id + + user = await fetch_user_by_id(db, payment.user_id) + if user and getattr(user, "language", None): + language = user.language + except Exception as error: + logger.debug("Не удалось получить пользователя для Heleket статуса: %s", error) + + texts = get_texts(language) + + message_lines = [ + "🪙 Статус платежа Heleket:", + "", + f"🆔 UUID: {payment.uuid[:8]}...", + f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", + f"📊 Статус: {emoji} {status_text}", + f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}", + ] + + if payment.payer_amount and payment.payer_currency: + message_lines.append( + f"🪙 Оплата: {payment.payer_amount} {payment.payer_currency}" + ) + + if payment.is_paid: + message_lines.append("\n✅ Платеж успешно завершен! Средства уже зачислены.") + elif payment.status in {"check", "process", "confirm_check"}: + message_lines.append("\n⏳ Платеж еще обрабатывается. Завершите оплату и проверьте статус позже.") + if payment.payment_url: + message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}") + elif payment.status in {"fail", "cancel", "wrong_amount"}: + message_lines.append( + f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" + ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), + callback_data=f"check_simple_heleket_{local_payment_id}", + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + ] + ) + + await callback.answer() + await callback.message.edit_text( + "\n".join(message_lines), + reply_markup=keyboard, + parse_mode="HTML", + ) + + @error_handler async def check_simple_wata_payment_status( callback: types.CallbackQuery, @@ -1703,6 +1911,11 @@ def register_simple_subscription_handlers(dp): F.data.startswith("check_simple_cryptobot_") ) + dp.callback_query.register( + check_simple_heleket_payment_status, + F.data.startswith("check_simple_heleket_") + ) + dp.callback_query.register( check_simple_wata_payment_status, F.data.startswith("check_simple_wata_") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index d9aa7fe7..471d26ee 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1140,6 +1140,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) ]) + if settings.is_heleket_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"), + callback_data=_build_callback("heleket") + ) + ]) + keyboard.append([ InlineKeyboardButton( text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"), diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 71719bfb..93fe7d48 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -9,6 +9,7 @@ from .stars import TelegramStarsMixin from .yookassa import YooKassaPaymentMixin from .tribute import TributePaymentMixin from .cryptobot import CryptoBotPaymentMixin +from .heleket import HeleketPaymentMixin from .mulenpay import MulenPayPaymentMixin from .pal24 import Pal24PaymentMixin from .wata import WataPaymentMixin @@ -19,6 +20,7 @@ __all__ = [ "YooKassaPaymentMixin", "TributePaymentMixin", "CryptoBotPaymentMixin", + "HeleketPaymentMixin", "MulenPayPaymentMixin", "Pal24PaymentMixin", "WataPaymentMixin", diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py new file mode 100644 index 00000000..8d7ae5de --- /dev/null +++ b/app/services/payment/heleket.py @@ -0,0 +1,377 @@ +"""Mixin with Heleket payment flow implementation.""" + +from __future__ import annotations + +import logging +import secrets +import time +from datetime import datetime +from importlib import import_module +from typing import Any, Dict, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import PaymentMethod, TransactionType +from app.utils.user_utils import format_referrer_info + +logger = logging.getLogger(__name__) + + +class HeleketPaymentMixin: + """Provides helpers to create and process Heleket payments.""" + + async def create_heleket_payment( + self, + db: AsyncSession, + user_id: int, + amount_kopeks: int, + description: str, + *, + language: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + if not getattr(self, "heleket_service", None): + logger.error("Heleket сервис не инициализирован") + return None + + if amount_kopeks <= 0: + logger.error("Сумма Heleket должна быть положительной: %s", amount_kopeks) + return None + + amount_rubles = amount_kopeks / 100 + amount_str = f"{amount_rubles:.2f}" + + order_id = f"heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}" + + markup_percent = settings.get_heleket_markup_percent() + discount_percent: Optional[int] = None + if markup_percent: + try: + rounded = int(round(markup_percent)) + if rounded != 0: + discount_percent = -rounded + except (TypeError, ValueError): + logger.warning("Некорректная наценка Heleket: %s", markup_percent) + + payload: Dict[str, Any] = { + "amount": amount_str, + "currency": "RUB", + "order_id": order_id, + "lifetime": settings.get_heleket_lifetime(), + } + + to_currency = (settings.HELEKET_DEFAULT_CURRENCY or "").strip() + if to_currency: + payload["to_currency"] = to_currency + + network = (settings.HELEKET_DEFAULT_NETWORK or "").strip() + if network: + payload["network"] = network + + callback_url = settings.get_heleket_callback_url() + if callback_url: + payload["url_callback"] = callback_url + + if settings.HELEKET_RETURN_URL: + payload["url_return"] = settings.HELEKET_RETURN_URL + if settings.HELEKET_SUCCESS_URL: + payload["url_success"] = settings.HELEKET_SUCCESS_URL + + if discount_percent is not None: + payload["discount_percent"] = discount_percent + + metadata: Dict[str, Any] = { + "language": language or settings.DEFAULT_LANGUAGE, + "created_at": datetime.utcnow().isoformat(), + } + + try: + response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr] + except Exception as error: # pragma: no cover - safety net + logger.exception("Ошибка создания Heleket платежа: %s", error) + return None + + if not response: + logger.error("Heleket API вернул пустой ответ при создании платежа") + return None + + payment_result = response.get("result") if isinstance(response, dict) else None + if not payment_result: + logger.error("Некорректный ответ Heleket API: %s", response) + return None + + uuid = str(payment_result.get("uuid")) + response_order_id = payment_result.get("order_id") + if response_order_id: + order_id = str(response_order_id) + + url = payment_result.get("url") + status = payment_result.get("status") or payment_result.get("payment_status") or "check" + payer_amount = payment_result.get("payer_amount") + payer_currency = payment_result.get("payer_currency") + exchange_rate = payment_result.get("payer_amount_exchange_rate") + + try: + exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None + except (TypeError, ValueError): + exchange_rate_value = None + + if exchange_rate_value is None and payer_amount: + try: + exchange_rate_value = float(payer_amount) / amount_rubles if amount_rubles else None + except (TypeError, ValueError, ZeroDivisionError): + exchange_rate_value = None + + expires_at_raw = payment_result.get("expired_at") + expires_at: Optional[datetime] = None + if expires_at_raw: + try: + expires_at = datetime.fromtimestamp(int(expires_at_raw)) + except (TypeError, ValueError, OSError): + expires_at = None + + heleket_crud = import_module("app.database.crud.heleket") + + local_payment = await heleket_crud.create_heleket_payment( + db=db, + user_id=user_id, + uuid=uuid, + order_id=order_id, + amount=amount_str, + currency="RUB", + status=status, + payer_amount=payer_amount, + payer_currency=payer_currency, + exchange_rate=exchange_rate_value, + discount_percent=discount_percent, + payment_url=url, + expires_at=expires_at, + metadata={"raw_response": payment_result, **metadata}, + ) + + logger.info( + "Создан Heleket платеж %s на %s₽ для пользователя %s", + uuid, + amount_str, + user_id, + ) + + return { + "local_payment_id": local_payment.id, + "uuid": uuid, + "order_id": order_id, + "amount": amount_str, + "amount_kopeks": amount_kopeks, + "payment_url": url, + "status": status, + "payer_amount": payer_amount, + "payer_currency": payer_currency, + "exchange_rate": exchange_rate_value, + "discount_percent": discount_percent, + } + + async def process_heleket_webhook( + self, + db: AsyncSession, + payload: Dict[str, Any], + ) -> bool: + if not isinstance(payload, dict): + logger.error("Heleket webhook payload не является словарём: %s", payload) + return False + + heleket_crud = import_module("app.database.crud.heleket") + payment_module = import_module("app.services.payment_service") + + uuid = str(payload.get("uuid") or "").strip() + order_id = str(payload.get("order_id") or "").strip() + status = payload.get("status") or payload.get("payment_status") + + if not uuid and not order_id: + logger.error("Heleket webhook без uuid/order_id: %s", payload) + return False + + payment = None + if uuid: + payment = await heleket_crud.get_heleket_payment_by_uuid(db, uuid) + if payment is None and order_id: + payment = await heleket_crud.get_heleket_payment_by_order_id(db, order_id) + + if not payment: + logger.error( + "Heleket платеж не найден (uuid=%s order_id=%s)", + uuid, + order_id, + ) + return False + + payer_amount = payload.get("payer_amount") or payload.get("payment_amount") + payer_currency = payload.get("payer_currency") or payload.get("currency") + discount_percent = payload.get("discount_percent") + exchange_rate_raw = payload.get("payer_amount_exchange_rate") + payment_url = payload.get("url") + + exchange_rate: Optional[float] = None + if exchange_rate_raw is not None: + try: + exchange_rate = float(exchange_rate_raw) + except (TypeError, ValueError): + exchange_rate = None + + if exchange_rate is None and payer_amount: + try: + exchange_rate = float(payer_amount) / payment.amount_float if payment.amount_float else None + except (TypeError, ValueError, ZeroDivisionError): + exchange_rate = None + + paid_at: Optional[datetime] = None + paid_at_raw = payload.get("paid_at") or payload.get("updated_at") + if paid_at_raw: + try: + if isinstance(paid_at_raw, (int, float)): + paid_at = datetime.utcfromtimestamp(float(paid_at_raw)) + else: + paid_at = datetime.fromisoformat(str(paid_at_raw).replace("Z", "+00:00")) + except (ValueError, TypeError): + paid_at = None + + updated_payment = await heleket_crud.update_heleket_payment( + db, + payment.uuid, + status=status, + payer_amount=str(payer_amount) if payer_amount is not None else None, + payer_currency=str(payer_currency) if payer_currency is not None else None, + exchange_rate=exchange_rate, + 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}, + ) + + if updated_payment is None: + return False + + if updated_payment.transaction_id: + logger.info( + "Heleket платеж %s уже связан с транзакцией %s", + updated_payment.uuid, + updated_payment.transaction_id, + ) + return True + + status_normalized = (status or "").lower() + if status_normalized not in {"paid", "paid_over"}: + logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status) + return True + + amount_kopeks = updated_payment.amount_kopeks + if amount_kopeks <= 0: + logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount) + return False + + transaction = await payment_module.create_transaction( + db, + user_id=updated_payment.user_id, + type=TransactionType.DEPOSIT, + amount_kopeks=amount_kopeks, + description=( + "Пополнение через Heleket" + if not updated_payment.payer_currency + else ( + "Пополнение через Heleket " + f"({updated_payment.payer_amount} {updated_payment.payer_currency})" + ) + ), + payment_method=PaymentMethod.HELEKET, + external_id=updated_payment.uuid, + is_completed=True, + ) + + await heleket_crud.link_heleket_payment_to_transaction(db, updated_payment.uuid, transaction.id) + + 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 + + old_balance = user.balance_kopeks + was_first_topup = not user.has_made_first_topup + + user.balance_kopeks += amount_kopeks + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + try: + from app.services.referral_service import process_referral_topup + + await process_referral_topup( + db, + user.id, + amount_kopeks, + getattr(self, "bot", None), + ) + except Exception as error: # pragma: no cover - defensive + logger.error("Ошибка реферального начисления Heleket: %s", error) + + if was_first_topup and not user.has_made_first_topup: + user.has_made_first_topup = True + await db.commit() + await db.refresh(user) + + if getattr(self, "bot", None): + topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + referrer_info = format_referrer_info(user) + subscription = getattr(user, "subscription", None) + promo_group = getattr(user, "promo_group", None) + + try: + from app.services.admin_notification_service import AdminNotificationService + + notification_service = AdminNotificationService(self.bot) + await notification_service.send_balance_topup_notification( + user, + transaction, + old_balance, + topup_status=topup_status, + referrer_info=referrer_info, + subscription=subscription, + promo_group=promo_group, + db=db, + ) + except Exception as error: # pragma: no cover + logger.error("Ошибка отправки админ-уведомления Heleket: %s", error) + + try: + keyboard = await self.build_topup_success_keyboard(user) + + exchange_rate_value = updated_payment.exchange_rate or 0 + rate_text = ( + f"💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}" + if exchange_rate_value and updated_payment.payer_currency + else None + ) + + message_lines = [ + "✅ Пополнение успешно!", + f"💰 Сумма: {settings.format_price(amount_kopeks)}", + "💳 Способ: Heleket", + ] + if updated_payment.payer_amount and updated_payment.payer_currency: + message_lines.append( + f"🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}" + ) + if rate_text: + message_lines.append(rate_text) + + await self.bot.send_message( + chat_id=user.telegram_id, + text="\n".join(message_lines), + parse_mode="HTML", + reply_markup=keyboard, + ) + except Exception as error: # pragma: no cover + logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error) + + return True diff --git a/app/services/payment_service.py b/app/services/payment_service.py index b65ca6ff..23ffb9c2 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -11,11 +11,13 @@ from aiogram import Bot from app.config import settings from app.utils.currency_converter import currency_converter # noqa: F401 from app.external.cryptobot import CryptoBotService +from app.external.heleket import HeleketService from app.external.telegram_stars import TelegramStarsService from app.services.mulenpay_service import MulenPayService from app.services.pal24_service import Pal24Service from app.services.payment import ( CryptoBotPaymentMixin, + HeleketPaymentMixin, MulenPayPaymentMixin, Pal24PaymentMixin, PaymentCommonMixin, @@ -178,12 +180,38 @@ async def link_cryptobot_payment_to_transaction(*args, **kwargs): return await crypto_crud.link_cryptobot_payment_to_transaction(*args, **kwargs) +async def create_heleket_payment(*args, **kwargs): + heleket_crud = import_module("app.database.crud.heleket") + return await heleket_crud.create_heleket_payment(*args, **kwargs) + + +async def get_heleket_payment_by_uuid(*args, **kwargs): + heleket_crud = import_module("app.database.crud.heleket") + return await heleket_crud.get_heleket_payment_by_uuid(*args, **kwargs) + + +async def get_heleket_payment_by_id(*args, **kwargs): + heleket_crud = import_module("app.database.crud.heleket") + return await heleket_crud.get_heleket_payment_by_id(*args, **kwargs) + + +async def update_heleket_payment(*args, **kwargs): + heleket_crud = import_module("app.database.crud.heleket") + return await heleket_crud.update_heleket_payment(*args, **kwargs) + + +async def link_heleket_payment_to_transaction(*args, **kwargs): + heleket_crud = import_module("app.database.crud.heleket") + return await heleket_crud.link_heleket_payment_to_transaction(*args, **kwargs) + + class PaymentService( PaymentCommonMixin, TelegramStarsMixin, YooKassaPaymentMixin, TributePaymentMixin, CryptoBotPaymentMixin, + HeleketPaymentMixin, MulenPayPaymentMixin, Pal24PaymentMixin, WataPaymentMixin, @@ -201,6 +229,9 @@ class PaymentService( self.cryptobot_service = ( CryptoBotService() if settings.is_cryptobot_enabled() else None ) + self.heleket_service = ( + HeleketService() if settings.is_heleket_enabled() else None + ) self.mulenpay_service = ( MulenPayService() if settings.is_mulenpay_enabled() else None ) @@ -211,10 +242,11 @@ class PaymentService( mulenpay_name = settings.get_mulenpay_display_name() logger.debug( - "PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, %s=%s, Pal24=%s, Wata=%s)", + "PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)", bool(self.yookassa_service), bool(self.stars_service), bool(self.cryptobot_service), + bool(self.heleket_service), mulenpay_name, bool(self.mulenpay_service), bool(self.pal24_service), diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 5668217e..8e6955c6 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -81,6 +81,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]: "description": "через CryptoBot", "callback": "topup_cryptobot" }) + + if settings.is_heleket_enabled(): + methods.append({ + "id": "heleket", + "name": "Криптовалюта", + "icon": "🪙", + "description": "через Heleket", + "callback": "topup_heleket" + }) # Поддержка всегда доступна methods.append({ @@ -163,6 +172,8 @@ def is_payment_method_available(method_id: str) -> bool: return settings.is_pal24_enabled() elif method_id == "cryptobot": return settings.is_cryptobot_enabled() + elif method_id == "heleket": + return settings.is_heleket_enabled() elif method_id == "support": return True # Поддержка всегда доступна else: @@ -180,6 +191,7 @@ def get_payment_method_status() -> Dict[str, bool]: "wata": settings.is_wata_enabled(), "pal24": settings.is_pal24_enabled(), "cryptobot": settings.is_cryptobot_enabled(), + "heleket": settings.is_heleket_enabled(), "support": True } @@ -202,4 +214,6 @@ def get_enabled_payment_methods_count() -> int: count += 1 if settings.is_cryptobot_enabled(): count += 1 + if settings.is_heleket_enabled(): + count += 1 return count diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 07c9fe51..24c54603 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -715,6 +715,18 @@ async def get_payment_methods( ) ) + if settings.is_heleket_enabled(): + methods.append( + MiniAppPaymentMethod( + id="heleket", + icon="🪙", + requires_amount=True, + currency="RUB", + min_amount_kopeks=100 * 100, + max_amount_kopeks=100_000 * 100, + ) + ) + if settings.TRIBUTE_ENABLED: methods.append( MiniAppPaymentMethod( @@ -733,7 +745,8 @@ async def get_payment_methods( "pal24": 5, "wata": 6, "cryptobot": 7, - "tribute": 8, + "heleket": 8, + "tribute": 9, } methods.sort(key=lambda item: order_map.get(item.id, 99)) @@ -1071,6 +1084,53 @@ async def create_payment_link( }, ) + if method == "heleket": + if not settings.is_heleket_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + + min_amount_kopeks = 100 * 100 + max_amount_kopeks = 100_000 * 100 + if amount_kopeks < min_amount_kopeks: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", + ) + if amount_kopeks > max_amount_kopeks: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", + ) + + payment_service = PaymentService() + result = await payment_service.create_heleket_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language or settings.DEFAULT_LANGUAGE, + ) + + if not result or not result.get("payment_url"): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=result["payment_url"], + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "uuid": result.get("uuid"), + "order_id": result.get("order_id"), + "payer_amount": result.get("payer_amount"), + "payer_currency": result.get("payer_currency"), + "discount_percent": result.get("discount_percent"), + "exchange_rate": result.get("exchange_rate"), + "requested_at": _current_request_timestamp(), + }, + ) + if method == "tribute": if not settings.TRIBUTE_ENABLED: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") @@ -1163,6 +1223,8 @@ async def _resolve_payment_status_entry( return await _resolve_pal24_payment_status(payment_service, db, user, query) if method == "cryptobot": return await _resolve_cryptobot_payment_status(db, user, query) + if method == "heleket": + return await _resolve_heleket_payment_status(db, user, query) if method == "stars": return await _resolve_stars_payment_status(db, user, query) if method == "tribute": @@ -1552,6 +1614,74 @@ async def _resolve_cryptobot_payment_status( ) +async def _resolve_heleket_payment_status( + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + from app.database.crud.heleket import ( + get_heleket_payment_by_id, + get_heleket_payment_by_order_id, + get_heleket_payment_by_uuid, + ) + + payment = None + if query.local_payment_id: + payment = await get_heleket_payment_by_id(db, query.local_payment_id) + if not payment and query.payment_id: + payment = await get_heleket_payment_by_uuid(db, query.payment_id) + if not payment and query.invoice_id: + payment = await get_heleket_payment_by_uuid(db, query.invoice_id) + if not payment and query.bill_id: + payment = await get_heleket_payment_by_order_id(db, query.bill_id) + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="heleket", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": query.local_payment_id, + "uuid": query.payment_id or query.invoice_id, + "order_id": query.bill_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_raw = payment.status + is_paid = bool(payment.is_paid) + status = _classify_status(status_raw, is_paid) + completed_at = payment.paid_at or payment.updated_at or payment.created_at + + return MiniAppPaymentStatusResult( + method="heleket", + status=status, + is_paid=status == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.uuid, + message=None, + extra={ + "status": payment.status, + "local_payment_id": payment.id, + "uuid": payment.uuid, + "order_id": payment.order_id, + "payer_amount": payment.payer_amount, + "payer_currency": payment.payer_currency, + "discount_percent": payment.discount_percent, + "exchange_rate": payment.exchange_rate, + "payment_url": payment.payment_url, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + async def _resolve_stars_payment_status( db: AsyncSession, user: User, diff --git a/main.py b/main.py index b4ff772a..30508b0e 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ from app.services.maintenance_service import maintenance_service from app.services.payment_service import PaymentService from app.services.version_service import version_service from app.external.webhook_server import WebhookServer +from app.external.heleket_webhook import start_heleket_webhook_server from app.external.yookassa_webhook import start_yookassa_webhook_server from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer from app.external.wata_webhook import start_wata_webhook_server @@ -74,6 +75,7 @@ async def main(): webhook_server = None yookassa_server_task = None wata_server_task = None + heleket_server_task = None pal24_server: Pal24WebhookServer | None = None monitoring_task = None maintenance_task = None @@ -304,6 +306,21 @@ async def main(): else: stage.skip("WATA отключен настройками") + async with timeline.stage( + "Heleket webhook", + "🪙", + success_message="Heleket webhook запущен", + ) as stage: + if settings.is_heleket_enabled(): + heleket_server_task = asyncio.create_task( + start_heleket_webhook_server(payment_service) + ) + stage.log( + f"Endpoint: {settings.WEBHOOK_URL}:{settings.HELEKET_WEBHOOK_PORT}{settings.HELEKET_WEBHOOK_PATH}" + ) + else: + stage.skip("Heleket отключен настройками") + async with timeline.stage( "Служба мониторинга", "📈", @@ -436,6 +453,18 @@ async def main(): else: wata_server_task = None + if heleket_server_task and heleket_server_task.done(): + exception = heleket_server_task.exception() + if exception: + logger.error(f"Heleket webhook сервер завершился с ошибкой: {exception}") + logger.info("🔄 Перезапуск Heleket webhook сервера...") + if settings.is_heleket_enabled(): + heleket_server_task = asyncio.create_task( + start_heleket_webhook_server(payment_service) + ) + else: + heleket_server_task = None + if monitoring_task.done(): exception = monitoring_task.exception() if exception: @@ -491,6 +520,14 @@ async def main(): except asyncio.CancelledError: pass + if heleket_server_task and not heleket_server_task.done(): + logger.info("ℹ️ Остановка Heleket webhook сервера...") + heleket_server_task.cancel() + try: + await heleket_server_task + except asyncio.CancelledError: + pass + if monitoring_task and not monitoring_task.done(): logger.info("ℹ️ Остановка службы мониторинга...") monitoring_service.stop_monitoring() diff --git a/tests/services/test_payment_service_heleket.py b/tests/services/test_payment_service_heleket.py new file mode 100644 index 00000000..d5e1e1f6 --- /dev/null +++ b/tests/services/test_payment_service_heleket.py @@ -0,0 +1,158 @@ +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.services.payment_service import PaymentService # noqa: E402 +from app.database.crud import heleket as heleket_crud # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +class DummySession: + def __init__(self) -> None: + self.added_objects: list[Any] = [] + + async def commit(self) -> None: # pragma: no cover - behaviour is mocked in tests + return None + + async def refresh(self, obj: Any) -> None: # pragma: no cover + return None + + def add(self, obj: Any) -> None: # pragma: no cover + self.added_objects.append(obj) + + +class DummyLocalPayment: + def __init__(self, payment_id: int = 123) -> None: + self.id = payment_id + self.created_at = datetime(2024, 1, 1, 12, 0, 0) + + +class StubHeleketService: + def __init__(self, response: Optional[Dict[str, Any]]) -> None: + self.response = response + self.calls: list[Dict[str, Any]] = [] + + async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + self.calls.append(payload) + return self.response + + +def _make_service(stub: Optional[StubHeleketService]) -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = None + service.heleket_service = stub + service.yookassa_service = None + service.stars_service = None + service.cryptobot_service = None + service.mulenpay_service = None + service.pal24_service = None + service.wata_service = None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + response = { + "state": 0, + "result": { + "uuid": "heleket-uuid", + "order_id": "order-123", + "url": "https://heleket/pay", + "status": "check", + "payer_amount": "12.50", + "payer_currency": "USDT", + "discount_percent": -5, + "payer_amount_exchange_rate": "0.0125", + "expired_at": 1750000000, + }, + } + stub = StubHeleketService(response) + service = _make_service(stub) + db = DummySession() + + captured_args: Dict[str, Any] = {} + + async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment: + captured_args.update(kwargs) + return DummyLocalPayment(payment_id=555) + + monkeypatch.setattr( + heleket_crud, + "create_heleket_payment", + fake_create_heleket_payment, + raising=False, + ) + + result = await service.create_heleket_payment( + db=db, + user_id=42, + amount_kopeks=15000, + description="Пополнение", + language="ru", + ) + + assert result is not None + assert result["local_payment_id"] == 555 + assert result["uuid"] == "heleket-uuid" + assert result["order_id"] == "order-123" + assert result["payment_url"] == "https://heleket/pay" + assert stub.calls and stub.calls[0]["amount"] == "150.00" + assert captured_args["uuid"] == "heleket-uuid" + assert captured_args["user_id"] == 42 + + +@pytest.mark.anyio("asyncio") +async def test_create_heleket_payment_returns_none_without_service() -> None: + service = _make_service(None) + db = DummySession() + + result = await service.create_heleket_payment( + db=db, + user_id=1, + amount_kopeks=10000, + description="Пополнение", + ) + + assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubHeleketService(response=None) + service = _make_service(stub) + db = DummySession() + + called = False + + async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment: + nonlocal called + called = True + return DummyLocalPayment() + + monkeypatch.setattr( + heleket_crud, + "create_heleket_payment", + fake_create_heleket_payment, + raising=False, + ) + + result = await service.create_heleket_payment( + db=db, + user_id=1, + amount_kopeks=20000, + description="Пополнение", + ) + + assert result is None + assert called is False diff --git a/tests/services/test_payment_service_mulenpay.py b/tests/services/test_payment_service_mulenpay.py index 68eec241..61f7fec8 100644 --- a/tests/services/test_payment_service_mulenpay.py +++ b/tests/services/test_payment_service_mulenpay.py @@ -50,6 +50,7 @@ def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService: service.yookassa_service = None service.stars_service = None service.cryptobot_service = None + service.heleket_service = None return service diff --git a/tests/services/test_payment_service_pal24.py b/tests/services/test_payment_service_pal24.py index fc23a3e0..02d4f91f 100644 --- a/tests/services/test_payment_service_pal24.py +++ b/tests/services/test_payment_service_pal24.py @@ -61,6 +61,7 @@ def _make_service(stub: Optional[StubPal24Service]) -> PaymentService: service.yookassa_service = None service.cryptobot_service = None service.stars_service = None + service.heleket_service = None return service diff --git a/tests/services/test_payment_service_tribute.py b/tests/services/test_payment_service_tribute.py index 1c8d5c05..ff6f46fd 100644 --- a/tests/services/test_payment_service_tribute.py +++ b/tests/services/test_payment_service_tribute.py @@ -28,6 +28,7 @@ def _make_service() -> PaymentService: service.pal24_service = None service.cryptobot_service = None service.stars_service = None + service.heleket_service = None return service diff --git a/tests/services/test_payment_service_wata.py b/tests/services/test_payment_service_wata.py index 3dc1c0d7..8b6688a8 100644 --- a/tests/services/test_payment_service_wata.py +++ b/tests/services/test_payment_service_wata.py @@ -74,6 +74,7 @@ def _make_service(stub: Optional[StubWataService]) -> PaymentService: service.yookassa_service = None service.stars_service = None service.cryptobot_service = None + service.heleket_service = None return service diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 8eda3023..c47ef6a6 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -54,6 +54,7 @@ def _make_service(bot: DummyBot) -> PaymentService: service.mulenpay_service = None service.pal24_service = None service.cryptobot_service = None + service.heleket_service = None return service diff --git a/tests/services/test_payment_service_yookassa.py b/tests/services/test_payment_service_yookassa.py index f8a950cf..caedb087 100644 --- a/tests/services/test_payment_service_yookassa.py +++ b/tests/services/test_payment_service_yookassa.py @@ -68,6 +68,7 @@ def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentSer service.pal24_service = None service.mulenpay_service = None service.cryptobot_service = None + service.heleket_service = None return service