Add Heleket payment provider integration

This commit is contained in:
Egor
2025-10-21 10:59:19 +03:00
parent 9b2b11ee44
commit 2f2d9bc1af
23 changed files with 1870 additions and 4 deletions
+42
View File
@@ -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"
+176
View File
@@ -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
+68 -1
View File
@@ -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"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
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 (
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
" {currency}, status={status})>"
).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"
+125
View File
@@ -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": "Таблица политик конфиденциальности",
+129
View File
@@ -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
+111
View File
@@ -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 сервер корректно остановлен")
+209
View File
@@ -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 = [
"🪙 <b>Пополнение через Heleket</b>",
"\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 = [
"🪙 <b>Оплата через Heleket</b>",
"",
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)
+30
View File
@@ -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,
+214 -1
View File
@@ -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 = [
"🪙 <b>Оплата через Heleket</b>",
"",
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(
[
"",
"📱 <b>Инструкция:</b>",
"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_")
+8
View File
@@ -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", "🛠️ Через поддержку"),
+2
View File
@@ -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",
+377
View File
@@ -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 = [
"✅ <b>Пополнение успешно!</b>",
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
+33 -1
View File
@@ -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),
+14
View File
@@ -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
+131 -1
View File
@@ -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,
+37
View File
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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