From 3a7bf7430db6bc2200e5c5f03e86af49161c1220 Mon Sep 17 00:00:00 2001 From: Vladless Date: Thu, 30 Apr 2026 17:21:53 +0000 Subject: [PATCH] ParityPay SBP provider, unified ChromeEditModal, responsive container queries, perf audit --- SoloNet Cabinet.html | 805 ++++++++++++++ SoloNet.html | 1154 ++++++++++++++++++++ api/v2/routes/auth/_common.py | 24 + api/v2/routes/auth/session.py | 1 + api/v2/schemas/web_public.py | 1 + handlers/admin/settings/settings_config.py | 1 + handlers/admin/tariffs/keyboard.py | 4 +- handlers/admin/tariffs/tariff_utils.py | 3 +- handlers/buttons.py | 1 + handlers/payments/__init__.py | 3 + handlers/payments/paritypay/__init__.py | 3 + handlers/payments/paritypay/handlers.py | 107 ++ handlers/payments/paritypay/service.py | 417 +++++++ services/payments/paritypay/__init__.py | 0 services/payments/paritypay/webhook.py | 149 +++ services/payments/providers.py | 8 + web/__init__.py | 3 + 17 files changed, 2681 insertions(+), 3 deletions(-) create mode 100644 SoloNet Cabinet.html create mode 100644 SoloNet.html create mode 100644 handlers/payments/paritypay/__init__.py create mode 100644 handlers/payments/paritypay/handlers.py create mode 100644 handlers/payments/paritypay/service.py create mode 100644 services/payments/paritypay/__init__.py create mode 100644 services/payments/paritypay/webhook.py diff --git a/SoloNet Cabinet.html b/SoloNet Cabinet.html new file mode 100644 index 00000000..6b1126ef --- /dev/null +++ b/SoloNet Cabinet.html @@ -0,0 +1,805 @@ + + + + +SoloNet — Кабинет + + + + + + + +
+ + + + + + + + diff --git a/SoloNet.html b/SoloNet.html new file mode 100644 index 00000000..f5974240 --- /dev/null +++ b/SoloNet.html @@ -0,0 +1,1154 @@ + + + + +SoloNet — VPN + + + + + + + +
+ + + + + + + + + diff --git a/api/v2/routes/auth/_common.py b/api/v2/routes/auth/_common.py index 30925f89..89b4818d 100644 --- a/api/v2/routes/auth/_common.py +++ b/api/v2/routes/auth/_common.py @@ -50,6 +50,7 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) "partner_percent": default_percent, "partner_percent_custom": False, "partner_referred_total": 0, + "partner_referred_paid": 0, "partner_payout_method": None, } try: @@ -95,6 +96,7 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e) payout_method = str(partner_row[5] or "").strip() or None referred_total = 0 + referred_paid = 0 if tg_id is not None: try: referred_total = int( @@ -108,6 +110,27 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) ) except Exception: referred_total = 0 + try: + referred_paid = int( + ( + await session.execute( + text( + "SELECT COUNT(DISTINCT pr.joined_tg_id) " + "FROM partners pr " + "WHERE pr.partner_tg_id = :tg_id " + "AND EXISTS (" + " SELECT 1 FROM payments pay " + " WHERE pay.tg_id = pr.joined_tg_id " + " AND lower(pay.status) = 'success'" + ")" + ), + {"tg_id": int(tg_id)}, + ) + ).scalar() + or 0 + ) + except Exception: + referred_paid = 0 payload.update({ "partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0), "partner_code": code, @@ -115,6 +138,7 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) "partner_percent": percent_value, "partner_percent_custom": percent_custom, "partner_referred_total": referred_total, + "partner_referred_paid": referred_paid, "partner_payout_method": payout_method, }) return payload diff --git a/api/v2/routes/auth/session.py b/api/v2/routes/auth/session.py index 1104bd6b..32db8265 100644 --- a/api/v2/routes/auth/session.py +++ b/api/v2/routes/auth/session.py @@ -224,6 +224,7 @@ async def auth_summary( partner_percent=float(partner.get("partner_percent") or 0.0), partner_percent_custom=bool(partner.get("partner_percent_custom", False)), partner_referred_total=int(partner.get("partner_referred_total") or 0), + partner_referred_paid=int(partner.get("partner_referred_paid") or 0), partner_payout_method=partner.get("partner_payout_method"), unread_notifications=int(unread_notifications), ) diff --git a/api/v2/schemas/web_public.py b/api/v2/schemas/web_public.py index 1f2ef4a1..a5803867 100644 --- a/api/v2/schemas/web_public.py +++ b/api/v2/schemas/web_public.py @@ -22,6 +22,7 @@ class AccountSummaryResponse(BaseModel): partner_percent: float = 0.0 partner_percent_custom: bool = False partner_referred_total: int = 0 + partner_referred_paid: int = 0 partner_payout_method: str | None = None unread_notifications: int = 0 diff --git a/handlers/admin/settings/settings_config.py b/handlers/admin/settings/settings_config.py index a0f7089b..3db8c56c 100644 --- a/handlers/admin/settings/settings_config.py +++ b/handlers/admin/settings/settings_config.py @@ -46,6 +46,7 @@ PAYMENT_PROVIDER_TITLES: Final[dict[str, str]] = { "KASSAI_SBP": "KassaAI СБП", "WATA_RU": "WATA карты РФ / СБП", "WATA_INT": "WATA международные", + "PARITYPAY_SBP": "ParityPay СБП", "TRIBUTE": "Tribute", "HELEKET": "Heleket", "CRYPTOBOT": "CryptoBot", diff --git a/handlers/admin/tariffs/keyboard.py b/handlers/admin/tariffs/keyboard.py index 1805746f..b663ccb1 100644 --- a/handlers/admin/tariffs/keyboard.py +++ b/handlers/admin/tariffs/keyboard.py @@ -86,7 +86,7 @@ def build_tariffs_arrangement_kb(group_code: str, tariffs: list) -> InlineKeyboa callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack(), ), InlineKeyboardButton( - text=f" {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack() + text=f" #{t.get('id')} · {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack() ), InlineKeyboardButton( text="⬇️", @@ -178,7 +178,7 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup: ) for t in grouped.get(None, []): - title = f"{t['name']} — {t['price_rub']}₽" + title = f"#{t['id']} · {t['name']} — {t['price_rub']}₽" builder.row( InlineKeyboardButton( text=title, diff --git a/handlers/admin/tariffs/tariff_utils.py b/handlers/admin/tariffs/tariff_utils.py index 7190242f..425a02cc 100644 --- a/handlers/admin/tariffs/tariff_utils.py +++ b/handlers/admin/tariffs/tariff_utils.py @@ -44,7 +44,8 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]: external_squad_text = getattr(tariff, "external_squad", None) or "Не задан" text = ( - f"📄 Тариф: {tariff.name}\n\n" + f"📄 Тариф: {tariff.name}\n" + f"🆔 ID: {tariff.id}\n\n" f"📁 Группа: {tariff.group_code}\n" f"📅 Длительность: {tariff.duration_days} дней\n" f"💰 Стоимость: {tariff.price_rub}₽\n" diff --git a/handlers/buttons.py b/handlers/buttons.py index 42ebd669..fd0756e2 100644 --- a/handlers/buttons.py +++ b/handlers/buttons.py @@ -85,6 +85,7 @@ WATA_INT = "🌍 WATA: Международные карты" KASSAI = "💳 KassaAI" KASSAI_CARDS = "💳 KassaAI: Карты РФ" KASSAI_SBP = "🏦 KassaAI: СБП" +PARITYPAY_SBP = "🏦 ParityPay: СБП" TRIBUTE = "💳 Tribute" HELEKET = "Heleket Crypto" diff --git a/handlers/payments/__init__.py b/handlers/payments/__init__.py index 4cff824a..8dcd5a99 100644 --- a/handlers/payments/__init__.py +++ b/handlers/payments/__init__.py @@ -23,6 +23,7 @@ from .freekassa.freekassa_pay import router as freekassa_router from .gift import router as gift_router from .heleket import router as heleket_router from .kassai import router as kassai_router +from .paritypay import router as paritypay_router from .pay import router as pay_router from .robokassa import router as robokassa_router from .stars import router as stars_router @@ -54,6 +55,8 @@ if PROVIDERS.get("HELEKET", {}).get("enabled"): router.include_router(heleket_router) if PROVIDERS.get("WATA_RU", {}).get("enabled") or PROVIDERS.get("WATA_INT", {}).get("enabled"): router.include_router(wata_router) +if PROVIDERS.get("PARITYPAY_SBP", {}).get("enabled"): + router.include_router(paritypay_router) router.include_router(tribute_router) router.include_router(gift_router) diff --git a/handlers/payments/paritypay/__init__.py b/handlers/payments/paritypay/__init__.py new file mode 100644 index 00000000..8a870356 --- /dev/null +++ b/handlers/payments/paritypay/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("router",) + +from .handlers import router diff --git a/handlers/payments/paritypay/handlers.py b/handlers/payments/paritypay/handlers.py new file mode 100644 index 00000000..62e5958a --- /dev/null +++ b/handlers/payments/paritypay/handlers.py @@ -0,0 +1,107 @@ +from aiogram import F, Router, types +from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_temporary_data +from database.models import User +from handlers.buttons import MAIN_MENU, PAY_2 +from handlers.texts import DEFAULT_PAYMENT_MESSAGE +from handlers.utils import edit_or_send_message +from logger import logger +from services.payments.currency_rates import format_for_user + +from ..constants import ALLOWED_TEMP_PAYMENT_STATES +from .service import ( + PARITYPAY_METHODS, + generate_paritypay_payment_link, + process_callback_pay_paritypay, + router as service_router, +) + + +router = Router(name="paritypay_router") +router.include_router(service_router) + + +@router.callback_query(F.data == "pay_paritypay_sbp") +async def handle_pay_paritypay_sbp(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): + await process_callback_pay_paritypay(callback_query, state, session, method_name="sbp") + + +async def _handle_custom_amount_input_paritypay( + event, + session: AsyncSession, + method_name: str, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + message = event.message + from_user = event.from_user + tg_id = from_user.id + + temp_data = await get_temporary_data(session, tg_id) + if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES: + await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.") + return + + amount = int(temp_data["data"].get("required_amount", 0)) + if amount <= 0: + await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.") + return + + method = PARITYPAY_METHODS.get(method_name) + if not method or not method["enable"]: + await edit_or_send_message( + target_message=message, + text="❌ Способ оплаты ParityPay временно недоступен.", + ) + return + + if amount < method["min_amount"]: + await edit_or_send_message( + target_message=message, + text=f"❌ Минимальная сумма для оплаты — {method['min_amount']}₽.", + ) + return + + try: + payment_url = await generate_paritypay_payment_link(amount, tg_id, method, session) + if not payment_url: + await edit_or_send_message( + target_message=message, + text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.", + ) + return + + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=pay_button_text, url=payment_url)], + [InlineKeyboardButton(text=main_menu_text, callback_data="profile")], + ] + ) + result = await session.execute(select(User.language_code).where(User.tg_id == tg_id)) + language_code = result.scalar_one_or_none() + amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB") + await edit_or_send_message( + target_message=message, + text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text), + reply_markup=markup, + ) + except Exception as e: + logger.error(f"Ошибка при создании платежа ParityPay {method_name} для пользователя {tg_id}: {e}") + await edit_or_send_message( + target_message=message, + text="Произошла ошибка при создании платежа. Попробуйте позже.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + + +async def handle_custom_amount_input_paritypay_sbp( + event, + session: AsyncSession, + pay_button_text: str = PAY_2, + main_menu_text: str = MAIN_MENU, +): + await _handle_custom_amount_input_paritypay(event, session, "sbp", pay_button_text, main_menu_text) diff --git a/handlers/payments/paritypay/service.py b/handlers/payments/paritypay/service.py new file mode 100644 index 00000000..416fadf6 --- /dev/null +++ b/handlers/payments/paritypay/service.py @@ -0,0 +1,417 @@ +import hashlib +import hmac +import json +import time + +import aiohttp + +from aiogram import F, Router, types +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ( + PARITYPAY_API_URL, + PARITYPAY_CALLBACK_URL, + PARITYPAY_FAIL_URL, + PARITYPAY_SECRET_KEY, + PARITYPAY_SHOP_ID, + PARITYPAY_SUCCESS_URL, + PROVIDERS_ENABLED, +) +from database import register_pending_payment +from database.models import User +from handlers.buttons import BACK, PARITYPAY_SBP, PAY_2 +from handlers.payments.keyboards import ( + build_amounts_keyboard, + parse_amount_from_callback, + pay_keyboard, + payment_options_for_user, +) +from handlers.texts import ( + PARITYPAY_PAYMENT_MESSAGE, + PARITYPAY_SBP_DESCRIPTION, +) +from handlers.utils import edit_or_send_message +from logger import logger +from services.payments.currency_rates import format_for_user +from services.payments.payment_links import register_payment_creator + + +router = Router() + + +async def get_user_language(session: AsyncSession, tg_id: int) -> str | None: + result = await session.execute(select(User.language_code).where(User.tg_id == tg_id)) + return result.scalar_one_or_none() + + +class ReplenishBalanceParityPay(StatesGroup): + choosing_method = State() + choosing_amount = State() + waiting_for_payment_confirmation = State() + entering_custom_amount = State() + + +PARITYPAY_METHODS = { + "sbp": { + "enable": PROVIDERS_ENABLED.get("PARITYPAY_SBP", False), + "service": "sbp", + "button": PARITYPAY_SBP, + "desc": PARITYPAY_SBP_DESCRIPTION, + "min_amount": 10, + }, +} + + +def _build_signature_string(payload: dict) -> str: + parts: list[str] = [] + for key in sorted(payload.keys()): + value = payload[key] + if value is None: + parts.append("") + elif isinstance(value, bool): + parts.append("1" if value else "0") + else: + parts.append(str(value)) + return "".join(parts) + + +def _sign_request(payload: dict) -> str: + sign_string = _build_signature_string(payload) + return hmac.new( + PARITYPAY_SECRET_KEY.encode("utf-8"), + sign_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +async def process_callback_pay_paritypay( + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, + method_name: str | None = None, +): + try: + tg_id = callback_query.from_user.id + logger.info(f"User {tg_id} initiated ParityPay payment.") + await state.clear() + + if method_name: + method = PARITYPAY_METHODS.get(method_name) + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + language_code = await get_user_language(session, tg_id) + opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB") + builder = build_amounts_keyboard( + prefix=f"paritypay_{method_name}", + pattern="{prefix}_amount|{price}", + back_cb="balance", + custom_cb=f"paritypay_custom_amount|{method_name}", + opts=opts, + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=method["desc"], + reply_markup=builder, + ) + await state.update_data( + paritypay_method=method_name, + message_id=callback_query.message.message_id, + chat_id=callback_query.message.chat.id, + ) + await state.set_state(ReplenishBalanceParityPay.choosing_amount) + return + + builder = InlineKeyboardBuilder() + for name, method in PARITYPAY_METHODS.items(): + if method["enable"]: + builder.row(InlineKeyboardButton(text=method["button"], callback_data=f"paritypay_method|{name}")) + builder.row(InlineKeyboardButton(text=BACK, callback_data="balance")) + + await edit_or_send_message( + target_message=callback_query.message, + text="Выберите способ оплаты ParityPay:", + reply_markup=builder.as_markup(), + ) + await state.update_data( + message_id=callback_query.message.message_id, + chat_id=callback_query.message.chat.id, + ) + await state.set_state(ReplenishBalanceParityPay.choosing_method) + except Exception as e: + logger.error(f"Error in process_callback_pay_paritypay for user {callback_query.from_user.id}: {e}") + await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True) + + +@router.callback_query(F.data.startswith("paritypay_method|")) +async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): + method_name = callback_query.data.split("|")[1] + method = PARITYPAY_METHODS.get(method_name) + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + await state.update_data(paritypay_method=method_name) + tg_id = callback_query.from_user.id + language_code = await get_user_language(session, tg_id) + opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB") + builder = build_amounts_keyboard( + prefix=f"paritypay_{method_name}", + pattern="{prefix}_amount|{price}", + back_cb="pay", + custom_cb=f"paritypay_custom_amount|{method_name}", + opts=opts, + ) + + await edit_or_send_message( + target_message=callback_query.message, + text=method["desc"], + reply_markup=builder, + ) + await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id) + await state.set_state(ReplenishBalanceParityPay.choosing_amount) + + +@router.callback_query(F.data.startswith("paritypay_custom_amount|")) +async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): + method_name = callback_query.data.split("|")[1] + await state.update_data(paritypay_method=method_name) + + builder = InlineKeyboardBuilder() + builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_paritypay")) + + await edit_or_send_message( + target_message=callback_query.message, + text="Пожалуйста, введите сумму пополнения в рублях (₽).", + reply_markup=builder.as_markup(), + ) + await state.set_state(ReplenishBalanceParityPay.entering_custom_amount) + + +@router.message(ReplenishBalanceParityPay.entering_custom_amount) +async def handle_custom_amount_input(message: types.Message, state: FSMContext, session: AsyncSession): + data = await state.get_data() + method_name = data.get("paritypay_method") + method = PARITYPAY_METHODS.get(method_name) + if not method or not method["enable"]: + await edit_or_send_message( + target_message=message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + try: + user_amount = int(message.text.strip()) + if user_amount <= 0: + raise ValueError + if user_amount < method["min_amount"]: + await edit_or_send_message( + target_message=message, + text=f"❌ Минимальная сумма для оплаты — {method['min_amount']}₽.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + except Exception: + await edit_or_send_message( + target_message=message, + text="❌ Некорректная сумма. Введите целое число больше 0.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + await state.update_data(amount=user_amount) + payment_url = await generate_paritypay_payment_link(user_amount, message.from_user.id, method, session) + if not payment_url: + await edit_or_send_message( + target_message=message, + text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + confirm_keyboard = pay_keyboard(payment_url, pay_text=PAY_2, back_cb="balance") + tg_id = message.from_user.id + language_code = await get_user_language(session, tg_id) + amount_text = await format_for_user(session, tg_id, float(user_amount), language_code, force_currency="RUB") + await edit_or_send_message( + target_message=message, + text=PARITYPAY_PAYMENT_MESSAGE.format(amount=amount_text), + reply_markup=confirm_keyboard, + ) + await state.set_state(ReplenishBalanceParityPay.waiting_for_payment_confirmation) + + +@router.callback_query(F.data.startswith("paritypay_sbp_amount|")) +async def process_amount_selection_sbp(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession): + await _process_amount_selection(callback_query, state, session, "sbp") + + +async def _process_amount_selection( + callback_query: types.CallbackQuery, + state: FSMContext, + session: AsyncSession, + method_name: str, +): + amount = parse_amount_from_callback(callback_query.data, prefixes=[f"paritypay_{method_name}"]) + if amount is None: + await edit_or_send_message( + target_message=callback_query.message, + text="Некорректная сумма.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + method = PARITYPAY_METHODS.get(method_name) + if not method or not method["enable"]: + await edit_or_send_message( + target_message=callback_query.message, + text="Ошибка: выбранный способ оплаты недоступен.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + if amount < method["min_amount"]: + await edit_or_send_message( + target_message=callback_query.message, + text=f"❌ Минимальная сумма для оплаты — {method['min_amount']}₽.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + await state.update_data(amount=amount) + payment_url = await generate_paritypay_payment_link(amount, callback_query.from_user.id, method, session) + if not payment_url: + await edit_or_send_message( + target_message=callback_query.message, + text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[]), + ) + return + + confirm_keyboard = pay_keyboard(payment_url, pay_text=PAY_2, back_cb="balance") + tg_id = callback_query.from_user.id + language_code = await get_user_language(session, tg_id) + amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB") + await edit_or_send_message( + target_message=callback_query.message, + text=PARITYPAY_PAYMENT_MESSAGE.format(amount=amount_text), + reply_markup=confirm_keyboard, + ) + await state.set_state(ReplenishBalanceParityPay.waiting_for_payment_confirmation) + + +async def generate_paritypay_payment_link( + amount: int, + tg_id: int, + method: dict, + session: AsyncSession | None = None, + *, + order_id: str | None = None, + success_url: str | None = None, + fail_url: str | None = None, + metadata: dict | None = None, +) -> str | None: + unique_order_id = order_id or f"{int(time.time())}_{tg_id}" + payload = { + "shop_id": PARITYPAY_SHOP_ID, + "amount": int(amount), + "order_id": unique_order_id, + "service": method["service"], + "success_url": success_url or PARITYPAY_SUCCESS_URL or "", + "fail_url": fail_url or PARITYPAY_FAIL_URL or "", + "callback_url": PARITYPAY_CALLBACK_URL or "", + "user_hash": str(tg_id), + } + payload = {k: v for k, v in payload.items() if v not in (None, "")} + signature = _sign_request(payload) + headers = {"Content-Type": "application/json", "X-SIGNATURE": signature} + url = f"{PARITYPAY_API_URL.rstrip('/')}/invoice/create" + + timeout = aiohttp.ClientTimeout(total=30, connect=10) + try: + async with aiohttp.ClientSession(timeout=timeout) as http_session: + async with http_session.post(url, headers=headers, data=json.dumps(payload)) as resp: + text = await resp.text() + if resp.status != 200: + logger.error(f"ParityPay API error: status={resp.status}, body={text}") + return None + try: + resp_json = json.loads(text) + except Exception as e: + logger.error(f"ParityPay: невалидный JSON в ответе ({e}): {text}") + return None + if "error" in resp_json: + logger.error(f"ParityPay error: {resp_json}") + return None + payment_url = resp_json.get("link") + if not payment_url: + logger.error(f"ParityPay: пустая ссылка в ответе: {resp_json}") + return None + await register_pending_payment( + payment_id=unique_order_id, + tg_id=tg_id, + amount=float(amount), + payment_system="paritypay", + currency="RUB", + metadata=metadata, + ) + logger.info(f"ParityPay payment URL created for user {tg_id}, order_id={unique_order_id}") + return payment_url + except Exception as e: + logger.error(f"Error creating ParityPay payment: {e}") + return None + + +def _create_link_factory(method_name: str): + async def create_link( + session: AsyncSession, + tg_id: int, + amount: float, + currency: str, + success_url: str | None, + failure_url: str | None, + metadata: dict | None, + ) -> tuple[str, str | None]: + if currency != "RUB": + raise ValueError("ParityPay поддерживает только RUB") + method = PARITYPAY_METHODS.get(method_name) + if not method or not method.get("enable"): + raise ValueError("Способ оплаты ParityPay недоступен") + amount_int = int(amount) + if amount_int < method["min_amount"]: + raise ValueError(f"Минимальная сумма — {method['min_amount']}₽") + order_id = f"{int(time.time())}_{tg_id}" + url = await generate_paritypay_payment_link( + amount_int, + tg_id, + method, + session, + order_id=order_id, + success_url=success_url, + fail_url=failure_url, + metadata=metadata, + ) + if not url: + raise ValueError("Не удалось создать платёж ParityPay") + return (url, order_id) + + return create_link + + +register_payment_creator("PARITYPAY_SBP", _create_link_factory("sbp")) diff --git a/services/payments/paritypay/__init__.py b/services/payments/paritypay/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/payments/paritypay/webhook.py b/services/payments/paritypay/webhook.py new file mode 100644 index 00000000..705e4266 --- /dev/null +++ b/services/payments/paritypay/webhook.py @@ -0,0 +1,149 @@ +import hashlib +import hmac +import json + +from aiohttp import web + +from config import PARITYPAY_SECRET_KEY +from core.webhook_abuse import ( + get_webhook_client_ip, + is_webhook_ip_blocked, + record_webhook_signature_failure, +) +from logger import logger +from services.payments.pipeline import ( + ParsedPayment, + process_cancelled_payment, + process_success_payment, +) + + +_PROVIDER = "paritypay" + + +def _build_signature_string(payload: dict) -> str: + parts: list[str] = [] + for key in sorted(payload.keys()): + value = payload[key] + if value is None: + parts.append("") + elif isinstance(value, bool): + parts.append("1" if value else "0") + else: + parts.append(str(value)) + return "".join(parts) + + +def verify_paritypay_signature(payload: dict, signature: str) -> bool: + try: + sign_string = _build_signature_string(payload) + expected = hmac.new( + PARITYPAY_SECRET_KEY.encode("utf-8"), + sign_string.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + result = hmac.compare_digest(expected, signature) + if not result: + logger.error(f"[ParityPay] Signature mismatch. Expected: {expected}, Got: {signature}") + return result + except Exception as e: + logger.error(f"[ParityPay] Ошибка проверки подписи: {e}") + return False + + +def _parse_tg_id_from_order(order_id: str) -> int | None: + if not order_id: + return None + parts = order_id.split("_") + if len(parts) < 2: + return None + try: + return int(parts[1]) + except (ValueError, TypeError): + return None + + +async def paritypay_webhook(request: web.Request): + try: + ip = get_webhook_client_ip(request) + if await is_webhook_ip_blocked(ip): + return web.Response(status=429) + + raw_body = await request.read() + try: + data = json.loads(raw_body) + except Exception as e: + logger.error(f"[ParityPay] Невалидный JSON в webhook: {e}") + return web.Response(status=400) + + signature = request.headers.get("X-SIGNATURE") or request.headers.get("X-Signature") or "" + if not signature: + logger.error("[ParityPay] Нет заголовка X-SIGNATURE") + await record_webhook_signature_failure(ip) + return web.Response(status=400) + + if not verify_paritypay_signature(data, signature): + await record_webhook_signature_failure(ip) + return web.Response(status=400) + + logger.info(f"[ParityPay] webhook: {json.dumps(data, ensure_ascii=False)}") + + order_id = str(data.get("order_id") or "") + invoice_id = str(data.get("id") or "") + status = str(data.get("status") or "").upper() + amount_raw = data.get("amount") + service = data.get("service") + + if not order_id: + logger.error(f"[ParityPay] Пустой order_id в webhook: {data}") + return web.Response(status=400) + + try: + amount = float(amount_raw) if amount_raw is not None else 0.0 + except (TypeError, ValueError): + amount = 0.0 + + tg_id = _parse_tg_id_from_order(order_id) + + if status == "PAID": + if tg_id is None: + logger.error(f"[ParityPay] Не удалось определить tg_id из order_id={order_id}") + return web.Response(status=400) + + metadata_patch = { + "provider": _PROVIDER, + "paritypay_invoice_id": invoice_id or None, + "paritypay_service": service, + } + parsed = ParsedPayment( + payment_id=order_id, + tg_id=int(tg_id), + amount=float(amount), + currency="RUB", + metadata=metadata_patch, + ) + result = await process_success_payment(_PROVIDER, parsed, metadata_patch=metadata_patch) + if not result.ok: + logger.error(f"[ParityPay] Pipeline вернул ошибку: {result.error}, order_id={order_id}") + return web.Response(status=500) + + logger.info(f"[ParityPay] Платёж обработан: tg_id={tg_id}, amount={amount:.2f} ₽, order_id={order_id}") + return web.Response(status=200, text="OK") + + if status in ("EXPIRED", "REFUNDED"): + new_status = "failed" if status == "EXPIRED" else "refunded" + parsed = ParsedPayment( + payment_id=order_id, + tg_id=int(tg_id) if tg_id is not None else None, + amount=float(amount), + currency="RUB", + ) + await process_cancelled_payment(_PROVIDER, parsed, new_status=new_status) + logger.warning(f"[ParityPay] Транзакция {status}: order_id={order_id}") + return web.Response(status=200, text="OK") + + logger.info(f"[ParityPay] Промежуточный статус '{status}' для order_id={order_id}, игнор") + return web.Response(status=200, text="OK") + except Exception as e: + logger.error(f"[ParityPay] Ошибка в webhook: {e}", exc_info=True) + return web.Response(status=500) diff --git a/services/payments/providers.py b/services/payments/providers.py index 7276f5af..740cdae9 100644 --- a/services/payments/providers.py +++ b/services/payments/providers.py @@ -43,6 +43,13 @@ PROVIDERS_BASE: dict[str, dict[str, Any]] = { "module": "wata", "order": 55, }, + "PARITYPAY_SBP": { + "currency": "RUB", + "value": "pay_paritypay_sbp", + "fast": "handle_custom_amount_input_paritypay_sbp", + "module": "paritypay", + "order": 57, + }, "TRIBUTE": { "currency": "RUB+USD", "value": "pay_tribute", @@ -90,6 +97,7 @@ WEB_LINK_PROVIDER_IDS = ( "KASSAI_SBP", "WATA_RU", "WATA_INT", + "PARITYPAY_SBP", "HELEKET", "FREEKASSA", "CRYPTOBOT", diff --git a/web/__init__.py b/web/__init__.py index 5939d98c..a9a132d8 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -2,6 +2,7 @@ from aiohttp.web_urldispatcher import UrlDispatcher from services.payments.heleket.webhook import heleket_webhook from services.payments.kassai.webhook import kassai_webhook +from services.payments.paritypay.webhook import paritypay_webhook from services.payments.wata.webhook import wata_webhook from utils.modules_loader import load_module_webhooks @@ -9,12 +10,14 @@ from utils.modules_loader import load_module_webhooks KASSAI_WEBHOOK_PATH = "/kassai/webhook" HELEKET_WEBHOOK_PATH = "/heleket/webhook" WATA_WEBHOOK_PATH = "/wata/webhook" +PARITYPAY_WEBHOOK_PATH = "/paritypay/webhook" async def register_web_routes(router: UrlDispatcher) -> None: router.add_post(KASSAI_WEBHOOK_PATH, kassai_webhook) router.add_post(HELEKET_WEBHOOK_PATH, heleket_webhook) router.add_post(WATA_WEBHOOK_PATH, wata_webhook) + router.add_post(PARITYPAY_WEBHOOK_PATH, paritypay_webhook) try: module_webhooks = load_module_webhooks()