diff --git a/README.md b/README.md index 9444857..374e55a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи - Web App / Mini App с входом через Telegram или email; - встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/` для передачи инструкции; - пробный период, промокоды и реферальная программа; -- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars; +- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars; - тикеты поддержки в Web App и внешняя ссылка на поддержку; - раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`. diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index e990f05..a4c4c57 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -369,7 +369,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "string", "payments", "Порядок методов оплаты", - "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket", + "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla", subsection="common", ), # ─── Trial ───────────────────────────────────────────────────── diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py new file mode 100644 index 0000000..297ba4c --- /dev/null +++ b/backend/bot/payment_providers/paykilla.py @@ -0,0 +1,1076 @@ +import hashlib +import hmac +import json +import logging +import re +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode + +from aiogram import Bot, F, Router, types +from aiohttp import web +from pydantic import AliasChoices, Field, field_validator +from pydantic_settings import SettingsConfigDict +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 config.tariffs_config import ( + default_currency_key_for_settings, + default_payment_currency_code_for_settings, +) +from db.dal import payment_dal + +from .base import ( + PaymentProviderSpec, + ProviderEnvConfig, + ProviderManifestField, + ServiceFactoryContext, + WebAppPaymentContext, + normalize_payment_currency_code, + parse_supported_currency_codes, + provider_env_file, + provider_runtime_enabled, +) +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, + payment_units_for_activation, + quote_hwid_callback_parts, + render_link_or_fail, +) + +router = Router(name="user_subscription_payments_paykilla_router") +_LOG = "paykilla" + +PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC,BTC,ETH" +PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES = ( + "RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC," + "USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC," + "USDTTON,AAVEETH,MANAETH,SHIBETH" +) +_FIAT_CURRENCIES = {"RUB", "USD", "EUR", "AED", "GBP"} +_SUCCESS_EVENTS = {"INVOICE_PAID", "PAYMENT_COMPLETED", "PAYMENT_OVERPAID"} +_FAILED_EVENTS = { + "PAYMENT_FAILED", + "PAYMENT_UNDERPAID", + "INVOICE_EXPIRED", + "INVOICE_CANCELLED", + "PAYMENT_CANCELLED", + "COMPLIANCE_FAILED", +} + + +class PaykillaConfig(ProviderEnvConfig): + """PayKilla V2 env vars.""" + + model_config = SettingsConfigDict( + env_file=provider_env_file(), + env_file_encoding="utf-8", + env_prefix="PAYKILLA_", + extra="ignore", + populate_by_name=True, + ) + + ENABLED: bool = Field(default=False) + API_KEY: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("PAYKILLA_API_KEY", "PAYKILLA_V2_API_KEY"), + ) + SECRET_KEY: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("PAYKILLA_SECRET_KEY", "PAYKILLA_V2_SECRET_KEY"), + ) + BASE_URL: str = Field( + default="https://account-api.paykilla.com", + validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"), + ) + WIDGET_URL: str = Field(default="https://gopay.paykilla.com") + CURRENCY: str = Field(default="RUB") + INVOICE_TYPE: Optional[str] = None + PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES) + SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES) + RETURN_URL: Optional[str] = None + SUCCESS_URL: Optional[str] = None + CANCEL_URL: Optional[str] = None + LIFETIME_SECONDS: int = Field(default=3600) + RECV_WINDOW_MS: int = Field(default=5000) + USER_PAYS_SERVICE_FEE: bool = Field(default=True) + USER_PAYS_NETWORK_FEE: bool = Field(default=True) + VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True) + WEBHOOK_URL: Optional[str] = None + TRUSTED_IPS: str = Field(default="") + + @field_validator("LIFETIME_SECONDS", mode="before") + @classmethod + def _clamp_lifetime(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 3600 + return min(2_592_000, max(300, value)) + + @field_validator("RECV_WINDOW_MS", mode="before") + @classmethod + def _clamp_recv_window(cls, v): + if isinstance(v, str): + v = v.strip() + try: + value = int(v) + except (TypeError, ValueError): + return 5000 + return min(60_000, max(1000, value)) + + @field_validator( + "API_KEY", + "SECRET_KEY", + "INVOICE_TYPE", + "RETURN_URL", + "SUCCESS_URL", + "CANCEL_URL", + "WEBHOOK_URL", + mode="before", + ) + @classmethod + def _strip_optional(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + + @field_validator("INVOICE_TYPE", mode="before") + @classmethod + def _normalize_invoice_type(cls, v): + if isinstance(v, str): + value = v.strip().upper() + return value or None + return v + + @property + def webhook_path(self) -> str: + return "/webhook/paykilla" + + def full_webhook_url(self, base: Optional[str]) -> Optional[str]: + if self.WEBHOOK_URL: + return self.WEBHOOK_URL.rstrip("/") + if not base: + return None + return f"{base.rstrip('/')}{self.webhook_path}" + + @property + def trusted_ips_list(self) -> List[str]: + return [item.strip() for item in (self.TRUSTED_IPS or "").split(",") if item.strip()] + + +class PaykillaPresentation(ProviderEnvConfig): + """Admin-tunable button text/icon overrides for PayKilla.""" + + model_config = SettingsConfigDict( + env_file=provider_env_file(), + env_file_encoding="utf-8", + env_prefix="PAYMENT_PAYKILLA_", + extra="ignore", + ) + + WEBAPP_LABEL_RU: Optional[str] = None + WEBAPP_LABEL_EN: Optional[str] = None + WEBAPP_ICON: Optional[str] = None + TELEGRAM_LABEL_RU: Optional[str] = None + TELEGRAM_LABEL_EN: Optional[str] = None + TELEGRAM_EMOJI: Optional[str] = None + + +def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str: + text = str(value or "") + text = re.sub(r"[-\u2010-\u2015]", " ", text) + text = re.sub(r"[^\w\s.,]", "", text, flags=re.UNICODE) + text = re.sub(r"\s+", " ", text).strip() + if not text: + text = fallback + return text[:max_length].strip() or fallback[:max_length] + + +def _payment_currencies(config: PaykillaConfig) -> List[str]: + currencies = list(parse_supported_currency_codes(config.PAYMENT_CURRENCIES)) + return currencies or ["USDTTRC"] + + +def _invoice_type_for(config: PaykillaConfig, currency: str) -> str: + explicit = (config.INVOICE_TYPE or "").strip().upper() + if explicit in {"FIAT_BASED", "FIXED_AMOUNT", "OPEN_AMOUNT"}: + return explicit + return "FIAT_BASED" if currency in _FIAT_CURRENCIES else "FIXED_AMOUNT" + + +def _sign_query(timestamp_ms: int, recv_window_ms: int, secret_key: str) -> Tuple[str, str]: + query = urlencode( + [ + ("timestamp", str(timestamp_ms)), + ("recvWindow", str(recv_window_ms)), + ] + ) + signature = hmac.new(secret_key.encode("utf-8"), query.encode("utf-8"), hashlib.sha256) + return query, signature.hexdigest() + + +def _webhook_signature( + *, + timestamp: str, + method: str, + url: str, + raw_body: bytes, + secret_key: str, +) -> str: + message = f"{timestamp}{method.upper()}{url}".encode("utf-8") + raw_body + return hmac.new(secret_key.encode("utf-8"), message, hashlib.sha256).hexdigest() + + +def _signature_preview(signature: str) -> str: + signature = str(signature or "") + if len(signature) <= 12: + return signature + return f"{signature[:6]}...{signature[-6:]}" + + +def _response_invoice_data(response_data: Dict[str, Any]) -> Dict[str, Any]: + data = response_data.get("data") if isinstance(response_data, dict) else None + if isinstance(data, dict) and data.get("id"): + return data + return response_data if isinstance(response_data, dict) else {} + + +class PaykillaService(HttpClientMixin): + def __init__( + self, + *, + bot: Bot, + settings: Settings, + config: PaykillaConfig, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + default_return_url: str, + ): + self.bot = bot + self.settings = settings + self.config = config + self.i18n = i18n + self.async_session_factory = async_session_factory + self.subscription_service = subscription_service + self.referral_service = referral_service + self._default_return_url = default_return_url + + self._init_http_client(total_timeout=20) + if not self.configured: + logging.warning( + "PaykillaService initialized but not fully configured. Payments disabled." + ) + + @property + def configured(self) -> bool: + return bool(provider_runtime_enabled(self.config) and self.api_key and self.secret_key) + + @property + def base_url(self) -> str: + return (self.config.BASE_URL or "https://account-api.paykilla.com").rstrip("/") + + @property + def widget_url(self) -> str: + return (self.config.WIDGET_URL or "https://gopay.paykilla.com").rstrip("/") + + @property + def api_key(self) -> str: + return (self.config.API_KEY or "").strip() + + @property + def secret_key(self) -> str: + return (self.config.SECRET_KEY or "").strip() + + @property + def currency(self) -> str: + return (self.config.CURRENCY or "RUB").upper() + + @property + def return_url(self) -> str: + return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}" + + @property + def success_url(self) -> str: + return self.config.SUCCESS_URL or self.return_url + + @property + def cancel_url(self) -> Optional[str]: + return self.config.CANCEL_URL + + @property + def verify_webhook_signature(self) -> bool: + return self.config.VERIFY_WEBHOOK_SIGNATURE + + def _signed_invoice_url(self) -> str: + timestamp_ms = int(time.time() * 1000) + recv_window_ms = int(self.config.RECV_WINDOW_MS) + query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key) + return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}" + + def _invoice_body( + self, + *, + payment_db_id: int, + amount: float, + currency: Optional[str], + description: str, + ) -> Dict[str, Any]: + currency_code = normalize_payment_currency_code(currency or self.currency) + purpose = _clean_paykilla_text( + description, + fallback=f"Payment {payment_db_id}", + max_length=255, + ) + body: Dict[str, Any] = { + "type": _invoice_type_for(self.config, currency_code), + "purpose": purpose, + "currency": currency_code, + "totalPrice": str(format_decimal_amount(amount)), + "paymentCurrencies": _payment_currencies(self.config), + "clientOrderId": str(payment_db_id), + "userPaysServiceFee": bool(self.config.USER_PAYS_SERVICE_FEE), + "userPaysNetworkFee": bool(self.config.USER_PAYS_NETWORK_FEE), + } + if description: + body["description"] = _clean_paykilla_text( + description, + fallback=purpose, + max_length=255, + ) + if self.config.LIFETIME_SECONDS: + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(self.config.LIFETIME_SECONDS) + ) + body["expiredAt"] = expires_at.isoformat().replace("+00:00", "Z") + + urls: List[Dict[str, Any]] = [] + if self.success_url: + urls.append({"type": "SUCCESS", "url": self.success_url, "autoRedirect": True}) + if self.return_url: + urls.append({"type": "RETURN", "url": self.return_url}) + if self.cancel_url: + urls.append({"type": "CANCEL", "url": self.cancel_url}) + if urls: + body["urls"] = urls + return body + + async def create_payment_link( + self, + *, + payment_db_id: int, + amount: float, + currency: Optional[str], + description: str, + url_callback: Optional[str] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("PaykillaService is not configured. Cannot create payment link.") + return False, {"message": "service_not_configured"} + + currency_code = normalize_payment_currency_code(currency or self.currency) + supported = parse_supported_currency_codes(self.config.SUPPORTED_CURRENCIES) + if supported and currency_code not in supported: + return False, { + "message": "unsupported_currency", + "currency": currency_code, + "supported_currencies": list(supported), + } + + body = self._invoice_body( + payment_db_id=payment_db_id, + amount=amount, + currency=currency_code, + description=description, + ) + headers = { + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.post( + self._signed_invoice_url(), + json=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("Paykilla create_payment_link: invalid JSON: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + invoice = _response_invoice_data(response_data) + invoice_id = first_value(invoice, "id") + if response.status not in {200, 201} or not invoice_id: + logging.error( + "Paykilla create_payment_link: API error (status=%s, body=%s)", + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + invoice["payment_url"] = f"{self.widget_url}/{invoice_id}" + return True, invoice + except Exception as exc: + logging.exception("Paykilla create_payment_link: request failed.") + return False, {"message": str(exc)} + + def _webhook_url_for_request(self, request: web.Request) -> Optional[str]: + configured = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None)) + if configured: + return configured + try: + return f"{request.scheme}://{request.host}{request.path_qs}" + except Exception: + return None + + def _verify_webhook_signature(self, request: web.Request, raw_body: bytes) -> bool: + timestamp = str(request.headers.get("X-API-TIMESTAMP") or "").strip() + signature = str(request.headers.get("X-API-SIGN") or "").strip().lower() + recv_window_raw = str(request.headers.get("X-API-RECV-WINDOW") or "5000").strip() + api_key = str(request.headers.get("X-API-KEY") or "").strip() + if not timestamp or not signature: + return False + if api_key and not hmac.compare_digest(api_key, self.api_key): + logging.warning("Paykilla webhook: X-API-KEY does not match configured API key.") + return False + try: + timestamp_ms = int(timestamp) + recv_window_ms = int(recv_window_raw) + except ValueError: + return False + now_ms = int(time.time() * 1000) + if abs(now_ms - timestamp_ms) > max(recv_window_ms, 1000): + logging.warning( + "Paykilla webhook: timestamp outside recv window " + "(timestamp=%s now=%s recvWindow=%s).", + timestamp_ms, + now_ms, + recv_window_ms, + ) + return False + webhook_url = self._webhook_url_for_request(request) + if not webhook_url: + logging.warning("Paykilla webhook: cannot resolve webhook URL for signature.") + return False + expected = _webhook_signature( + timestamp=timestamp, + method=request.method or "POST", + url=webhook_url, + raw_body=raw_body, + secret_key=self.secret_key, + ) + if hmac.compare_digest(expected, signature): + return True + logging.warning( + "Paykilla webhook: invalid signature (received=%s expected=%s url=%s).", + _signature_preview(signature), + _signature_preview(expected), + webhook_url, + ) + return False + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="paykilla_disabled") + + client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies) + trusted = self.config.trusted_ips_list + if trusted and not ip_in_allowlist(client_ip, trusted): + logging.warning( + "Paykilla webhook denied from unauthorized IP source " + "(client_ip=%s remote=%s x_forwarded_for=%s trusted_ips=%s trusted_proxies=%s).", + client_ip, + request.remote, + request.headers.get("X-Forwarded-For"), + trusted, + self.settings.trusted_proxies, + ) + return web.Response(status=403, text="forbidden") + + raw_body = await request.read() + if self.verify_webhook_signature and not self._verify_webhook_signature( + request, + raw_body, + ): + return web.Response(status=403, text="invalid_signature") + + try: + payload = json.loads(raw_body.decode("utf-8")) + except Exception: + logging.exception("Paykilla 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") + + event_type = str(payload.get("eventType") or "").strip().upper() + data = payload.get("data") + if not event_type or not isinstance(data, dict): + logging.error("Paykilla webhook: missing event envelope fields: %s", payload) + return web.Response(status=400, text="missing_fields") + + if event_type not in _SUCCESS_EVENTS and event_type not in _FAILED_EVENTS: + logging.info("Paykilla webhook: intermediate event '%s' ignored.", event_type) + return web.Response(text="status_ignored") + + data_type = str(data.get("type") or "").strip().upper() + if data_type and data_type != "INVOICE": + logging.info( + "Paykilla webhook: non-invoice event '%s' ignored (type=%s).", + event_type, + data_type, + ) + return web.Response(text="status_ignored") + + invoice_id = str(data.get("id") or "").strip() + client_order_id = data.get("clientOrderId") + amount_raw = data.get("amount") or data.get("expectedAmount") + currency = data.get("currency") or self.currency + + if not (invoice_id or client_order_id): + logging.error("Paykilla webhook: missing invoice 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=client_order_id, + provider_payment_id=invoice_id or None, + ) + if not payment: + logging.error( + "Paykilla webhook: payment not found (clientOrderId=%s, invoice_id=%s)", + client_order_id, + invoice_id, + ) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded" and event_type in _SUCCESS_EVENTS: + return web.Response(text="ok") + + resolved_id = invoice_id or str(payment.payment_id) + + if event_type in _SUCCESS_EVENTS: + if amount_raw is not None: + try: + if not decimal_amounts_equal(amount_raw, payment.amount): + logging.warning( + "Paykilla 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( + "Paykilla 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( + "Paykilla webhook: failed to mark payment %s as succeeded.", + resolved_id, + ) + return web.Response(status=500, text="processing_error") + + sale_mode = payment.sale_mode or ( + "traffic" if self.settings.traffic_sale_mode else "subscription" + ) + payment_units = payment_units_for_activation(payment, sale_mode) + + 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="paykilla", + provider_notification="paykilla", + db_user=payment.user, + log_prefix="Paykilla webhook", + ) + ) + if outcome is None: + return web.Response(status=500, text="processing_error") + return web.Response(text="ok") + + if event_type in _FAILED_EVENTS: + 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( + "Paykilla 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") + + +@router.callback_query(F.data.startswith("pay_paykilla:")) +async def pay_paykilla_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + paykilla_service: PaykillaService, + 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 SPEC.is_available_to_user( + settings, + user_id=callback.from_user.id, + require_configured=False, + ): + await notify_service_unavailable(callback, translator) + return + + if not paykilla_service or not paykilla_service.configured: + logging.error("Paykilla 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_paykilla data in callback: %s", callback.data) + await notify_callback_parse_error(callback, translator) + return + parts, hwid_quote = await quote_hwid_callback_parts( + session=session, + user_id=callback.from_user.id, + parts=parts, + subscription_service=paykilla_service.subscription_service, + currency=default_currency_key_for_settings(settings), + ) + if not parts: + await notify_callback_parse_error(callback, translator) + return + + currency_code = default_payment_currency_code_for_settings(settings) + 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_paykilla", + description=payment_description, + months=parts.months, + provider="paykilla", + sale_mode=parts.sale_mode, + hwid_quote=hwid_quote, + ) + + try: + payment_record = await payment_dal.create_payment_record(session, record_payload) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Paykilla: failed to create payment record for user %s.", callback.from_user.id + ) + await notify_payment_record_failure(callback, translator) + return + + success, response_data = await paykilla_service.create_payment_link( + payment_db_id=payment_record.payment_id, + amount=parts.price, + currency=currency_code, + description=payment_description, + url_callback=paykilla_service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), + ) + 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(response_data, "payment_url"), + provider_payment_id=first_value(response_data, "id"), + log_prefix=_LOG, + ) + + +async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: + settings: Settings = ctx.request.app["settings"] + service: PaykillaService = ctx.request.app["paykilla_service"] + if not service or not service.configured: + return payment_unavailable() + + currency = ctx.currency or default_payment_currency_code_for_settings(settings) + try: + payment = await create_webapp_payment_record( + ctx, + amount=ctx.price, + currency=currency, + status="pending_paykilla", + provider="paykilla", + ) + success, response_data = await service.create_payment_link( + payment_db_id=payment.payment_id, + amount=ctx.price, + currency=currency, + description=ctx.description, + url_callback=service.config.full_webhook_url(settings.WEBHOOK_BASE_URL), + ) + except Exception: + await ctx.session.rollback() + logging.exception("Paykilla WebApp payment failed") + return payment_failed() + + return await finalize_webapp_link_payment( + session=ctx.session, + payment=payment, + api_success=success, + payment_url=first_value(response_data, "payment_url") if success else None, + provider_payment_id=first_value(response_data, "id"), + log_prefix="Paykilla", + ) + + +async def paykilla_webhook_route(request: web.Request) -> web.Response: + service: PaykillaService = request.app["paykilla_service"] + return await service.webhook_route(request) + + +def create_service(ctx: ServiceFactoryContext) -> PaykillaService: + bundle = ctx.config_for("paykilla_service") + config = ( + bundle.config if bundle and isinstance(bundle.config, PaykillaConfig) else PaykillaConfig() + ) + return PaykillaService( + bot=ctx.bot, + settings=ctx.settings, + config=config, + 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, + ) + + +_PRESENTATION_MANIFEST = tuple( + ProviderManifestField( + key=key, + type=type_, + label=label, + description=description, + placeholder=placeholder, + subsection="PayKilla", + target="presentation", + attr=attr, + ) + for key, type_, label, description, placeholder, attr in ( + ( + "PAYMENT_PAYKILLA_WEBAPP_LABEL_RU", + "string", + "WebApp button text (RU)", + "Custom Russian text shown in the Web App payment method button.", + "", + "WEBAPP_LABEL_RU", + ), + ( + "PAYMENT_PAYKILLA_WEBAPP_LABEL_EN", + "string", + "WebApp button text (EN)", + "Custom English text shown in the Web App payment method button.", + "", + "WEBAPP_LABEL_EN", + ), + ( + "PAYMENT_PAYKILLA_WEBAPP_ICON", + "icon", + "WebApp button icon", + "Lucide icon name rendered inside the Web App payment method button.", + "Bitcoin", + "WEBAPP_ICON", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU", + "string", + "Telegram button text (RU)", + "Custom Russian text shown in Telegram bot payment buttons.", + "", + "TELEGRAM_LABEL_RU", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN", + "string", + "Telegram button text (EN)", + "Custom English text shown in Telegram bot payment buttons.", + "", + "TELEGRAM_LABEL_EN", + ), + ( + "PAYMENT_PAYKILLA_TELEGRAM_EMOJI", + "string", + "Telegram button emoji", + "Emoji prepended to the Telegram bot payment button when customized.", + "", + "TELEGRAM_EMOJI", + ), + ) +) + + +_CONFIG_MANIFEST = ( + ProviderManifestField( + "PAYKILLA_ENABLED", "bool", "Enabled", subsection="PayKilla", attr="ENABLED" + ), + ProviderManifestField( + "PAYKILLA_API_KEY", + "string", + "API key", + description="PayKilla public HMAC key with INVOICE permission.", + subsection="PayKilla", + secret=True, + attr="API_KEY", + ), + ProviderManifestField( + "PAYKILLA_SECRET_KEY", + "string", + "Secret key", + description="PayKilla HMAC secret key. Never expose it client-side.", + subsection="PayKilla", + secret=True, + attr="SECRET_KEY", + ), + ProviderManifestField( + "PAYKILLA_BASE_URL", + "url", + "Base URL", + placeholder="https://account-api.paykilla.com", + subsection="PayKilla", + attr="BASE_URL", + ), + ProviderManifestField( + "PAYKILLA_WIDGET_URL", + "url", + "Widget URL", + placeholder="https://gopay.paykilla.com", + subsection="PayKilla", + attr="WIDGET_URL", + ), + ProviderManifestField( + "PAYKILLA_CURRENCY", + "string", + "Invoice currency", + description=( + "Fiat or crypto invoice currency. FIAT_BASED is selected for RUB/USD/EUR/AED/GBP." + ), + placeholder="RUB", + subsection="PayKilla", + attr="CURRENCY", + ), + ProviderManifestField( + "PAYKILLA_SUPPORTED_CURRENCIES", + "string", + "Supported invoice currencies", + description="Comma-separated invoice currencies allowed for PayKilla in this shop.", + placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES, + subsection="PayKilla", + attr="SUPPORTED_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_PAYMENT_CURRENCIES", + "string", + "Accepted crypto tickers", + description=( + "Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC,BTC,ETH." + ), + placeholder=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES, + subsection="PayKilla", + attr="PAYMENT_CURRENCIES", + ), + ProviderManifestField( + "PAYKILLA_INVOICE_TYPE", + "string", + "Invoice type", + description="Optional override: FIAT_BASED, FIXED_AMOUNT, or OPEN_AMOUNT.", + subsection="PayKilla", + attr="INVOICE_TYPE", + choices=( + ("", "Auto"), + ("FIAT_BASED", "FIAT_BASED"), + ("FIXED_AMOUNT", "FIXED_AMOUNT"), + ("OPEN_AMOUNT", "OPEN_AMOUNT"), + ), + ), + ProviderManifestField( + "PAYKILLA_RETURN_URL", "url", "Return URL", subsection="PayKilla", attr="RETURN_URL" + ), + ProviderManifestField( + "PAYKILLA_SUCCESS_URL", "url", "Success URL", subsection="PayKilla", attr="SUCCESS_URL" + ), + ProviderManifestField( + "PAYKILLA_CANCEL_URL", "url", "Cancel URL", subsection="PayKilla", attr="CANCEL_URL" + ), + ProviderManifestField( + "PAYKILLA_LIFETIME_SECONDS", + "int", + "Invoice lifetime (seconds)", + description="Used to send expiredAt to PayKilla.", + subsection="PayKilla", + min=300, + max=2_592_000, + attr="LIFETIME_SECONDS", + ), + ProviderManifestField( + "PAYKILLA_RECV_WINDOW_MS", + "int", + "Request recvWindow (ms)", + description="Validity window for signed PayKilla API requests.", + subsection="PayKilla", + min=1000, + max=60_000, + attr="RECV_WINDOW_MS", + ), + ProviderManifestField( + "PAYKILLA_USER_PAYS_SERVICE_FEE", + "bool", + "User pays service fee", + subsection="PayKilla", + attr="USER_PAYS_SERVICE_FEE", + ), + ProviderManifestField( + "PAYKILLA_USER_PAYS_NETWORK_FEE", + "bool", + "User pays network fee", + subsection="PayKilla", + attr="USER_PAYS_NETWORK_FEE", + ), + ProviderManifestField( + "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", + "bool", + "Verify webhook signature", + subsection="PayKilla", + attr="VERIFY_WEBHOOK_SIGNATURE", + ), + ProviderManifestField( + "PAYKILLA_WEBHOOK_URL", + "url", + "Exact webhook URL", + description=( + "Optional override for signature verification. Leave empty to use " + "WEBHOOK_BASE_URL + /webhook/paykilla." + ), + subsection="PayKilla", + attr="WEBHOOK_URL", + ), + ProviderManifestField( + "PAYKILLA_TRUSTED_IPS", + "string", + "Trusted IPs", + description="Optional comma-separated IP addresses accepted for PayKilla webhooks.", + subsection="PayKilla", + attr="TRUSTED_IPS", + ), +) + + +SPEC = PaymentProviderSpec( + id="paykilla", + provider_key="paykilla", + label="PayKilla", + webapp_label="PayKilla", + webapp_labels={"ru": "PayKilla", "en": "PayKilla"}, + webapp_icon="Bitcoin", + telegram_labels={"ru": "PayKilla", "en": "PayKilla"}, + telegram_emoji="", + pending_status="pending_paykilla", + enabled=lambda config: bool(getattr(config, "ENABLED", False)), + service_key="paykilla_service", + callback_prefix="pay_paykilla", + router=router, + create_service=create_service, + webhook_path=lambda source: "/webhook/paykilla", + webhook_route=paykilla_webhook_route, + create_webapp_payment=create_webapp_payment, + emoji="", + config_class=PaykillaConfig, + presentation_class=PaykillaPresentation, + manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, + supported_currencies_resolver=lambda config: getattr( + config, "SUPPORTED_CURRENCIES", PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES + ), + currency_support_note=( + "PayKilla invoice currency and paymentCurrencies availability can depend on " + "merchant account settings." + ), + currency_support_url="https://paykilla.gitbook.io/paykilla-docs/api-integration/supported-currencies", +) diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 1767ca9..673a10e 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, Dict, Iterable, List, Mapping, Optional -from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa +from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa from .base import ( PaymentProviderPresentation, PaymentProviderSpec, @@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( stars.SPEC, cryptopay.SPEC, heleket.SPEC, + paykilla.SPEC, ) diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 3562405..419bc2a 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -14,6 +14,7 @@ class PaymentContextMixin: "severpay": "SeverPay", "wata": "Wata", "cryptopay": "Crypto Pay", + "paykilla": "PayKilla", "telegram_stars": "Telegram Stars", } diff --git a/backend/config/settings.py b/backend/config/settings.py index 0908818..43795a3 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -305,7 +305,7 @@ class Settings(BaseSettings): STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 + description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501 ) SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field( default=True, @@ -1026,6 +1026,7 @@ class Settings(BaseSettings): "stars", "cryptopay", "heleket", + "paykilla", ] # Make sure default_order itself includes every registered spec. for sid in spec_ids: diff --git a/backend/scripts/import_legacy.py b/backend/scripts/import_legacy.py index 5936020..d2352a2 100644 --- a/backend/scripts/import_legacy.py +++ b/backend/scripts/import_legacy.py @@ -75,6 +75,7 @@ SUPPORTED_REMNASHOP_PROVIDER_TYPES = { "TELEGRAM_STARS", "YOOKASSA", "HELEKET", + "PAYKILLA", "CRYPTOPAY", "FREEKASSA", "PLATEGA", @@ -93,6 +94,7 @@ PAYMENT_WEBHOOK_PATHS = { "wata": "/webhook/wata", "cryptopay": "/webhook/cryptopay", "heleket": "/webhook/heleket", + "paykilla": "/webhook/paykilla", "freekassa": "/webhook/freekassa", "platega": "/webhook/platega", } @@ -477,6 +479,26 @@ def remnashop_payment_gateway_overrides( ) return _provider_mapping_result(gateway_type, ["heleket"], overrides, warnings) + if gateway_type == "PAYKILLA": + _add_override(overrides, "PAYKILLA_ENABLED", active) + _add_override( + overrides, + "PAYKILLA_API_KEY", + settings.get("api_key") or settings.get("public_key") or settings.get("publicKey"), + ) + _add_override( + overrides, + "PAYKILLA_SECRET_KEY", + settings.get("secret_key") or settings.get("secretKey"), + ) + if currency and currency != "RUB": + warnings.append( + f"PayKilla source currency was {currency}; Minishop keeps payment currency " + "controlled by tariffs/default currency. Configure PAYKILLA_CURRENCY and " + "PAYKILLA_PAYMENT_CURRENCIES manually if this instance needs a different default." + ) + return _provider_mapping_result(gateway_type, ["paykilla"], overrides, warnings) + if gateway_type == "FREEKASSA": _add_override(overrides, "FREEKASSA_ENABLED", active) _add_override(overrides, "FREEKASSA_MERCHANT_ID", settings.get("shop_id")) @@ -539,6 +561,7 @@ def remnashop_post_migration_actions( "wata": "WATA merchant dashboard -> webhook/callback URL", "cryptopay": "CryptoBot/Crypto Pay app -> webhook URL", "heleket": "Heleket merchant dashboard -> payment webhook/callback URL", + "paykilla": "PayKilla Dashboard -> Settings -> Webhooks", "freekassa": "FreeKassa shop settings -> notification/result URL", "platega": "Platega merchant/project settings -> webhook URL", }.get(provider_id, "Payment provider dashboard -> webhook/callback URL"), diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 7b5e03e..6bea97d 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -197,7 +197,7 @@ | Переменная | Назначение | | --- | --- | -| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket`. | +| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. | | `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. | | `PAYMENT__WEBAPP_LABEL_RU` / `PAYMENT__WEBAPP_LABEL_EN` | Текст кнопки провайдера в Web App. | @@ -213,6 +213,7 @@ | `WATA_ENABLED` | Включает Wata. | | `CRYPTOPAY_ENABLED` | Включает CryptoPay. | | `HELEKET_ENABLED` | Включает Heleket. | +| `PAYKILLA_ENABLED` | Включает PayKilla. | Конкретные ключи отображения: @@ -271,6 +272,12 @@ PAYMENT_HELEKET_WEBAPP_ICON PAYMENT_HELEKET_TELEGRAM_LABEL_RU PAYMENT_HELEKET_TELEGRAM_LABEL_EN PAYMENT_HELEKET_TELEGRAM_EMOJI +PAYMENT_PAYKILLA_WEBAPP_LABEL_RU +PAYMENT_PAYKILLA_WEBAPP_LABEL_EN +PAYMENT_PAYKILLA_WEBAPP_ICON +PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU +PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN +PAYMENT_PAYKILLA_TELEGRAM_EMOJI ``` ### YooKassa @@ -357,6 +364,33 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI | `HELEKET_VERIFY_WEBHOOK_SIGNATURE` | Проверять подпись webhook. | | `HELEKET_TRUSTED_IPS` | Список доверенных IP webhook-источников. | +### PayKilla + +Для приема оплат нужен API key типа **HMAC** с правом **INVOICE**. Право **WITHDRAWAL** для оплаты подписок не требуется; включайте его только для отдельной интеграции выплат. + +Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks**. Укажите `WEBHOOK_BASE_URL` + `/webhook/paykilla`, например `https://bot.example.com/webhook/paykilla`. Включите события `INVOICE_PAID` и `INVOICE_EXPIRED` как минимум. Рекомендуемый набор галочек: `INVOICE_PAID`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `INVOICE_EXPIRED`, `COMPLIANCE_FAILED`. Если хотите видеть промежуточные статусы в логах PayKilla, дополнительно включите `INVOICE_CREATED`, `PAYMENT_PENDING`, `TRANSACTION_CONFIRMED` и `TRANSACTION_FINAL`. + +| Переменная | Назначение | +| --- | --- | +| `PAYKILLA_BASE_URL` | Базовый URL API, по умолчанию `https://account-api.paykilla.com`. | +| `PAYKILLA_WIDGET_URL` | URL hosted checkout, по умолчанию `https://gopay.paykilla.com`. | +| `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. | +| `PAYKILLA_SECRET_KEY` / `PAYKILLA_V2_SECRET_KEY` | Secret HMAC key для подписи API-запросов и проверки webhook. | +| `PAYKILLA_CURRENCY` | Валюта инвойса. Для RUB/USD/EUR/AED/GBP используется `FIAT_BASED`, для остальных - `FIXED_AMOUNT`. | +| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты, например `USDTTRC,BTC,ETH`. | +| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты инвойса, разрешенные в этом магазине. | +| `PAYKILLA_INVOICE_TYPE` | Необязательный override: `FIAT_BASED`, `FIXED_AMOUNT` или `OPEN_AMOUNT`. | +| `PAYKILLA_RETURN_URL` | URL кнопки возврата на checkout-странице. | +| `PAYKILLA_SUCCESS_URL` | URL после успешной оплаты; используется с auto-redirect. | +| `PAYKILLA_CANCEL_URL` | URL после отмены оплаты. | +| `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. | +| `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. | +| `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service fee. | +| `PAYKILLA_USER_PAYS_NETWORK_FEE` | `true`, если пользователь оплачивает network fee. | +| `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE` | Проверять `X-API-SIGN` по raw body webhook. | +| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. | +| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. | + ## Тарифы и legacy-цены Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`. diff --git a/docs/features/payments.md b/docs/features/payments.md index ce8d4ee..9cd4c77 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -31,6 +31,7 @@ | Wata | `WEBHOOK_BASE_URL` + `/webhook/wata` | Если включена проверка подписи, настройте `WATA_WEBHOOK_VERIFY_SIGNATURE` и `WATA_PUBLIC_KEY`. | | CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | Указывается в настройках Crypto Bot / CryptoPay webhook. | | Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE` и `HELEKET_TRUSTED_IPS`. | +| PayKilla | `WEBHOOK_BASE_URL` + `/webhook/paykilla` | Указывается в PayKilla Dashboard -> Settings -> Webhooks; включите события оплаты инвойсов. | | Telegram Stars | Отдельный платежный webhook не нужен | Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. | После настройки сделайте тестовый платеж и проверьте, что в логах `backend` видно входящий `POST` на нужный путь. Если провайдер сообщает, что адрес недоступен, сначала проверьте DNS/HTTPS и reverse proxy для `WEBHOOK_BASE_URL`, затем убедитесь, что путь начинается ровно с `/webhook/...` без `/api`, `/auth` и frontend-домена. @@ -153,6 +154,37 @@ Heleket используется для крипто-инвойсов с отд Справочник переменных: [Heleket](../configuration/env-vars.md#heleket). +## PayKilla + +PayKilla используется для крипто-инвойсов V2 через hosted checkout `https://gopay.paykilla.com/{invoice_id}`. API-запросы подписываются HMAC-SHA256, webhook проверяется по заголовку `X-API-SIGN` и raw body. + +Какие полномочия нужны API key: + +1. В PayKilla Dashboard откройте **Settings -> API keys**. +2. Создайте ключ типа **HMAC**. +3. Для приема оплат включите permission **INVOICE**. +4. Permission **WITHDRAWAL** не нужен для Minishop-платежей; не включайте его без отдельной необходимости выплат. +5. Сохраните `publicKey` в `PAYKILLA_API_KEY`, а `secretKey` в `PAYKILLA_SECRET_KEY`. + +Как настроить webhook в PayKilla: + +1. Откройте **Settings -> Webhooks**. +2. В URL укажите `WEBHOOK_BASE_URL` + `/webhook/paykilla`, например `https://bot.example.com/webhook/paykilla`. +3. Минимальные галочки: `INVOICE_PAID`, `INVOICE_EXPIRED`. +4. Рекомендуемые галочки для production: `INVOICE_PAID`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED`, `PAYMENT_OVERPAID`, `PAYMENT_UNDERPAID`, `PAYMENT_PARTIAL`, `INVOICE_EXPIRED`, `COMPLIANCE_FAILED`. +5. Опционально включите `INVOICE_CREATED`, `PAYMENT_PENDING`, `TRANSACTION_CONFIRMED` и `TRANSACTION_FINAL`, если нужны промежуточные события в логах. +6. Оставьте `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE=True`. Если публичный URL у PayKilla отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`, задайте точное значение в `PAYKILLA_WEBHOOK_URL`. + +Что настроить в Minishop: + +1. Включите `PAYKILLA_ENABLED`. +2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`. +3. Проверьте `PAYKILLA_CURRENCY` и `PAYKILLA_PAYMENT_CURRENCIES`, например `USDTTRC,BTC,ETH`. +4. При необходимости задайте `PAYKILLA_SUCCESS_URL`, `PAYKILLA_RETURN_URL` и `PAYKILLA_CANCEL_URL`. +5. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок. + +Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla). + ## Telegram Stars Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов. diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md index 00dd2af..00d6056 100644 --- a/docs/migrations/remnashop.md +++ b/docs/migrations/remnashop.md @@ -46,7 +46,7 @@ App, а не веб-кабинетом Remnashop. В Minishop `SUBSCRIPTION_MINI Платежные провайдеры берутся из таблицы Remnashop `payment_gateways`. Поддерживаются и автоматически маппятся: Telegram Stars, YooKassa, WATA, -CryptoPay, Heleket, FreeKassa и Platega. Для них importer переносит флаги +CryptoPay, Heleket, PayKilla, FreeKassa и Platega. Для них importer переносит флаги включения, API-ключи/merchant IDs и прямые технические параметры, без которых провайдер не сможет работать: YooKassa receipt email/VAT, FreeKassa second secret/payment method/server IP и Platega payment method. @@ -55,7 +55,8 @@ Provider currency и supported-currency ограничения не перено в Minishop валюта платежа управляется тарифами и `DEFAULT_CURRENCY_SYMBOL`. Если старый gateway Remnashop был настроен на нестандартную валюту, importer оставит предупреждение в JSON-сводке; проверьте `CRYPTOPAY_ASSET`, -`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES` или +`HELEKET_CURRENCY`, `HELEKET_SUPPORTED_CURRENCIES`, `PAYKILLA_CURRENCY`, +`PAYKILLA_PAYMENT_CURRENCIES` или `PLATEGA_SUPPORTED_CURRENCIES` вручную. Провайдеры YooMoney, Cryptomus, MulenPay, PayMaster, RoboKassa и UrlPay сейчас @@ -77,6 +78,7 @@ Remnashop может хранить секреты в формате `enc_...`. - WATA webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/wata`; - CryptoBot/Crypto Pay webhook URL: `WEBHOOK_BASE_URL` + `/webhook/cryptopay`; - Heleket payment webhook/callback URL: `WEBHOOK_BASE_URL` + `/webhook/heleket`; +- PayKilla webhook URL: `WEBHOOK_BASE_URL` + `/webhook/paykilla`; - FreeKassa notification/result URL: `WEBHOOK_BASE_URL` + `/webhook/freekassa`; - Platega webhook URL: `WEBHOOK_BASE_URL` + `/webhook/platega`; - Telegram webhook `WEBHOOK_BASE_URL` + `/tg/webhook` выставляется ботом diff --git a/frontend/src/lib/webapp/demoDataset.js b/frontend/src/lib/webapp/demoDataset.js index fdf5256..23982b1 100644 --- a/frontend/src/lib/webapp/demoDataset.js +++ b/frontend/src/lib/webapp/demoDataset.js @@ -479,6 +479,11 @@ export const DEMO_DATASET = { name: "Heleket", icon: "CreditCard", }, + { + id: "paykilla", + name: "PayKilla", + icon: "Bitcoin", + }, ], referral: { code: "DEMO0001", diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json index dac56b4..36ac6d3 100644 --- a/frontend/src/lib/webapp/settingsManifest.generated.json +++ b/frontend/src/lib/webapp/settingsManifest.generated.json @@ -650,7 +650,7 @@ "section_order": 4, "subsection": "common", "label": "Порядок методов оплаты", - "description": "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket", + "description": "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla", "i18n_label_key": "admin_settings_field_payment_methods_order_label", "i18n_description_key": "admin_settings_field_payment_methods_order_description", "i18n_subsection_key": "admin_settings_subsection_common", @@ -3839,6 +3839,692 @@ "overridden": false, "updated_at": null, "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_ENABLED", + "type": "bool", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Enabled", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_enabled_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "mutually_exclusive_key": "PAYKILLA_ADMIN_ONLY_ENABLED", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_API_KEY", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "API key", + "description": "PayKilla public HMAC key with INVOICE permission.", + "i18n_label_key": "admin_settings_field_paykilla_api_key_label", + "i18n_description_key": "admin_settings_field_paykilla_api_key_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": true, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "has_value": false, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_SECRET_KEY", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Secret key", + "description": "PayKilla HMAC secret key. Never expose it client-side.", + "i18n_label_key": "admin_settings_field_paykilla_secret_key_label", + "i18n_description_key": "admin_settings_field_paykilla_secret_key_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": true, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "has_value": false, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_BASE_URL", + "type": "url", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Base URL", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_base_url_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_paykilla_base_url_placeholder", + "placeholder": "https://account-api.paykilla.com", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_WIDGET_URL", + "type": "url", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Widget URL", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_widget_url_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_paykilla_widget_url_placeholder", + "placeholder": "https://gopay.paykilla.com", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_CURRENCY", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Invoice currency", + "description": "Fiat or crypto invoice currency. FIAT_BASED is selected for RUB/USD/EUR/AED/GBP.", + "i18n_label_key": "admin_settings_field_paykilla_currency_label", + "i18n_description_key": "admin_settings_field_paykilla_currency_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_paykilla_currency_placeholder", + "placeholder": "RUB", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_SUPPORTED_CURRENCIES", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Supported invoice currencies", + "description": "Comma-separated invoice currencies allowed for PayKilla in this shop.", + "i18n_label_key": "admin_settings_field_paykilla_supported_currencies_label", + "i18n_description_key": "admin_settings_field_paykilla_supported_currencies_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_paykilla_supported_currencies_placeholder", + "placeholder": "RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC,USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC,USDTTON,AAVEETH,MANAETH,SHIBETH", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_PAYMENT_CURRENCIES", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Accepted crypto tickers", + "description": "Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC,BTC,ETH.", + "i18n_label_key": "admin_settings_field_paykilla_payment_currencies_label", + "i18n_description_key": "admin_settings_field_paykilla_payment_currencies_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_paykilla_payment_currencies_placeholder", + "placeholder": "USDTTRC,BTC,ETH", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_INVOICE_TYPE", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Invoice type", + "description": "Optional override: FIAT_BASED, FIXED_AMOUNT, or OPEN_AMOUNT.", + "i18n_label_key": "admin_settings_field_paykilla_invoice_type_label", + "i18n_description_key": "admin_settings_field_paykilla_invoice_type_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "choices": [ + { + "value": "", + "label": "Auto", + "i18n_label_key": "admin_settings_field_paykilla_invoice_type_choice_default" + }, + { + "value": "FIAT_BASED", + "label": "FIAT_BASED", + "i18n_label_key": "admin_settings_field_paykilla_invoice_type_choice_fiat_based" + }, + { + "value": "FIXED_AMOUNT", + "label": "FIXED_AMOUNT", + "i18n_label_key": "admin_settings_field_paykilla_invoice_type_choice_fixed_amount" + }, + { + "value": "OPEN_AMOUNT", + "label": "OPEN_AMOUNT", + "i18n_label_key": "admin_settings_field_paykilla_invoice_type_choice_open_amount" + } + ], + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_RETURN_URL", + "type": "url", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Return URL", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_return_url_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_SUCCESS_URL", + "type": "url", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Success URL", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_success_url_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_CANCEL_URL", + "type": "url", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Cancel URL", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_cancel_url_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_LIFETIME_SECONDS", + "type": "int", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Invoice lifetime (seconds)", + "description": "Used to send expiredAt to PayKilla.", + "i18n_label_key": "admin_settings_field_paykilla_lifetime_seconds_label", + "i18n_description_key": "admin_settings_field_paykilla_lifetime_seconds_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 300, + "max": 2592000, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_RECV_WINDOW_MS", + "type": "int", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Request recvWindow (ms)", + "description": "Validity window for signed PayKilla API requests.", + "i18n_label_key": "admin_settings_field_paykilla_recv_window_ms_label", + "i18n_description_key": "admin_settings_field_paykilla_recv_window_ms_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "min": 1000, + "max": 60000, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_USER_PAYS_SERVICE_FEE", + "type": "bool", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "User pays service fee", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_user_pays_service_fee_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_USER_PAYS_NETWORK_FEE", + "type": "bool", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "User pays network fee", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_user_pays_network_fee_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", + "type": "bool", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Verify webhook signature", + "description": "", + "i18n_label_key": "admin_settings_field_paykilla_verify_webhook_signature_label", + "i18n_description_key": null, + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_WEBHOOK_URL", + "type": "url", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Exact webhook URL", + "description": "Optional override for signature verification. Leave empty to use WEBHOOK_BASE_URL + /webhook/paykilla.", + "i18n_label_key": "admin_settings_field_paykilla_webhook_url_label", + "i18n_description_key": "admin_settings_field_paykilla_webhook_url_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_TRUSTED_IPS", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Trusted IPs", + "description": "Optional comma-separated IP addresses accepted for PayKilla webhooks.", + "i18n_label_key": "admin_settings_field_paykilla_trusted_ips_label", + "i18n_description_key": "admin_settings_field_paykilla_trusted_ips_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYMENT_PAYKILLA_WEBAPP_LABEL_RU", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "WebApp button text (RU)", + "description": "Custom Russian text shown in the Web App payment method button.", + "i18n_label_key": "admin_settings_field_payment_paykilla_webapp_label_ru_label", + "i18n_description_key": "admin_settings_field_payment_paykilla_webapp_label_ru_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_payment_paykilla_webapp_label_ru_placeholder", + "placeholder": "PayKilla", + "optional": true, + "secret": false, + "default": "PayKilla", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYMENT_PAYKILLA_WEBAPP_LABEL_EN", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "WebApp button text (EN)", + "description": "Custom English text shown in the Web App payment method button.", + "i18n_label_key": "admin_settings_field_payment_paykilla_webapp_label_en_label", + "i18n_description_key": "admin_settings_field_payment_paykilla_webapp_label_en_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_payment_paykilla_webapp_label_en_placeholder", + "placeholder": "PayKilla", + "optional": true, + "secret": false, + "default": "PayKilla", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYMENT_PAYKILLA_WEBAPP_ICON", + "type": "icon", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "WebApp button icon", + "description": "Lucide icon name rendered inside the Web App payment method button.", + "i18n_label_key": "admin_settings_field_payment_paykilla_webapp_icon_label", + "i18n_description_key": "admin_settings_field_payment_paykilla_webapp_icon_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_payment_paykilla_webapp_icon_placeholder", + "placeholder": "Bitcoin", + "optional": true, + "secret": false, + "default": "Bitcoin", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Telegram button text (RU)", + "description": "Custom Russian text shown in Telegram bot payment buttons.", + "i18n_label_key": "admin_settings_field_payment_paykilla_telegram_label_ru_label", + "i18n_description_key": "admin_settings_field_payment_paykilla_telegram_label_ru_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_payment_paykilla_telegram_label_ru_placeholder", + "placeholder": "PayKilla", + "optional": true, + "secret": false, + "default": "PayKilla", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Telegram button text (EN)", + "description": "Custom English text shown in Telegram bot payment buttons.", + "i18n_label_key": "admin_settings_field_payment_paykilla_telegram_label_en_label", + "i18n_description_key": "admin_settings_field_payment_paykilla_telegram_label_en_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": "admin_settings_field_payment_paykilla_telegram_label_en_placeholder", + "placeholder": "PayKilla", + "optional": true, + "secret": false, + "default": "PayKilla", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYMENT_PAYKILLA_TELEGRAM_EMOJI", + "type": "string", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Telegram button emoji", + "description": "Emoji prepended to the Telegram bot payment button when customized.", + "i18n_label_key": "admin_settings_field_payment_paykilla_telegram_emoji_label", + "i18n_description_key": "admin_settings_field_payment_paykilla_telegram_emoji_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "default": "", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false + }, + { + "key": "PAYKILLA_ADMIN_ONLY_ENABLED", + "type": "bool", + "section": "payments", + "section_order": 4, + "subsection": "PayKilla", + "label": "Only for admins", + "description": "Shows this payment method only to users from ADMIN_IDS. Webhooks and provider services remain active for admin test payments.", + "i18n_label_key": "admin_settings_provider_admin_only_label", + "i18n_description_key": "admin_settings_provider_admin_only_description", + "i18n_subsection_key": "admin_settings_subsection_paykilla", + "i18n_placeholder_key": null, + "placeholder": "", + "optional": true, + "secret": false, + "mutually_exclusive_key": "PAYKILLA_ENABLED", + "provider_id": "paykilla", + "provider_label": "PayKilla", + "webhook_provider_id": "paykilla", + "webhook_path": "/webhook/paykilla", + "webhook_requires_base_url": false, + "value": "", + "overridden": false, + "updated_at": null, + "webhook_base_url_configured": false } ] }, diff --git a/scripts/install.sh b/scripts/install.sh index b4b5caf..df4fb41 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -874,6 +874,7 @@ remnashop_webhook_checklist() { printf ' WATA merchant dashboard -> webhook/callback URL: %s/webhook/wata\n' "$base_url" printf ' CryptoBot/Crypto Pay app -> webhook URL: %s/webhook/cryptopay\n' "$base_url" printf ' Heleket merchant dashboard -> payment webhook/callback URL: %s/webhook/heleket\n' "$base_url" + printf ' PayKilla Dashboard -> Settings -> Webhooks URL: %s/webhook/paykilla\n' "$base_url" printf ' FreeKassa shop settings -> notification/result URL: %s/webhook/freekassa\n' "$base_url" printf ' Platega merchant/project settings -> webhook URL: %s/webhook/platega\n' "$base_url" printf ' Telegram webhook: %s/tg/webhook (configured automatically on bot startup)\n' "$base_url" diff --git a/tests/test_build_services_wiring.py b/tests/test_build_services_wiring.py index 9b47b82..1ba47ea 100644 --- a/tests/test_build_services_wiring.py +++ b/tests/test_build_services_wiring.py @@ -175,6 +175,7 @@ class BuildServicesWiringTests(unittest.TestCase): "severpay_service", "wata_service", "heleket_service", + "paykilla_service", } self.assertEqual(set(services), expected_keys) diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 204db17..8de03a0 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -76,6 +76,7 @@ def test_shell_installer_prints_remnashop_webhook_checklist(): assert "/webhook/wata" in script assert "/webhook/cryptopay" in script assert "/webhook/heleket" in script + assert "/webhook/paykilla" in script assert "/webhook/freekassa" in script assert "/webhook/platega" in script assert "/tg/webhook" in script diff --git a/tests/test_migration_doc_accuracy.py b/tests/test_migration_doc_accuracy.py index 6c0b850..6afa7e1 100644 --- a/tests/test_migration_doc_accuracy.py +++ b/tests/test_migration_doc_accuracy.py @@ -203,6 +203,7 @@ class RemnashopMigrationDocumentationFactsTests(unittest.TestCase): "/webhook/wata", "/webhook/cryptopay", "/webhook/heleket", + "/webhook/paykilla", "/webhook/freekassa", "/webhook/platega", "/tg/webhook", diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index fd05e27..9557113 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -64,6 +64,7 @@ _PROVIDER_MODULES = { "stars": "StarsService", "wata": "WataService", "heleket": "HeleketService", + "paykilla": "PaykillaService", } @@ -125,6 +126,7 @@ def test_service_keys_and_statuses_come_from_provider_specs(): "stars_service", "cryptopay_service", "heleket_service", + "paykilla_service", } assert set(pending_statuses()) >= { "pending", @@ -136,6 +138,7 @@ def test_service_keys_and_statuses_come_from_provider_specs(): "pending_cryptopay", "pending_stars", "pending_heleket", + "pending_paykilla", } @@ -152,6 +155,7 @@ def test_provider_labels_and_emojis_include_storage_keys_and_method_aliases(): assert emojis["stars"] == get_provider_spec("stars").default_telegram_emoji assert emojis["telegram_stars"] == get_provider_spec("stars").default_telegram_emoji assert emojis["cryptopay"] == get_provider_spec("cryptopay").default_telegram_emoji + assert labels["paykilla"] == "PayKilla" def test_provider_presentation_resolves_defaults_and_overrides(): diff --git a/tests/test_provider_labels.py b/tests/test_provider_labels.py index 405a848..73d8b08 100644 --- a/tests/test_provider_labels.py +++ b/tests/test_provider_labels.py @@ -27,6 +27,7 @@ _PROVIDERS_FROM_CALLERS = { "severpay", "wata", "cryptopay", + "paykilla", "telegram_stars", } diff --git a/tests/test_remnashop_import.py b/tests/test_remnashop_import.py index 4316f96..034c5ec 100644 --- a/tests/test_remnashop_import.py +++ b/tests/test_remnashop_import.py @@ -185,6 +185,22 @@ def test_remnashop_crypto_provider_currency_is_not_imported_as_setting_override( } assert "HELEKET_CURRENCY" not in heleket["overrides"] + paykilla = remnashop_payment_gateway_overrides( + { + "type": "PAYKILLA", + "currency": "USD", + "is_active": True, + "settings": {"public_key": "public", "secret_key": "secret"}, + } + ) + assert paykilla["overrides"] == { + "PAYKILLA_ENABLED": True, + "PAYKILLA_API_KEY": "public", + "PAYKILLA_SECRET_KEY": "secret", + } + assert "PAYKILLA_CURRENCY" not in paykilla["overrides"] + assert any("source currency was USD" in warning for warning in paykilla["warnings"]) + def test_remnashop_unsupported_gateway_is_reported_without_overrides(): result = remnashop_payment_gateway_overrides( diff --git a/tests/test_security.py b/tests/test_security.py index 9fae43d..7ffa40b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,6 +1,7 @@ import hashlib import hmac import json +import time import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -19,6 +20,12 @@ from bot.app.web.webapp_auth import ( from bot.payment_providers.cryptopay import CryptoPayService from bot.payment_providers.freekassa import FreeKassaService from bot.payment_providers.heleket import HeleketConfig, HeleketService, _compute_signature +from bot.payment_providers.paykilla import ( + PaykillaConfig, + PaykillaService, + _sign_query, + _webhook_signature, +) from bot.payment_providers.yookassa import yookassa_webhook_route from bot.services.email_templates import render_login_code from bot.utils.request_security import request_client_ip @@ -199,6 +206,79 @@ class HeleketServiceTests(unittest.TestCase): self.assertFalse(service._verify_signature({"order_id": "435", "sign": "bad"})) +class PaykillaServiceTests(unittest.TestCase): + def _make_service(self, *, webhook_url: str = "https://shop.example/webhook/paykilla"): + service = PaykillaService.__new__(PaykillaService) + service.config = PaykillaConfig( + ENABLED=True, + API_KEY="public-key", + SECRET_KEY="secret-key", + WEBHOOK_URL=webhook_url, + ) + service.settings = SimpleNamespace( + WEBHOOK_BASE_URL="https://shop.example", + trusted_proxies=["127.0.0.1"], + ) + return service + + def test_sign_query_uses_timestamp_and_recv_window_only(self): + query, signature = _sign_query(1738800000000, 5000, "secret-key") + expected = hmac.new( + b"secret-key", + b"timestamp=1738800000000&recvWindow=5000", + hashlib.sha256, + ).hexdigest() + + self.assertEqual(query, "timestamp=1738800000000&recvWindow=5000") + self.assertEqual(signature, expected) + + def test_verify_webhook_signature_accepts_raw_body_signature(self): + service = self._make_service() + raw_body = ( + b'{"id":"evt_1","priority":"HIGH","eventType":"INVOICE_PAID",' + b'"data":{"id":"inv_1","clientOrderId":"42"}}' + ) + timestamp = str(int(time.time() * 1000)) + signature = _webhook_signature( + timestamp=timestamp, + method="POST", + url="https://shop.example/webhook/paykilla", + raw_body=raw_body, + secret_key="secret-key", + ) + request = SimpleNamespace( + headers={ + "X-API-KEY": "public-key", + "X-API-TIMESTAMP": timestamp, + "X-API-RECV-WINDOW": "5000", + "X-API-SIGN": signature, + }, + method="POST", + scheme="https", + host="shop.example", + path_qs="/webhook/paykilla", + ) + + self.assertTrue(service._verify_webhook_signature(request, raw_body)) + + def test_verify_webhook_signature_rejects_invalid_signature(self): + service = self._make_service() + request = SimpleNamespace( + headers={ + "X-API-KEY": "public-key", + "X-API-TIMESTAMP": str(int(time.time() * 1000)), + "X-API-RECV-WINDOW": "5000", + "X-API-SIGN": "bad", + }, + method="POST", + scheme="https", + host="shop.example", + path_qs="/webhook/paykilla", + ) + + self.assertFalse(service._verify_webhook_signature(request, b"{}")) + + class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase): class _AsyncSessionFactory: def __call__(self):