Add Heleket payment provider integration
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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": "Таблица политик конфиденциальности",
|
||||
|
||||
Vendored
+129
@@ -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
|
||||
Vendored
+111
@@ -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 сервер корректно остановлен")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -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", "🛠️ Через поддержку"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user