feat: add heleket payment provider
This commit is contained in:
+22
-2
@@ -90,8 +90,9 @@ CRYPTOPAY_ENABLED=True #
|
||||
PLATEGA_ENABLED=False # Turn on PLATEGA
|
||||
SEVERPAY_ENABLED=False # Turn on SeverPay
|
||||
WATA_ENABLED=False # Turn on Wata
|
||||
# Order of payment methods (top to bottom). Supported: severpay, wata, freekassa, platega, yookassa, stars, cryptopay
|
||||
PAYMENT_METHODS_ORDER=severpay,wata,yookassa,cryptopay,freekassa,platega,stars
|
||||
HELEKET_ENABLED=False # Turn on Heleket (crypto payments)
|
||||
# Order of payment methods (top to bottom). Supported: severpay, wata, freekassa, platega, yookassa, stars, cryptopay, heleket
|
||||
PAYMENT_METHODS_ORDER=severpay,wata,yookassa,cryptopay,freekassa,platega,stars,heleket
|
||||
|
||||
# Payment button presentation overrides (all optional; empty = provider defaults)
|
||||
# Text supports per-language overrides: *_LABEL_RU and *_LABEL_EN. Legacy *_LABEL applies to all languages if per-language values are empty.
|
||||
@@ -144,6 +145,12 @@ PAYMENT_CRYPTOPAY_WEBAPP_ICON=Bitcoin # W
|
||||
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU= # Telegram bot payment button text for CryptoPay (Russian)
|
||||
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN= # Telegram bot payment button text for CryptoPay (English)
|
||||
PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI= # Telegram bot payment button emoji for CryptoPay
|
||||
PAYMENT_HELEKET_WEBAPP_LABEL_RU= # WebApp payment button text for Heleket (Russian)
|
||||
PAYMENT_HELEKET_WEBAPP_LABEL_EN= # WebApp payment button text for Heleket (English)
|
||||
PAYMENT_HELEKET_WEBAPP_ICON=Bitcoin # WebApp payment button icon for Heleket
|
||||
PAYMENT_HELEKET_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Heleket (Russian)
|
||||
PAYMENT_HELEKET_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Heleket (English)
|
||||
PAYMENT_HELEKET_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Heleket
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
@@ -203,6 +210,19 @@ WATA_WEBHOOK_VERIFY_SIGNATURE=True #
|
||||
WATA_PUBLIC_KEY= # Optional: cached public key; leave empty to fetch from Wata
|
||||
WATA_TRUSTED_IPS=62.84.126.140,51.250.106.150 # Wata webhook source IP allowlist
|
||||
|
||||
# Heleket Payment Gateway Configuration (https://doc.heleket.com)
|
||||
HELEKET_BASE_URL=https://api.heleket.com # Base API URL
|
||||
HELEKET_MERCHANT_ID= # Your merchant UUID from Heleket dashboard
|
||||
HELEKET_API_KEY= # Payment API key from Heleket dashboard
|
||||
HELEKET_CURRENCY=RUB # Invoice currency (fiat or crypto code, e.g. RUB, USD, USDT)
|
||||
HELEKET_TO_CURRENCY= # Optional: target cryptocurrency for conversion (e.g. USDT)
|
||||
HELEKET_NETWORK= # Optional: blockchain network code (e.g. tron, bsc, eth)
|
||||
HELEKET_RETURN_URL= # Optional: redirect after invoice expires/cancel (defaults to bot link)
|
||||
HELEKET_SUCCESS_URL= # Optional: redirect after successful payment (defaults to return URL)
|
||||
HELEKET_LIFETIME_SECONDS=3600 # Invoice lifetime in seconds (300-43200)
|
||||
HELEKET_VERIFY_WEBHOOK_SIGNATURE=True # Verify md5 signature on incoming webhook
|
||||
HELEKET_TRUSTED_IPS=31.133.220.8 # Heleket webhook source IP allowlist
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
PaymentSuccessRequest,
|
||||
build_payment_record_payload,
|
||||
create_webapp_payment_record,
|
||||
decimal_amounts_equal,
|
||||
describe_payment,
|
||||
finalize_successful_payment,
|
||||
finalize_webapp_link_payment,
|
||||
first_value,
|
||||
format_decimal_amount,
|
||||
lookup_payment_by_order_or_provider_id,
|
||||
make_translator,
|
||||
notify_callback_parse_error,
|
||||
notify_payment_record_failure,
|
||||
notify_service_unavailable,
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_unavailable,
|
||||
render_link_or_fail,
|
||||
)
|
||||
|
||||
router = Router(name="user_subscription_payments_heleket_router")
|
||||
_LOG = "heleket"
|
||||
|
||||
_SUCCESS_STATUSES = {"paid", "paid_over"}
|
||||
_FAILED_STATUSES = {"fail", "wrong_amount", "cancel", "system_fail"}
|
||||
|
||||
|
||||
def _serialize_for_signature(payload: Dict[str, Any]) -> str:
|
||||
"""Serialize JSON exactly the way Heleket signs it.
|
||||
|
||||
Heleket's PHP example uses ``json_encode`` with ``JSON_UNESCAPED_UNICODE``
|
||||
and default forward-slash escaping. We replicate both: ``ensure_ascii=False``
|
||||
keeps unicode untouched, then we manually escape ``/`` so that the base64
|
||||
payload matches the one signed on the Heleket side.
|
||||
"""
|
||||
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
return encoded.replace("/", "\\/")
|
||||
|
||||
|
||||
def _compute_signature(payload: Dict[str, Any], api_key: str) -> str:
|
||||
body = _serialize_for_signature(payload).encode("utf-8")
|
||||
b64 = base64.b64encode(body).decode("ascii")
|
||||
return hashlib.md5((b64 + api_key).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class HeleketService(HttpClientMixin):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.base_url = (settings.HELEKET_BASE_URL or "https://api.heleket.com").rstrip("/")
|
||||
self.merchant_id = settings.HELEKET_MERCHANT_ID or ""
|
||||
self.api_key = settings.HELEKET_API_KEY or ""
|
||||
self.currency = (settings.HELEKET_CURRENCY or "RUB").upper()
|
||||
self.to_currency = (settings.HELEKET_TO_CURRENCY or "").strip() or None
|
||||
self.network = (settings.HELEKET_NETWORK or "").strip() or None
|
||||
self.return_url = settings.HELEKET_RETURN_URL or f"https://t.me/{default_return_url}"
|
||||
self.success_url = settings.HELEKET_SUCCESS_URL or self.return_url
|
||||
self.lifetime_seconds = settings.HELEKET_LIFETIME_SECONDS
|
||||
self.verify_webhook_signature = settings.HELEKET_VERIFY_WEBHOOK_SIGNATURE
|
||||
|
||||
self._init_http_client(total_timeout=20)
|
||||
self.configured: bool = bool(
|
||||
settings.HELEKET_ENABLED and self.merchant_id and self.api_key
|
||||
)
|
||||
if not self.configured:
|
||||
logging.warning(
|
||||
"HeleketService initialized but not fully configured. Payments disabled."
|
||||
)
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: str,
|
||||
url_callback: Optional[str],
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("HeleketService is not configured. Cannot create payment link.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"amount": str(format_decimal_amount(amount)),
|
||||
"currency": (currency or self.currency).upper(),
|
||||
"order_id": str(payment_db_id),
|
||||
"url_return": self.return_url,
|
||||
"url_success": self.success_url,
|
||||
"lifetime": int(self.lifetime_seconds),
|
||||
}
|
||||
if description:
|
||||
body["additional_data"] = description[:255]
|
||||
if self.to_currency:
|
||||
body["to_currency"] = self.to_currency
|
||||
if self.network:
|
||||
body["network"] = self.network
|
||||
if url_callback:
|
||||
body["url_callback"] = url_callback
|
||||
|
||||
signed_body = _serialize_for_signature(body).encode("utf-8")
|
||||
headers = {
|
||||
"merchant": self.merchant_id,
|
||||
"sign": _compute_signature(body, self.api_key),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.post(
|
||||
f"{self.base_url}/v1/payment",
|
||||
data=signed_body,
|
||||
headers=headers,
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Heleket create_payment_link: invalid JSON: %s", response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
state = response_data.get("state") if isinstance(response_data, dict) else None
|
||||
if response.status != 200 or state != 0:
|
||||
logging.error(
|
||||
"Heleket create_payment_link: API error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("Heleket create_payment_link: request failed.")
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
def _verify_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
received = payload.get("sign")
|
||||
if not isinstance(received, str) or not received:
|
||||
return False
|
||||
data = {k: v for k, v in payload.items() if k != "sign"}
|
||||
expected = _compute_signature(data, self.api_key)
|
||||
return hmac.compare_digest(expected, received)
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="heleket_disabled")
|
||||
|
||||
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
|
||||
if self.settings.heleket_trusted_ips and not ip_in_allowlist(
|
||||
client_ip, self.settings.heleket_trusted_ips
|
||||
):
|
||||
logging.warning("Heleket webhook denied from unauthorized IP source.")
|
||||
return web.Response(status=403, text="forbidden")
|
||||
|
||||
raw_body = await request.read()
|
||||
try:
|
||||
payload = json.loads(raw_body.decode("utf-8"))
|
||||
except Exception:
|
||||
logging.exception("Heleket webhook: failed to parse JSON.")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
if self.verify_webhook_signature and not self._verify_signature(payload):
|
||||
logging.warning("Heleket webhook: invalid signature.")
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
uuid_value = str(payload.get("uuid") or "").strip()
|
||||
status = str(payload.get("status") or "").strip().lower()
|
||||
order_id_raw = payload.get("order_id")
|
||||
amount_raw = payload.get("amount")
|
||||
currency = payload.get("currency") or self.currency
|
||||
|
||||
if not status or not (uuid_value or order_id_raw):
|
||||
logging.error("Heleket webhook: missing status or ids: %s", payload)
|
||||
return web.Response(status=400, text="missing_fields")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await lookup_payment_by_order_or_provider_id(
|
||||
session,
|
||||
order_id_raw=order_id_raw,
|
||||
provider_payment_id=uuid_value or None,
|
||||
)
|
||||
if not payment:
|
||||
logging.error(
|
||||
"Heleket webhook: payment not found (order_id=%s, uuid=%s)",
|
||||
order_id_raw,
|
||||
uuid_value,
|
||||
)
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded" and status in _SUCCESS_STATUSES:
|
||||
return web.Response(text="ok")
|
||||
|
||||
resolved_id = uuid_value or str(payment.payment_id)
|
||||
|
||||
if status in _SUCCESS_STATUSES:
|
||||
if amount_raw is not None:
|
||||
try:
|
||||
if not decimal_amounts_equal(amount_raw, payment.amount):
|
||||
logging.warning(
|
||||
"Heleket webhook: amount mismatch for payment %s "
|
||||
"(expected %s, got %s)",
|
||||
payment.payment_id,
|
||||
format_decimal_amount(payment.amount),
|
||||
format_decimal_amount(amount_raw),
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning(
|
||||
"Heleket webhook: failed to compare amounts for %s: %s",
|
||||
payment.payment_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_id,
|
||||
"succeeded",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Heleket webhook: failed to mark payment %s as succeeded.",
|
||||
resolved_id,
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
subscription_service=self.subscription_service,
|
||||
referral_service=self.referral_service,
|
||||
payment=payment,
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=str(currency),
|
||||
sale_mode=sale_mode,
|
||||
months=payment_units,
|
||||
traffic_amount=float(payment_units),
|
||||
provider_subscription="heleket",
|
||||
provider_notification="heleket",
|
||||
db_user=payment.user,
|
||||
log_prefix="Heleket webhook",
|
||||
)
|
||||
)
|
||||
if outcome is None:
|
||||
return web.Response(status=500, text="processing_error")
|
||||
return web.Response(text="ok")
|
||||
|
||||
if status in _FAILED_STATUSES:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_id,
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Heleket webhook: failed to mark payment %s as failed.",
|
||||
resolved_id,
|
||||
)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
await notify_user_payment_failed(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
payment=payment,
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
|
||||
logging.info(
|
||||
"Heleket webhook: intermediate status '%s' for payment %s",
|
||||
status,
|
||||
resolved_id,
|
||||
)
|
||||
return web.Response(status=202, text="status_ignored")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_heleket:"))
|
||||
async def pay_heleket_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
heleket_service: HeleketService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
translator = make_translator(i18n, current_lang)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not heleket_service or not heleket_service.configured:
|
||||
logging.error("Heleket service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
parts = parse_payment_callback(callback.data or "")
|
||||
if not parts:
|
||||
logging.error("Invalid pay_heleket data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = (heleket_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
status="pending_heleket",
|
||||
description=payment_description,
|
||||
months=parts.months,
|
||||
provider="heleket",
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"Heleket: failed to create payment record for user %s.", callback.from_user.id
|
||||
)
|
||||
await notify_payment_record_failure(callback, translator)
|
||||
return
|
||||
|
||||
success, response_data = await heleket_service.create_payment_link(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
url_callback=settings.heleket_full_webhook_url,
|
||||
)
|
||||
result = response_data.get("result") if isinstance(response_data, dict) else None
|
||||
await render_link_or_fail(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
session=session,
|
||||
payment=payment_record,
|
||||
api_success=success,
|
||||
payment_url=first_value(result, "url"),
|
||||
provider_payment_id=first_value(result, "uuid"),
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
|
||||
|
||||
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
settings: Settings = ctx.request.app["settings"]
|
||||
service: HeleketService = ctx.request.app["heleket_service"]
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = (service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
amount=ctx.price,
|
||||
currency=currency,
|
||||
status="pending_heleket",
|
||||
provider="heleket",
|
||||
)
|
||||
success, response_data = await service.create_payment_link(
|
||||
payment_db_id=payment.payment_id,
|
||||
amount=ctx.price,
|
||||
currency=currency,
|
||||
description=ctx.description,
|
||||
url_callback=settings.heleket_full_webhook_url,
|
||||
)
|
||||
except Exception:
|
||||
await ctx.session.rollback()
|
||||
logging.exception("Heleket WebApp payment failed")
|
||||
return payment_failed()
|
||||
|
||||
result = response_data.get("result") if isinstance(response_data, dict) else None
|
||||
return await finalize_webapp_link_payment(
|
||||
session=ctx.session,
|
||||
payment=payment,
|
||||
api_success=success,
|
||||
payment_url=first_value(result, "url") if success else None,
|
||||
provider_payment_id=first_value(result, "uuid"),
|
||||
log_prefix="Heleket",
|
||||
)
|
||||
|
||||
|
||||
async def heleket_webhook_route(request: web.Request) -> web.Response:
|
||||
service: HeleketService = request.app["heleket_service"]
|
||||
return await service.webhook_route(request)
|
||||
|
||||
|
||||
def create_service(ctx: ServiceFactoryContext) -> HeleketService:
|
||||
return HeleketService(
|
||||
bot=ctx.bot,
|
||||
settings=ctx.settings,
|
||||
i18n=ctx.i18n,
|
||||
async_session_factory=ctx.async_session_factory,
|
||||
subscription_service=ctx.subscription_service,
|
||||
referral_service=ctx.referral_service,
|
||||
default_return_url=ctx.bot_username_for_default_return,
|
||||
)
|
||||
|
||||
|
||||
SPEC = PaymentProviderSpec(
|
||||
id="heleket",
|
||||
provider_key="heleket",
|
||||
label="Heleket",
|
||||
webapp_label="Heleket",
|
||||
webapp_labels={"ru": "Heleket", "en": "Heleket"},
|
||||
webapp_icon="Bitcoin",
|
||||
telegram_labels={"ru": "Heleket", "en": "Heleket"},
|
||||
telegram_emoji="🪙",
|
||||
pending_status="pending_heleket",
|
||||
enabled=lambda settings: settings.HELEKET_ENABLED,
|
||||
service_key="heleket_service",
|
||||
button_text_key="pay_with_heleket_button",
|
||||
callback_prefix="pay_heleket",
|
||||
router=router,
|
||||
create_service=create_service,
|
||||
webhook_path=lambda settings: settings.heleket_webhook_path,
|
||||
webhook_route=heleket_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
emoji="🪙",
|
||||
)
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from . import cryptopay, freekassa, platega, severpay, stars, wata, yookassa
|
||||
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa
|
||||
from .base import PaymentProviderPresentation, PaymentProviderSpec, ServiceFactoryContext
|
||||
|
||||
PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
|
||||
@@ -14,6 +14,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
|
||||
yookassa.SPEC,
|
||||
stars.SPEC,
|
||||
cryptopay.SPEC,
|
||||
heleket.SPEC,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +76,18 @@ class PaymentSettings(BaseModel):
|
||||
cryptopay_network: str
|
||||
cryptopay_currency_type: str
|
||||
cryptopay_asset: str
|
||||
heleket_enabled: bool
|
||||
heleket_merchant_id: Optional[str]
|
||||
heleket_api_key: Optional[str]
|
||||
heleket_base_url: str
|
||||
heleket_currency: str
|
||||
heleket_to_currency: Optional[str]
|
||||
heleket_network: Optional[str]
|
||||
heleket_return_url: Optional[str]
|
||||
heleket_success_url: Optional[str]
|
||||
heleket_lifetime_seconds: int
|
||||
heleket_verify_webhook_signature: bool
|
||||
heleket_trusted_ips: List[str]
|
||||
|
||||
|
||||
class EmailSettings(BaseModel):
|
||||
@@ -283,6 +295,34 @@ class Settings(BaseSettings):
|
||||
description="Comma-separated Wata webhook IP allowlist.",
|
||||
)
|
||||
|
||||
HELEKET_ENABLED: bool = Field(default=False)
|
||||
HELEKET_MERCHANT_ID: Optional[str] = None
|
||||
HELEKET_API_KEY: Optional[str] = None
|
||||
HELEKET_BASE_URL: str = Field(default="https://api.heleket.com")
|
||||
HELEKET_CURRENCY: str = Field(
|
||||
default="RUB",
|
||||
description="Invoice currency (fiat or crypto code).",
|
||||
)
|
||||
HELEKET_TO_CURRENCY: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional target cryptocurrency for conversion (e.g. USDT).",
|
||||
)
|
||||
HELEKET_NETWORK: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional blockchain network code (e.g. tron, bsc).",
|
||||
)
|
||||
HELEKET_RETURN_URL: Optional[str] = None
|
||||
HELEKET_SUCCESS_URL: Optional[str] = None
|
||||
HELEKET_LIFETIME_SECONDS: int = Field(
|
||||
default=3600,
|
||||
description="Invoice lifetime in seconds (300-43200).",
|
||||
)
|
||||
HELEKET_VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
|
||||
HELEKET_TRUSTED_IPS: str = Field(
|
||||
default="31.133.220.8",
|
||||
description="Comma-separated Heleket webhook IP allowlist.",
|
||||
)
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||
@@ -337,6 +377,12 @@ class Settings(BaseSettings):
|
||||
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU: Optional[str] = None
|
||||
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN: Optional[str] = None
|
||||
PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI: Optional[str] = None
|
||||
PAYMENT_HELEKET_WEBAPP_LABEL_RU: Optional[str] = None
|
||||
PAYMENT_HELEKET_WEBAPP_LABEL_EN: Optional[str] = None
|
||||
PAYMENT_HELEKET_WEBAPP_ICON: Optional[str] = None
|
||||
PAYMENT_HELEKET_TELEGRAM_LABEL_RU: Optional[str] = None
|
||||
PAYMENT_HELEKET_TELEGRAM_LABEL_EN: Optional[str] = None
|
||||
PAYMENT_HELEKET_TELEGRAM_EMOJI: Optional[str] = None
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
@@ -622,6 +668,18 @@ class Settings(BaseSettings):
|
||||
cryptopay_network=self.CRYPTOPAY_NETWORK,
|
||||
cryptopay_currency_type=self.CRYPTOPAY_CURRENCY_TYPE,
|
||||
cryptopay_asset=self.CRYPTOPAY_ASSET,
|
||||
heleket_enabled=self.HELEKET_ENABLED,
|
||||
heleket_merchant_id=self.HELEKET_MERCHANT_ID,
|
||||
heleket_api_key=self.HELEKET_API_KEY,
|
||||
heleket_base_url=self.HELEKET_BASE_URL,
|
||||
heleket_currency=self.HELEKET_CURRENCY,
|
||||
heleket_to_currency=self.HELEKET_TO_CURRENCY,
|
||||
heleket_network=self.HELEKET_NETWORK,
|
||||
heleket_return_url=self.HELEKET_RETURN_URL,
|
||||
heleket_success_url=self.HELEKET_SUCCESS_URL,
|
||||
heleket_lifetime_seconds=self.HELEKET_LIFETIME_SECONDS,
|
||||
heleket_verify_webhook_signature=self.HELEKET_VERIFY_WEBHOOK_SIGNATURE,
|
||||
heleket_trusted_ips=self.heleket_trusted_ips,
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@@ -822,6 +880,24 @@ class Settings(BaseSettings):
|
||||
def wata_trusted_ips(self) -> List[str]:
|
||||
return _split_csv(self.WATA_TRUSTED_IPS)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def heleket_webhook_path(self) -> str:
|
||||
return "/webhook/heleket"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def heleket_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.heleket_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def heleket_trusted_ips(self) -> List[str]:
|
||||
return _split_csv(self.HELEKET_TRUSTED_IPS)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def platega_webhook_path(self) -> str:
|
||||
@@ -1055,6 +1131,7 @@ class Settings(BaseSettings):
|
||||
"yookassa",
|
||||
"stars",
|
||||
"cryptopay",
|
||||
"heleket",
|
||||
]
|
||||
if not self.PAYMENT_METHODS_ORDER:
|
||||
return default_order
|
||||
@@ -1168,6 +1245,12 @@ class Settings(BaseSettings):
|
||||
"WATA_FAILED_URL",
|
||||
"WATA_API_TOKEN",
|
||||
"WATA_PUBLIC_KEY",
|
||||
"HELEKET_MERCHANT_ID",
|
||||
"HELEKET_API_KEY",
|
||||
"HELEKET_RETURN_URL",
|
||||
"HELEKET_SUCCESS_URL",
|
||||
"HELEKET_TO_CURRENCY",
|
||||
"HELEKET_NETWORK",
|
||||
"CRYPT4_REDIRECT_URL",
|
||||
"PRIVACY_POLICY_URL",
|
||||
"USER_AGREEMENT_URL",
|
||||
@@ -1210,6 +1293,17 @@ class Settings(BaseSettings):
|
||||
return 3
|
||||
return min(30, max(1, value))
|
||||
|
||||
@field_validator("HELEKET_LIFETIME_SECONDS", mode="before")
|
||||
@classmethod
|
||||
def validate_heleket_lifetime(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
try:
|
||||
value = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 3600
|
||||
return min(43200, max(300, value))
|
||||
|
||||
# Notification types
|
||||
LOG_NEW_USERS: bool = Field(
|
||||
default=True, description="Send notifications for new user registrations"
|
||||
@@ -1306,6 +1400,14 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: Wata is enabled but WATA_API_TOKEN is missing. Wata payments will not work." # noqa: E501
|
||||
)
|
||||
if _settings_instance.HELEKET_ENABLED:
|
||||
if (
|
||||
not _settings_instance.HELEKET_MERCHANT_ID
|
||||
or not _settings_instance.HELEKET_API_KEY
|
||||
):
|
||||
logging.warning(
|
||||
"CRITICAL: Heleket is enabled but HELEKET_MERCHANT_ID or HELEKET_API_KEY is missing. Heleket payments will not work." # noqa: E501
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
logging.critical(f"Pydantic validation error while loading settings: {e}")
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"pay_with_platega_crypto_button": "🪙 Pay with crypto",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"pay_with_wata_button": "Wata",
|
||||
"pay_with_heleket_button": "🪙 Heleket",
|
||||
"back_to_payment_methods_button": "⬅️ Back",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"pay_with_platega_crypto_button": "🪙 Оплата криптой",
|
||||
"pay_with_severpay_button": "💳 SeverPay",
|
||||
"pay_with_wata_button": "Wata",
|
||||
"pay_with_heleket_button": "🪙 Heleket",
|
||||
"back_to_payment_methods_button": "⬅️ Назад",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
|
||||
@@ -132,6 +132,7 @@ class BuildServicesWiringTests(unittest.TestCase):
|
||||
"platega_service",
|
||||
"severpay_service",
|
||||
"wata_service",
|
||||
"heleket_service",
|
||||
}
|
||||
self.assertEqual(set(services), expected_keys)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ _PROVIDER_MODULES = {
|
||||
"cryptopay": "CryptoPayService",
|
||||
"stars": "StarsService",
|
||||
"wata": "WataService",
|
||||
"heleket": "HeleketService",
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +116,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
|
||||
"wata_service",
|
||||
"stars_service",
|
||||
"cryptopay_service",
|
||||
"heleket_service",
|
||||
}
|
||||
assert set(pending_statuses()) >= {
|
||||
"pending",
|
||||
@@ -125,6 +127,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
|
||||
"pending_wata",
|
||||
"pending_cryptopay",
|
||||
"pending_stars",
|
||||
"pending_heleket",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user