From 04419fdff7dc244eb2c9553ea1501e2de454010b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 7 Mar 2026 14:21:54 +0000 Subject: [PATCH] feat: add SBP and Card sub-options to kassa_ai payment method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kassa_ai shows single admin entry with СБП/Карта sub-option checkboxes - SBP routes to payment_system_id=44, Card to payment_system_id=36 - Bot: added kassa_ai_sbp/card handlers and FSM flow (mirrors freekassa pattern) - Cabinet: KASSA_AI_OPTION_MAP reads payment_option to select correct ps_id - Config: KASSA_AI_SBP_ENABLED / KASSA_AI_CARD_ENABLED env vars + helpers - Guest payments: kassa_ai_sbp/card supported in landing page checkout - payment_method_config_service: kassa_ai has available_sub_options=[sbp,card] --- app/cabinet/routes/balance.py | 6 + app/config.py | 24 ++ app/handlers/balance/kassa_ai.py | 234 +++++++++++++++++- app/handlers/balance/main.py | 17 +- app/keyboards/inline.py | 30 ++- app/services/payment/kassa_ai.py | 5 +- app/services/payment_method_config_service.py | 5 +- app/services/payment_service.py | 7 +- 8 files changed, 318 insertions(+), 10 deletions(-) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 37ef85a7..34bb1ca7 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -701,6 +701,11 @@ async def create_topup( detail='KassaAI payment method is unavailable', ) + # Use payment_option to select sbp or card + KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36} + option = (request.payment_option or '').strip().lower() + ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default + payment_service = PaymentService() result = await payment_service.create_kassa_ai_payment( db=db, @@ -709,6 +714,7 @@ async def create_topup( description=settings.get_balance_payment_description(request.amount_kopeks), email=getattr(user, 'email', None), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_system_id=ps_id, ) if result and result.get('payment_url'): diff --git a/app/config.py b/app/config.py index 54dfd07b..69bab8f5 100644 --- a/app/config.py +++ b/app/config.py @@ -535,6 +535,11 @@ class Settings(BaseSettings): KASSA_AI_WEBHOOK_PORT: int = 8089 # Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay KASSA_AI_PAYMENT_SYSTEM_ID: int = 44 + # Раздельные методы оплаты KassaAI (отображаются как отдельные кнопки) + KASSA_AI_SBP_ENABLED: bool = False # СБП — payment_system_id=44 + KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)' + KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36 + KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)' # RioPay (api.riopay.online) v2.0.1 RIOPAY_ENABLED: bool = False @@ -1849,6 +1854,25 @@ class Settings(BaseSettings): def get_riopay_display_name_html(self) -> str: return html.escape(self.get_riopay_display_name()) + def is_kassa_ai_sbp_enabled(self) -> bool: + return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled() + + def get_kassa_ai_sbp_display_name(self) -> str: + name = (self.KASSA_AI_SBP_DISPLAY_NAME or '').strip() + return name if name else 'СБП (KassaAI)' + + def get_kassa_ai_sbp_display_name_html(self) -> str: + return html.escape(self.get_kassa_ai_sbp_display_name()) + + def is_kassa_ai_card_enabled(self) -> bool: + return self.KASSA_AI_CARD_ENABLED and self.is_kassa_ai_enabled() + + def get_kassa_ai_card_display_name(self) -> str: + name = (self.KASSA_AI_CARD_DISPLAY_NAME or '').strip() + return name if name else 'Карта (KassaAI)' + + def get_kassa_ai_card_display_name_html(self) -> str: + return html.escape(self.get_kassa_ai_card_display_name()) def is_payment_verification_auto_check_enabled(self) -> bool: return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED diff --git a/app/handlers/balance/kassa_ai.py b/app/handlers/balance/kassa_ai.py index 55c449af..4c73ce33 100644 --- a/app/handlers/balance/kassa_ai.py +++ b/app/handlers/balance/kassa_ai.py @@ -18,12 +18,20 @@ from app.utils.decorators import error_handler logger = structlog.get_logger(__name__) +KASSA_AI_SUB_METHODS = { + 'kassa_ai_sbp': {'payment_system_id': 44}, + 'kassa_ai_card': {'payment_system_id': 36}, +} +KASSA_AI_PAYMENT_METHODS = {'kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'} + + async def _create_kassa_ai_payment_and_respond( message_or_callback, db_user: User, db: AsyncSession, amount_kopeks: int, edit_message: bool = False, + payment_method: str = 'kassa_ai', ): """ Common logic for creating KassaAI payment and sending response. @@ -34,6 +42,7 @@ async def _create_kassa_ai_payment_and_respond( db: Database session amount_kopeks: Amount in kopeks edit_message: Whether to edit existing message or send new one + payment_method: 'kassa_ai', 'kassa_ai_sbp', or 'kassa_ai_card' """ texts = get_texts(db_user.language) amount_rub = amount_kopeks / 100 @@ -46,6 +55,9 @@ async def _create_kassa_ai_payment_and_respond( description='Пополнение баланса', ) + sub = KASSA_AI_SUB_METHODS.get(payment_method) + payment_system_id = sub['payment_system_id'] if sub else settings.KASSA_AI_PAYMENT_SYSTEM_ID + result = await payment_service.create_kassa_ai_payment( db=db, user_id=db_user.id, @@ -53,6 +65,7 @@ async def _create_kassa_ai_payment_and_respond( description=description, email=getattr(db_user, 'email', None), language=db_user.language, + payment_system_id=payment_system_id, ) if not result: @@ -74,7 +87,12 @@ async def _create_kassa_ai_payment_and_respond( return payment_url = result.get('payment_url') - display_name = settings.get_kassa_ai_display_name() + if payment_method == 'kassa_ai_sbp': + display_name = settings.get_kassa_ai_sbp_display_name() + elif payment_method == 'kassa_ai_card': + display_name = settings.get_kassa_ai_card_display_name() + else: + display_name = settings.get_kassa_ai_display_name() # Create keyboard with payment button keyboard = InlineKeyboardMarkup( @@ -128,6 +146,7 @@ async def process_kassa_ai_payment_amount( db: AsyncSession, amount_kopeks: int, state: FSMContext, + payment_method: str = 'kassa_ai', ): """ Process payment amount directly (called from quick_amount handlers). @@ -183,6 +202,7 @@ async def process_kassa_ai_payment_amount( db=db, amount_kopeks=amount_kopeks, edit_message=False, + payment_method=payment_method, ) @@ -260,7 +280,8 @@ async def process_kassa_ai_custom_amount( Process custom amount input for KassaAI payment. """ data = await state.get_data() - if data.get('payment_method') != 'kassa_ai': + pm = data.get('payment_method', 'kassa_ai') + if pm not in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'): return texts = get_texts(db_user.language) @@ -285,6 +306,7 @@ async def process_kassa_ai_custom_amount( db=db, amount_kopeks=amount_kopeks, state=state, + payment_method=pm, ) @@ -364,3 +386,211 @@ async def process_kassa_ai_quick_amount( amount_kopeks=amount_kopeks, edit_message=True, ) + + +@error_handler +async def start_kassa_ai_sbp_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Start KassaAI SBP top-up process.""" + texts = get_texts(db_user.language) + + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + support_url = settings.get_support_contact_url() + keyboard = [] + if support_url: + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + await callback.message.edit_text( + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method='kassa_ai_sbp') + + min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100 + display_name = settings.get_kassa_ai_sbp_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')] + ] + ) + + await callback.message.edit_text( + texts.t( + 'KASSA_AI_ENTER_AMOUNT', + '💳 Пополнение через {name}\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}₽\nМаксимум: {max_amount}₽', + ).format(name=display_name, min_amount=min_amount, max_amount=f'{max_amount:,}'.replace(',', ' ')), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def process_kassa_ai_sbp_quick_amount( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Process quick amount for KassaAI SBP.""" + texts = get_texts(db_user.language) + if not settings.is_kassa_ai_sbp_enabled(): + await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI СБП временно недоступен'), show_alert=True) + return + + try: + parts = callback.data.split('|') + amount_kopeks = int(parts[2]) if len(parts) >= 3 else None + if amount_kopeks is None: + raise ValueError + except (ValueError, IndexError): + await callback.answer('Invalid amount', show_alert=True) + return + + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + support_url = settings.get_support_contact_url() + keyboard = [] + if support_url: + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + await callback.message.edit_text( + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + return + + min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS + max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS + if amount_kopeks < min_amount: + await callback.answer(texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True) + return + if amount_kopeks > max_amount: + await callback.answer(texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True) + return + + await callback.answer() + await state.clear() + await _create_kassa_ai_payment_and_respond( + message_or_callback=callback.message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=True, + payment_method='kassa_ai_sbp', + ) + + +@error_handler +async def start_kassa_ai_card_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Start KassaAI Card top-up process.""" + texts = get_texts(db_user.language) + + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + support_url = settings.get_support_contact_url() + keyboard = [] + if support_url: + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + await callback.message.edit_text( + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + return + + await state.set_state(BalanceStates.waiting_for_amount) + await state.update_data(payment_method='kassa_ai_card') + + min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100 + display_name = settings.get_kassa_ai_card_display_name() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')] + ] + ) + + await callback.message.edit_text( + texts.t( + 'KASSA_AI_ENTER_AMOUNT', + '💳 Пополнение через {name}\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}₽\nМаксимум: {max_amount}₽', + ).format(name=display_name, min_amount=min_amount, max_amount=f'{max_amount:,}'.replace(',', ' ')), + parse_mode='HTML', + reply_markup=keyboard, + ) + + +@error_handler +async def process_kassa_ai_card_quick_amount( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Process quick amount for KassaAI Card.""" + texts = get_texts(db_user.language) + if not settings.is_kassa_ai_card_enabled(): + await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI Карта временно недоступна'), show_alert=True) + return + + try: + parts = callback.data.split('|') + amount_kopeks = int(parts[2]) if len(parts) >= 3 else None + if amount_kopeks is None: + raise ValueError + except (ValueError, IndexError): + await callback.answer('Invalid amount', show_alert=True) + return + + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + support_url = settings.get_support_contact_url() + keyboard = [] + if support_url: + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + await callback.message.edit_text( + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + return + + min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS + max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS + if amount_kopeks < min_amount: + await callback.answer(texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True) + return + if amount_kopeks > max_amount: + await callback.answer(texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True) + return + + await callback.answer() + await state.clear() + await _create_kassa_ai_payment_and_respond( + message_or_callback=callback.message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=True, + payment_method='kassa_ai_card', + ) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 3fdd1f15..b2594fd6 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -131,11 +131,11 @@ async def route_payment_by_method( ) return True - if payment_method == 'kassa_ai': + if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'): from .kassa_ai import process_kassa_ai_payment_amount async with AsyncSessionLocal() as db: - await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state) + await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state, payment_method=payment_method) return True if payment_method == 'riopay': @@ -790,10 +790,21 @@ def register_balance_handlers(dp: Dispatcher): dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card') dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|')) - from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup + from .kassa_ai import ( + process_kassa_ai_card_quick_amount, + process_kassa_ai_quick_amount, + process_kassa_ai_sbp_quick_amount, + start_kassa_ai_card_topup, + start_kassa_ai_sbp_topup, + start_kassa_ai_topup, + ) dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai') dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|')) + dp.callback_query.register(start_kassa_ai_sbp_topup, F.data == 'topup_kassa_ai_sbp') + dp.callback_query.register(process_kassa_ai_sbp_quick_amount, F.data.startswith('topup_amount|kassa_ai_sbp|')) + dp.callback_query.register(start_kassa_ai_card_topup, F.data == 'topup_kassa_ai_card') + dp.callback_query.register(process_kassa_ai_card_quick_amount, F.data.startswith('topup_amount|kassa_ai_card|')) from .riopay import process_riopay_quick_amount, start_riopay_topup diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 1196e93e..d7208536 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1695,7 +1695,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True - if settings.is_kassa_ai_enabled(): + if settings.is_kassa_ai_sbp_enabled(): + sbp_name = settings.get_kassa_ai_sbp_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_KASSA_AI_SBP', f'📱 {sbp_name}'), + callback_data=_build_callback('kassa_ai_sbp'), + ) + ] + ) + has_direct_payment_methods = True + + if settings.is_kassa_ai_card_enabled(): + card_name = settings.get_kassa_ai_card_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_KASSA_AI_CARD', f'💳 {card_name}'), + callback_data=_build_callback('kassa_ai_card'), + ) + ] + ) + has_direct_payment_methods = True + + if ( + settings.is_kassa_ai_enabled() + and not settings.is_kassa_ai_sbp_enabled() + and not settings.is_kassa_ai_card_enabled() + ): kassa_ai_name = settings.get_kassa_ai_display_name() keyboard.append( [ diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 9f7273c8..7292e85d 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -28,6 +28,7 @@ class KassaAiPaymentMixin: description: str = 'Пополнение баланса', email: str | None = None, language: str = 'ru', + payment_system_id: int | None = None, ) -> dict[str, Any] | None: """ Создает платеж KassaAI. @@ -96,7 +97,7 @@ class KassaAiPaymentMixin: amount=amount_rubles, currency=currency, email=email, - payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID, + payment_system_id=payment_system_id if payment_system_id is not None else settings.KASSA_AI_PAYMENT_SYSTEM_ID, ) payment_url = result.get('location') @@ -118,7 +119,7 @@ class KassaAiPaymentMixin: currency=currency, description=description, payment_url=payment_url, - payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID, + payment_system_id=payment_system_id if payment_system_id is not None else settings.KASSA_AI_PAYMENT_SYSTEM_ID, expires_at=expires_at, metadata_json=metadata, ) diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index b4d79cb4..7cb25a84 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -126,7 +126,10 @@ def _get_method_defaults() -> dict: 'is_configured': settings.is_kassa_ai_enabled(), 'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS, 'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS, - 'available_sub_options': None, + 'available_sub_options': [ + {'id': 'sbp', 'name': 'СБП'}, + {'id': 'card', 'name': 'Карта'}, + ], }, 'riopay': { 'default_display_name': settings.get_riopay_display_name(), diff --git a/app/services/payment_service.py b/app/services/payment_service.py index bd979756..e0d59095 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -666,16 +666,21 @@ class PaymentService( return None # --- KassaAI ---------------------------------------------------------- - if payment_method == 'kassa_ai': + if payment_method in ('kassa_ai', 'kassa_ai_sbp', 'kassa_ai_card'): if not settings.is_kassa_ai_enabled(): logger.warning('KassaAI is not enabled, cannot create guest payment') return None + from app.handlers.balance.kassa_ai import KASSA_AI_SUB_METHODS + sub = KASSA_AI_SUB_METHODS.get(payment_method) + ps_id = sub['payment_system_id'] if sub else None + result = await self.create_kassa_ai_payment( db=db, user_id=None, amount_kopeks=amount_kopeks, description=description, + payment_system_id=ps_id, ) if result: await _patch_guest_metadata(result['local_payment_id'], 'kassa_ai')