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')