v_0.0
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||||
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
|
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa, 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,12 @@
|
|||||||
| `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_FIRST_SECRET` | Секретное слово №1 для формирования ссылок оплаты. |
|
||||||
|
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 для проверки уведомлений. |
|
||||||
|
| `FREEKASSA_PAYMENT_URL` | (Опционально) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||||
|
| `FREEKASSA_CURRENCY` | Код валюты платежа (например, `RUB`). |
|
||||||
| `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 +131,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`
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
|||||||
@router.callback_query(F.data == "noop")
|
@router.callback_query(F.data == "noop")
|
||||||
async def noop_handler(callback: types.CallbackQuery):
|
async def noop_handler(callback: types.CallbackQuery):
|
||||||
"""Handle no-op callback (for pagination display)."""
|
"""Handle no-op callback (for pagination display)."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 +262,138 @@ 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 = freekassa_service.currency 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
|
||||||
|
|
||||||
|
payment_link = None
|
||||||
|
try:
|
||||||
|
payment_link = freekassa_service.build_payment_link(
|
||||||
|
payment_db_id=payment_record.payment_id,
|
||||||
|
user_id=user_id,
|
||||||
|
months=months,
|
||||||
|
amount=price_rub,
|
||||||
|
)
|
||||||
|
except Exception as e_link:
|
||||||
|
logging.error(f"FreeKassa: failed to build payment link for payment {payment_record.payment_id}: {e_link}", exc_info=True)
|
||||||
|
|
||||||
|
if payment_link:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
get_text(key="payment_link_message", months=months),
|
||||||
|
reply_markup=get_payment_url_keyboard(payment_link, current_lang, i18n),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception as e_edit:
|
||||||
|
logging.warning(f"FreeKassa: edit message failed ({e_edit}), sending new message.")
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
get_text(key="payment_link_message", months=months),
|
||||||
|
reply_markup=get_payment_url_keyboard(payment_link, current_lang, i18n),
|
||||||
|
disable_web_page_preview=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
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 +568,3 @@ async def handle_successful_stars_payment(
|
|||||||
stars_amount=stars_amount,
|
stars_amount=stars_amount,
|
||||||
i18n_data=i18n_data,
|
i18n_data=i18n_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from aiohttp import 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.merchant_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||||
|
self.first_secret: Optional[str] = settings.FREEKASSA_FIRST_SECRET
|
||||||
|
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||||
|
self.payment_url: str = settings.FREEKASSA_PAYMENT_URL.rstrip("/")
|
||||||
|
self.currency: str = settings.FREEKASSA_CURRENCY.upper() if settings.FREEKASSA_CURRENCY else "RUB"
|
||||||
|
|
||||||
|
self.configured: bool = bool(
|
||||||
|
settings.FREEKASSA_ENABLED
|
||||||
|
and self.merchant_id
|
||||||
|
and self.first_secret
|
||||||
|
and self.second_secret
|
||||||
|
)
|
||||||
|
if not self.configured:
|
||||||
|
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_amount(amount: float) -> str:
|
||||||
|
"""Format amount for signatures with two decimal places."""
|
||||||
|
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
return f"{quantized:.2f}"
|
||||||
|
|
||||||
|
def build_payment_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
payment_db_id: int,
|
||||||
|
user_id: int,
|
||||||
|
months: int,
|
||||||
|
amount: float,
|
||||||
|
extra_params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if not self.configured:
|
||||||
|
logging.error("FreeKassaService is not configured. Cannot build payment link.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
amount_str = self._format_amount(amount)
|
||||||
|
signature_source = f"{self.merchant_id}:{amount_str}:{self.first_secret}:{payment_db_id}"
|
||||||
|
signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"m": self.merchant_id,
|
||||||
|
"oa": amount_str,
|
||||||
|
"o": str(payment_db_id),
|
||||||
|
"currency": self.currency,
|
||||||
|
"s": signature,
|
||||||
|
"us_user_id": str(user_id),
|
||||||
|
"us_months": str(months),
|
||||||
|
}
|
||||||
|
if extra_params:
|
||||||
|
for key, value in extra_params.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
params[f"us_{key}"] = value
|
||||||
|
|
||||||
|
query_string = urlencode(params, doseq=False, safe=":")
|
||||||
|
return f"{self.payment_url}?{query_string}"
|
||||||
|
|
||||||
|
def _validate_signature(self, merchant_order_id: str, amount: str, provided_signature: str) -> bool:
|
||||||
|
if not self.configured:
|
||||||
|
return False
|
||||||
|
signature_source = f"{self.merchant_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||||
|
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||||
|
return expected_signature.lower() == provided_signature.lower()
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||||
|
return data.get(key) or data.get(key.lower()) or default
|
||||||
|
|
||||||
|
merchant_id = _get("MERCHANT_ID")
|
||||||
|
if merchant_id != self.merchant_id:
|
||||||
|
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||||
|
return web.Response(status=403, text="merchant_mismatch")
|
||||||
|
|
||||||
|
signature = _get("SIGN")
|
||||||
|
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):
|
||||||
|
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)
|
||||||
@@ -158,6 +158,7 @@ class NotificationService:
|
|||||||
|
|
||||||
provider_emoji = {
|
provider_emoji = {
|
||||||
"yookassa": "💳",
|
"yookassa": "💳",
|
||||||
|
"freekassa": "💳",
|
||||||
"cryptopay": "₿",
|
"cryptopay": "₿",
|
||||||
"stars": "⭐",
|
"stars": "⭐",
|
||||||
"tribute": "💎"
|
"tribute": "💎"
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ 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")
|
||||||
|
|
||||||
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 +243,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 +386,14 @@ 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 (
|
||||||
|
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||||
|
or not _settings_instance.FREEKASSA_FIRST_SECRET
|
||||||
|
or not _settings_instance.FREEKASSA_SECOND_SECRET
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
"CRITICAL: FreeKassa is enabled but credentials are incomplete (merchant ID or secret words). FreeKassa payments will not work."
|
||||||
|
)
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
logging.critical(
|
logging.critical(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user