diff --git a/.env.example b/.env.example index f0f4c84..9038982 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py new file mode 100644 index 0000000..f3c16ce --- /dev/null +++ b/backend/bot/payment_providers/heleket.py @@ -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="🪙", +) diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 554d0ec..201a4c6 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -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, ) diff --git a/backend/config/settings.py b/backend/config/settings.py index 363c085..b628af5 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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}") diff --git a/locales/en.json b/locales/en.json index b03f805..6a4edea 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/ru.json b/locales/ru.json index d65a0df..8243b67 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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", diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py index a987d69..1ba985d 100644 --- a/tests/test_build_services_wiring.py +++ b/tests/test_build_services_wiring.py @@ -132,6 +132,7 @@ class BuildServicesWiringTests(unittest.TestCase): "platega_service", "severpay_service", "wata_service", + "heleket_service", } self.assertEqual(set(services), expected_keys) diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 93d4004..dcb08b0 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -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", }