Merge pull request #99 from zerodata731/freekassa-codex

v_0.0
This commit is contained in:
machka pasla
2025-10-13 10:05:45 +03:00
committed by GitHub
15 changed files with 769 additions and 11 deletions
+8
View File
@@ -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)
+11 -3
View File
@@ -10,7 +10,7 @@
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute. - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
### Для администраторов: ### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
@@ -27,7 +27,7 @@
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки). - **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами. - **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
- **Pydantic:** Для управления настройками из `.env` файла. - **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания. - **Docker & Docker Compose:** Для контейнеризации и развертывания.
@@ -81,6 +81,13 @@
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
| `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>
@@ -125,12 +132,13 @@
Эта команда скачает образ и запустит сервис в фоновом режиме. Эта команда скачает образ и запустит сервис в фоновом режиме.
4. **Настройка вебхуков (Обязательно):** 4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave. Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
**Пути для перенаправления:** **Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa` - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay` - `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute` - `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel` - `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
+10 -1
View File
@@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService from bot.services.tribute_service import TributeService
from bot.services.crypto_pay_service import CryptoPayService from bot.services.crypto_pay_service import CryptoPayService
from bot.services.panel_webhook_service import PanelWebhookService from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService
def build_core_services( def build_core_services(
@@ -36,6 +37,14 @@ def build_core_services(
subscription_service, subscription_service,
referral_service, referral_service,
) )
freekassa_service = FreeKassaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
)
tribute_service = TributeService( tribute_service = TributeService(
bot, bot,
settings, settings,
@@ -70,9 +79,9 @@ def build_core_services(
"promo_code_service": promo_code_service, "promo_code_service": promo_code_service,
"stars_service": stars_service, "stars_service": stars_service,
"cryptopay_service": cryptopay_service, "cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"tribute_service": tribute_service, "tribute_service": tribute_service,
"panel_webhook_service": panel_webhook_service, "panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service, "yookassa_service": yookassa_service,
} }
+7 -1
View File
@@ -27,6 +27,7 @@ async def build_and_start_web_app(
"referral_service", "referral_service",
"panel_service", "panel_service",
"stars_service", "stars_service",
"freekassa_service",
"cryptopay_service", "cryptopay_service",
"tribute_service", "tribute_service",
"panel_webhook_service", "panel_webhook_service",
@@ -50,6 +51,7 @@ async def build_and_start_web_app(
from bot.services.tribute_service import tribute_webhook_route from bot.services.tribute_service import tribute_webhook_route
from bot.services.crypto_pay_service import cryptopay_webhook_route from bot.services.crypto_pay_service import cryptopay_webhook_route
from bot.services.panel_webhook_service import panel_webhook_route from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.freekassa_service import freekassa_webhook_route
tribute_path = settings.tribute_webhook_path tribute_path = settings.tribute_webhook_path
if tribute_path.startswith("/"): if tribute_path.startswith("/"):
@@ -61,6 +63,11 @@ async def build_and_start_web_app(
app.router.add_post(cp_path, cryptopay_webhook_route) app.router.add_post(cp_path, cryptopay_webhook_route)
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}") logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
fk_path = settings.freekassa_webhook_path
if fk_path.startswith("/"):
app.router.add_post(fk_path, freekassa_webhook_route)
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
# YooKassa webhook (register only when base URL present and path configured) # YooKassa webhook (register only when base URL present and path configured)
yk_path = settings.yookassa_webhook_path yk_path = settings.yookassa_webhook_path
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"): if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
@@ -88,4 +95,3 @@ async def build_and_start_web_app(
# Run until cancelled # Run until cancelled
await asyncio.Event().wait() await asyncio.Event().wait()
+3 -2
View File
@@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
status_emoji = "" if payment.status == 'succeeded' else ( status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status in ['pending', 'pending_yookassa'] else "" "" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else ""
) )
user_info = f"User {payment.user_id}" user_info = f"User {payment.user_id}"
@@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
'yookassa': 'YooKassa', 'yookassa': 'YooKassa',
'tribute': 'Tribute', 'tribute': 'Tribute',
'telegram_stars': 'Telegram Stars', 'telegram_stars': 'Telegram Stars',
'cryptopay': 'CryptoPay' 'cryptopay': 'CryptoPay',
'freekassa': 'FreeKassa',
}.get(payment.provider, payment.provider or 'Unknown') }.get(payment.provider, payment.provider or 'Unknown')
return ( return (
+1 -2
View File
@@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
f"\n<b>{_('admin_stats_recent_payments_header')}</b>") f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models: for payment in last_payments_models:
status_emoji = "" if payment.status == 'succeeded' else ( status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status == 'pending' "" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "")
or payment.status == 'pending_yookassa' else "")
user_info = f"User {payment.user_id}" user_info = f"User {payment.user_id}"
if payment.user and payment.user.username: if payment.user and payment.user.username:
+285 -1
View File
@@ -1,11 +1,13 @@
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
from config.settings import Settings from config.settings import Settings
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.services.freekassa_service import FreeKassaService
from bot.services.crypto_pay_service import CryptoPayService from bot.services.crypto_pay_service import CryptoPayService
from bot.services.stars_service import StarsService from bot.services.stars_service import StarsService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
@@ -261,6 +263,289 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
pass pass
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_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:
logging.error("FreeKassa service is not configured or unavailable.")
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:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
price_rub = float(price_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": months,
"provider": "freekassa",
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
method_keyboard = InlineKeyboardBuilder()
method_keyboard.button(
text=get_text("freekassa_method_qr"),
callback_data=f"pay_fk_method:{payment_record.payment_id}:44",
)
method_keyboard.button(
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)
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:
await callback.message.edit_text(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(location, current_lang, i18n),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.")
try:
await callback.message.answer(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(location, current_lang, i18n),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
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:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pay_crypto:")) @router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler( async def pay_crypto_callback_handler(
callback: types.CallbackQuery, callback: types.CallbackQuery,
@@ -435,4 +720,3 @@ async def handle_successful_stars_payment(
stars_amount=stars_amount, stars_amount=stars_amount,
i18n_data=i18n_data, i18n_data=i18n_data,
) )
+3
View File
@@ -126,6 +126,9 @@ def get_payment_method_keyboard(months: int, price: float,
if settings.YOOKASSA_ENABLED: if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"), builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}") callback_data=f"pay_yk:{months}:{price}")
if settings.FREEKASSA_ENABLED:
builder.button(text=_("pay_with_freekassa_button"),
callback_data=f"pay_fk:{months}:{price}")
if settings.CRYPTOPAY_ENABLED: if settings.CRYPTOPAY_ENABLED:
builder.button(text=_("pay_with_cryptopay_button"), builder.button(text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{months}:{price}") callback_data=f"pay_crypto:{months}:{price}")
+1
View File
@@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
for service_key in ( for service_key in (
"panel_service", "panel_service",
"cryptopay_service", "cryptopay_service",
"freekassa_service",
"tribute_service", "tribute_service",
"panel_webhook_service", "panel_webhook_service",
"yookassa_service", "yookassa_service",
+394
View File
@@ -0,0 +1,394 @@
import asyncio
import hashlib
import hmac
import json
import logging
import time
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
class FreeKassaService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (
settings.FREEKASSA_CURRENCY or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
).upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15)
self._session: Optional[ClientSession] = None
self._nonce_lock = asyncio.Lock()
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:
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
def _format_amount(amount: float) -> str:
"""Format amount for payloads and signature with two decimal places."""
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{quantized:.2f}"
async def create_order(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
method_code: int,
email: Optional[str] = None,
ip_address: Optional[str] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
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)
currency_code = (currency or self.default_currency or "RUB").upper()
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(method_code),
"amount": amount_str,
"currency": currency_code,
"email": email,
"ip": ip_address,
"us_user_id": str(user_id),
"us_months": str(months),
"us_payment_db_id": str(payment_db_id),
}
if extra_params:
for key, value in extra_params.items():
if value is None:
continue
payload[key] = value
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
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
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()
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:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
data = await request.post()
except Exception as e:
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any]
if data:
payload_dict = {str(k): v for k, v in data.items()}
else:
try:
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:
payload_dict = {}
data = {}
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 payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
return web.Response(status=403, text="merchant_mismatch")
signature = _get("SIGN") or _get("signature")
if not signature:
logging.error("FreeKassa webhook: missing signature")
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
logging.error("FreeKassa webhook: missing order_id or amount")
return web.Response(status=400, text="missing_data")
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
logging.error("FreeKassa webhook: invalid signature")
return web.Response(status=403, text="invalid_signature")
try:
payment_db_id = int(order_id_str)
except (TypeError, ValueError):
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
return web.Response(status=400, text="invalid_order_id")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded":
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
return web.Response(text="YES")
# Optional amount verification
try:
amount_decimal = Decimal(amount_str)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
logging.warning(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})"
)
except Exception as e:
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
activation = None
referral_bonus = None
try:
await payment_dal.update_provider_payment_and_status(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
new_status="succeeded",
)
months = payment.subscription_duration_months or 1
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
months,
float(payment.amount),
payment.payment_id,
provider="freekassa",
)
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
months,
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as e:
await session.rollback()
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
config_link = None
final_end = None
months = payment.subscription_duration_months or 1
if activation:
config_link = activation.get("subscription_url")
final_end = activation.get("end_date")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if not final_end and activation and activation.get("end_date"):
final_end = activation["end_date"]
if not config_link:
config_link = _("config_link_not_available")
if final_end:
end_date_str = final_end.strftime("%Y-%m-%d")
else:
end_date_str = _("config_link_not_available")
if applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
text = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str,
bonus_days=applied_days,
final_end_date=end_date_str,
inviter_name=inviter_name_display,
config_link=config_link,
)
else:
text = _(
"payment_successful_full",
months=months,
end_date=end_date_str,
config_link=config_link,
)
markup = get_connect_and_main_keyboard(lang, self.i18n, self.settings, config_link)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.currency or self.settings.DEFAULT_CURRENCY_SYMBOL,
months=months,
payment_provider="freekassa",
username=db_user.username if db_user else None,
)
except Exception as e:
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
return web.Response(text="YES")
async def freekassa_webhook_route(request: web.Request) -> web.Response:
service: FreeKassaService = request.app["freekassa_service"]
return await service.webhook_route(request)
+1
View File
@@ -158,6 +158,7 @@ class NotificationService:
provider_emoji = { provider_emoji = {
"yookassa": "💳", "yookassa": "💳",
"freekassa": "💳",
"cryptopay": "", "cryptopay": "",
"stars": "", "stars": "",
"tribute": "💎" "tribute": "💎"
+34
View File
@@ -44,6 +44,15 @@ class Settings(BaseSettings):
CRYPTOPAY_ASSET: str = Field(default="RUB") CRYPTOPAY_ASSET: str = Field(default="RUB")
CRYPTOPAY_ENABLED: bool = Field(default=True) CRYPTOPAY_ENABLED: bool = Field(default=True)
FREEKASSA_ENABLED: bool = Field(default=False)
FREEKASSA_MERCHANT_ID: Optional[str] = None
FREEKASSA_FIRST_SECRET: Optional[str] = None
FREEKASSA_SECOND_SECRET: Optional[str] = None
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
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)
TRIBUTE_ENABLED: bool = Field(default=True) TRIBUTE_ENABLED: bool = Field(default=True)
@@ -236,6 +245,19 @@ class Settings(BaseSettings):
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}" return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
return None return None
@computed_field
@property
def freekassa_webhook_path(self) -> str:
return "/webhook/freekassa"
@computed_field
@property
def freekassa_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
return None
# Computed YooKassa receipt fields based on recurring toggle # Computed YooKassa receipt fields based on recurring toggle
@computed_field @computed_field
@property @property
@@ -366,6 +388,18 @@ 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:
if (
not _settings_instance.FREEKASSA_MERCHANT_ID
or not _settings_instance.FREEKASSA_API_KEY
):
logging.warning(
"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:
logging.critical( logging.critical(
+5
View File
@@ -30,9 +30,14 @@
"choose_payment_method": "Choose payment method:", "choose_payment_method": "Choose payment method:",
"pay_button": "💳 Pay", "pay_button": "💳 Pay",
"pay_with_yookassa_button": "💳 YooKassa", "pay_with_yookassa_button": "💳 YooKassa",
"pay_with_freekassa_button": "💳 FreeKassa",
"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.",
+5
View File
@@ -30,9 +30,14 @@
"choose_payment_method": "Выберите способ оплаты:", "choose_payment_method": "Выберите способ оплаты:",
"pay_button": "💳 Оплатить", "pay_button": "💳 Оплатить",
"pay_with_yookassa_button": "💳 ЮKassa", "pay_with_yookassa_button": "💳 ЮKassa",
"pay_with_freekassa_button": "💳 FreeKassa",
"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.