v0.1
This commit is contained in:
@@ -32,6 +32,14 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com #
|
|||||||
YOOKASSA_VAT_CODE=1 # VAT code
|
YOOKASSA_VAT_CODE=1 # VAT code
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||||
|
|
||||||
|
# FreeKassa Payment Gateway Configuration
|
||||||
|
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||||
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
|
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||||
|
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||||
|
FREEKASSA_CURRENCY=RUB # Default currency for orders
|
||||||
|
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||||
|
|
||||||
# CryptoBot Payment Gateway Configuration
|
# CryptoBot Payment Gateway Configuration
|
||||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa, CryptoPay, Telegram Stars и Tribute.
|
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||||
|
|
||||||
### Для администраторов:
|
### Для администраторов:
|
||||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||||
@@ -83,10 +83,11 @@
|
|||||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||||
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||||
| `FREEKASSA_FIRST_SECRET` | Секретное слово №1 для формирования ссылок оплаты. |
|
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 для проверки уведомлений. |
|
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||||
| `FREEKASSA_PAYMENT_URL` | (Опционально) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||||
| `FREEKASSA_CURRENCY` | Код валюты платежа (например, `RUB`). |
|
| `FREEKASSA_CURRENCY` | Код валюты платежа (например, `RUB`). |
|
||||||
|
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F, types
|
from aiogram import Router, F, types
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -308,7 +309,7 @@ async def pay_fk_callback_handler(
|
|||||||
|
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
payment_description = get_text("payment_description_subscription", months=months)
|
payment_description = get_text("payment_description_subscription", months=months)
|
||||||
currency_code = freekassa_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
|
||||||
payment_record_payload = {
|
payment_record_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -339,30 +340,169 @@ async def pay_fk_callback_handler(
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
payment_link = None
|
method_keyboard = InlineKeyboardBuilder()
|
||||||
try:
|
method_keyboard.button(
|
||||||
payment_link = freekassa_service.build_payment_link(
|
text=get_text("freekassa_method_qr"),
|
||||||
payment_db_id=payment_record.payment_id,
|
callback_data=f"pay_fk_method:{payment_record.payment_id}:44",
|
||||||
user_id=user_id,
|
|
||||||
months=months,
|
|
||||||
amount=price_rub,
|
|
||||||
)
|
)
|
||||||
except Exception as e_link:
|
method_keyboard.button(
|
||||||
logging.error(f"FreeKassa: failed to build payment link for payment {payment_record.payment_id}: {e_link}", exc_info=True)
|
text=get_text("freekassa_method_card"),
|
||||||
|
callback_data=f"pay_fk_method:{payment_record.payment_id}:36",
|
||||||
|
)
|
||||||
|
method_keyboard.button(
|
||||||
|
text=get_text("freekassa_method_sberpay"),
|
||||||
|
callback_data=f"pay_fk_method:{payment_record.payment_id}:43",
|
||||||
|
)
|
||||||
|
method_keyboard.button(
|
||||||
|
text=get_text("back_to_main_menu_button"),
|
||||||
|
callback_data="main_action:subscribe",
|
||||||
|
)
|
||||||
|
method_keyboard.adjust(1)
|
||||||
|
|
||||||
if payment_link:
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text("freekassa_choose_method"),
|
||||||
|
reply_markup=method_keyboard.as_markup(),
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"FreeKassa: failed to show method selector ({e_edit}), sending new message.")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text("freekassa_choose_method"),
|
||||||
|
reply_markup=method_keyboard.as_markup(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("pay_fk_method:"))
|
||||||
|
async def pay_fk_method_callback_handler(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
freekassa_service: FreeKassaService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not i18n or not callback.message:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not freekassa_service or not freekassa_service.configured:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, payload = callback.data.split(":", 1)
|
||||||
|
payment_id_str, method_code = payload.split(":")
|
||||||
|
payment_id = int(payment_id_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
logging.error(f"FreeKassa: invalid method payload {callback.data}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
|
||||||
|
except Exception as e_db:
|
||||||
|
logging.error(f"FreeKassa: failed to load payment {payment_id}: {e_db}")
|
||||||
|
payment_record = None
|
||||||
|
|
||||||
|
if not payment_record:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if payment_record.user_id != callback.from_user.id:
|
||||||
|
logging.warning(
|
||||||
|
f"FreeKassa: user {callback.from_user.id} attempted to access payment {payment_id} owned by {payment_record.user_id}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
months = payment_record.subscription_duration_months or 1
|
||||||
|
amount = float(payment_record.amount)
|
||||||
|
|
||||||
|
try:
|
||||||
|
method_code_int = int(method_code)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logging.error(f"FreeKassa: invalid method code {method_code} for payment {payment_record.payment_id}")
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
success, response_data = await freekassa_service.create_order(
|
||||||
|
payment_db_id=payment_record.payment_id,
|
||||||
|
user_id=payment_record.user_id,
|
||||||
|
months=months,
|
||||||
|
amount=amount,
|
||||||
|
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||||
|
method_code=method_code_int,
|
||||||
|
ip_address=freekassa_service.server_ip,
|
||||||
|
extra_params={
|
||||||
|
"us_method": method_code_int,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
location = response_data.get("location")
|
||||||
|
provider_identifier = response_data.get("orderHash") or response_data.get("orderId")
|
||||||
|
|
||||||
|
if provider_identifier:
|
||||||
|
try:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment_record.payment_id,
|
||||||
|
str(provider_identifier),
|
||||||
|
payment_record.status,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e_status:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if location:
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
get_text(key="payment_link_message", months=months),
|
get_text(key="payment_link_message", months=months),
|
||||||
reply_markup=get_payment_url_keyboard(payment_link, current_lang, i18n),
|
reply_markup=get_payment_url_keyboard(location, current_lang, i18n),
|
||||||
disable_web_page_preview=False,
|
disable_web_page_preview=False,
|
||||||
)
|
)
|
||||||
except Exception as e_edit:
|
except Exception as e_edit:
|
||||||
logging.warning(f"FreeKassa: edit message failed ({e_edit}), sending new message.")
|
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
|
||||||
try:
|
try:
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
get_text(key="payment_link_message", months=months),
|
get_text(key="payment_link_message", months=months),
|
||||||
reply_markup=get_payment_url_keyboard(payment_link, current_lang, i18n),
|
reply_markup=get_payment_url_keyboard(location, current_lang, i18n),
|
||||||
disable_web_page_preview=False,
|
disable_web_page_preview=False,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -373,6 +513,18 @@ async def pay_fk_callback_handler(
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s",
|
||||||
|
payment_record.payment_id,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa: create_order failed for payment %s with response %s",
|
||||||
|
payment_record.payment_id,
|
||||||
|
response_data,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await payment_dal.update_payment_status_by_db_id(
|
await payment_dal.update_payment_status_by_db_id(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Tuple
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
@@ -36,68 +39,162 @@ class FreeKassaService:
|
|||||||
self.subscription_service = subscription_service
|
self.subscription_service = subscription_service
|
||||||
self.referral_service = referral_service
|
self.referral_service = referral_service
|
||||||
|
|
||||||
self.merchant_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||||
self.first_secret: Optional[str] = settings.FREEKASSA_FIRST_SECRET
|
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||||
self.payment_url: str = settings.FREEKASSA_PAYMENT_URL.rstrip("/")
|
self.default_currency: str = (
|
||||||
self.currency: str = settings.FREEKASSA_CURRENCY.upper() if settings.FREEKASSA_CURRENCY else "RUB"
|
settings.FREEKASSA_CURRENCY or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
|
).upper()
|
||||||
|
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||||
|
|
||||||
self.configured: bool = bool(
|
self.api_base_url: str = "https://api.fk.life/v1"
|
||||||
settings.FREEKASSA_ENABLED
|
self._timeout = ClientTimeout(total=15)
|
||||||
and self.merchant_id
|
self._session: Optional[ClientSession] = None
|
||||||
and self.first_secret
|
self._nonce_lock = asyncio.Lock()
|
||||||
and self.second_secret
|
self._last_nonce = int(time.time() * 1000)
|
||||||
)
|
|
||||||
|
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
|
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
|
||||||
|
if settings.FREEKASSA_ENABLED and not self.server_ip:
|
||||||
|
logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_amount(amount: float) -> str:
|
def _format_amount(amount: float) -> str:
|
||||||
"""Format amount for signatures with two decimal places."""
|
"""Format amount for payloads and signature with two decimal places."""
|
||||||
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
return f"{quantized:.2f}"
|
return f"{quantized:.2f}"
|
||||||
|
|
||||||
def build_payment_link(
|
async def create_order(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
payment_db_id: int,
|
payment_db_id: int,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
months: int,
|
months: int,
|
||||||
amount: float,
|
amount: float,
|
||||||
|
currency: Optional[str],
|
||||||
|
method_code: int,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
extra_params: Optional[Dict[str, Any]] = None,
|
extra_params: Optional[Dict[str, Any]] = None,
|
||||||
) -> Optional[str]:
|
) -> Tuple[bool, Dict[str, Any]]:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
logging.error("FreeKassaService is not configured. Cannot build payment link.")
|
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||||
return None
|
return False, {"message": "service_not_configured"}
|
||||||
|
|
||||||
|
ip_address = ip_address or self.server_ip
|
||||||
|
if not ip_address:
|
||||||
|
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||||
|
return False, {"message": "missing_ip"}
|
||||||
|
|
||||||
|
email = email or f"{user_id}@telegram.org"
|
||||||
amount_str = self._format_amount(amount)
|
amount_str = self._format_amount(amount)
|
||||||
signature_source = f"{self.merchant_id}:{amount_str}:{self.first_secret}:{payment_db_id}"
|
currency_code = (currency or self.default_currency or "RUB").upper()
|
||||||
signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
params: Dict[str, Any] = {
|
payload: Dict[str, Any] = {
|
||||||
"m": self.merchant_id,
|
"shopId": int(self.shop_id),
|
||||||
"oa": amount_str,
|
"nonce": await self._generate_nonce(),
|
||||||
"o": str(payment_db_id),
|
"paymentId": str(payment_db_id),
|
||||||
"currency": self.currency,
|
"i": int(method_code),
|
||||||
"s": signature,
|
"amount": amount_str,
|
||||||
|
"currency": currency_code,
|
||||||
|
"email": email,
|
||||||
|
"ip": ip_address,
|
||||||
"us_user_id": str(user_id),
|
"us_user_id": str(user_id),
|
||||||
"us_months": str(months),
|
"us_months": str(months),
|
||||||
|
"us_payment_db_id": str(payment_db_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
if extra_params:
|
if extra_params:
|
||||||
for key, value in extra_params.items():
|
for key, value in extra_params.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
params[f"us_{key}"] = value
|
payload[key] = value
|
||||||
|
|
||||||
query_string = urlencode(params, doseq=False, safe=":")
|
payload["signature"] = self._sign_payload(payload)
|
||||||
return f"{self.payment_url}?{query_string}"
|
|
||||||
|
|
||||||
def _validate_signature(self, merchant_order_id: str, amount: str, provided_signature: str) -> bool:
|
session = await self._get_session()
|
||||||
if not self.configured:
|
url = f"{self.api_base_url}/orders/create"
|
||||||
|
try:
|
||||||
|
async with session.post(url, json=payload) as response:
|
||||||
|
response_text = await response.text()
|
||||||
|
try:
|
||||||
|
response_data = json.loads(response_text) if response_text else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text)
|
||||||
|
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||||
|
|
||||||
|
if response.status != 200 or response_data.get("type") != "success":
|
||||||
|
logging.error(
|
||||||
|
"FreeKassa create_order: API returned 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.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
|
||||||
|
return False, {"message": str(exc)}
|
||||||
|
|
||||||
|
async def _get_session(self) -> ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = ClientSession(timeout=self._timeout)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def _generate_nonce(self) -> int:
|
||||||
|
async with self._nonce_lock:
|
||||||
|
candidate = int(time.time() * 1000)
|
||||||
|
if candidate <= self._last_nonce:
|
||||||
|
candidate = self._last_nonce + 1
|
||||||
|
self._last_nonce = candidate
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||||
|
if not self.api_key:
|
||||||
|
raise RuntimeError("FreeKassa API key is not configured.")
|
||||||
|
items = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key != "signature" and value is not None
|
||||||
|
]
|
||||||
|
items.sort(key=lambda pair: pair[0])
|
||||||
|
message = "|".join(str(value) for _, value in items)
|
||||||
|
return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
def _validate_signature(
|
||||||
|
self,
|
||||||
|
merchant_order_id: str,
|
||||||
|
amount: str,
|
||||||
|
provided_signature: str,
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
if not provided_signature:
|
||||||
return False
|
return False
|
||||||
signature_source = f"{self.merchant_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
|
||||||
|
if self.shop_id and self.second_secret:
|
||||||
|
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||||
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||||
return expected_signature.lower() == provided_signature.lower()
|
if expected_signature.lower() == provided_signature.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.api_key and payload:
|
||||||
|
items = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"signature", "SIGN"} and value is not None
|
||||||
|
]
|
||||||
|
items.sort(key=lambda pair: pair[0])
|
||||||
|
message = "|".join(str(value) for _, value in items)
|
||||||
|
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
if alt_signature.lower() == provided_signature.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
@@ -109,21 +206,29 @@ class FreeKassaService:
|
|||||||
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||||
return web.Response(status=400, text="bad_request")
|
return web.Response(status=400, text="bad_request")
|
||||||
|
|
||||||
if not data:
|
payload_dict: Dict[str, Any]
|
||||||
|
if data:
|
||||||
|
payload_dict = {str(k): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
json_payload = await request.json()
|
||||||
|
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
||||||
|
data = json_payload
|
||||||
except Exception:
|
except Exception:
|
||||||
|
payload_dict = {}
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
if isinstance(data, dict):
|
||||||
return data.get(key) or data.get(key.lower()) or default
|
return data.get(key) or data.get(key.lower()) or default
|
||||||
|
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||||
|
|
||||||
merchant_id = _get("MERCHANT_ID")
|
merchant_id = _get("MERCHANT_ID")
|
||||||
if merchant_id != self.merchant_id:
|
if merchant_id != self.shop_id:
|
||||||
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||||
return web.Response(status=403, text="merchant_mismatch")
|
return web.Response(status=403, text="merchant_mismatch")
|
||||||
|
|
||||||
signature = _get("SIGN")
|
signature = _get("SIGN") or _get("signature")
|
||||||
if not signature:
|
if not signature:
|
||||||
logging.error("FreeKassa webhook: missing signature")
|
logging.error("FreeKassa webhook: missing signature")
|
||||||
return web.Response(status=400, text="missing_signature")
|
return web.Response(status=400, text="missing_signature")
|
||||||
@@ -136,7 +241,7 @@ class FreeKassaService:
|
|||||||
logging.error("FreeKassa webhook: missing order_id or amount")
|
logging.error("FreeKassa webhook: missing order_id or amount")
|
||||||
return web.Response(status=400, text="missing_data")
|
return web.Response(status=400, text="missing_data")
|
||||||
|
|
||||||
if not self._validate_signature(order_id_str, amount_str, signature):
|
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||||
logging.error("FreeKassa webhook: invalid signature")
|
logging.error("FreeKassa webhook: invalid signature")
|
||||||
return web.Response(status=403, text="invalid_signature")
|
return web.Response(status=403, text="invalid_signature")
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -50,6 +50,8 @@ class Settings(BaseSettings):
|
|||||||
FREEKASSA_SECOND_SECRET: Optional[str] = None
|
FREEKASSA_SECOND_SECRET: Optional[str] = None
|
||||||
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||||
FREEKASSA_CURRENCY: str = Field(default="RUB")
|
FREEKASSA_CURRENCY: str = Field(default="RUB")
|
||||||
|
FREEKASSA_API_KEY: Optional[str] = None
|
||||||
|
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||||
|
|
||||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||||
STARS_ENABLED: bool = Field(default=True)
|
STARS_ENABLED: bool = Field(default=True)
|
||||||
@@ -386,13 +388,17 @@ def get_settings() -> Settings:
|
|||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||||
)
|
)
|
||||||
if _settings_instance.FREEKASSA_ENABLED and (
|
if _settings_instance.FREEKASSA_ENABLED:
|
||||||
|
if (
|
||||||
not _settings_instance.FREEKASSA_MERCHANT_ID
|
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||||
or not _settings_instance.FREEKASSA_FIRST_SECRET
|
or not _settings_instance.FREEKASSA_API_KEY
|
||||||
or not _settings_instance.FREEKASSA_SECOND_SECRET
|
|
||||||
):
|
):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"CRITICAL: FreeKassa is enabled but credentials are incomplete (merchant ID or secret words). FreeKassa payments will not work."
|
"CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work."
|
||||||
|
)
|
||||||
|
if not _settings_instance.FREEKASSA_SECOND_SECRET:
|
||||||
|
logging.warning(
|
||||||
|
"WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified."
|
||||||
)
|
)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||||
|
"freekassa_choose_method": "Choose how to pay with FreeKassa:",
|
||||||
|
"freekassa_method_qr": "📱 SBP QR (i=44)",
|
||||||
|
"freekassa_method_card": "💳 Bank Card (i=36)",
|
||||||
|
"freekassa_method_sberpay": "🏦 SberPay (i=43)",
|
||||||
"connect_button": "🔗 Connect",
|
"connect_button": "🔗 Connect",
|
||||||
"cancel_button": "❌ Cancel",
|
"cancel_button": "❌ Cancel",
|
||||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||||
"pay_with_tribute_button": "❤️ Tribute",
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||||
|
"freekassa_choose_method": "Выберите способ оплаты FreeKassa:",
|
||||||
|
"freekassa_method_qr": "📱 QR по СБП (i=44)",
|
||||||
|
"freekassa_method_card": "💳 Банковская карта РФ (i=36)",
|
||||||
|
"freekassa_method_sberpay": "🏦 SberPay (i=43)",
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
"cancel_button": "❌ Отмена",
|
"cancel_button": "❌ Отмена",
|
||||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user