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..938e1c7d 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 @@ -1850,6 +1855,26 @@ 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..4a1fda47 100644 --- a/app/handlers/balance/kassa_ai.py +++ b/app/handlers/balance/kassa_ai.py @@ -10,6 +10,7 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts +from app.services.kassa_ai_service import KASSA_AI_SUB_METHODS from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler @@ -18,23 +19,55 @@ from app.utils.decorators import error_handler logger = structlog.get_logger(__name__) +# --- Enabled check + display name lookup by payment method --- + +_KASSA_AI_METHOD_CONFIG = { + 'kassa_ai': { + 'is_enabled': settings.is_kassa_ai_enabled, + 'display_name': settings.get_kassa_ai_display_name, + 'unavailable_text': 'KassaAI временно недоступен', + }, + 'kassa_ai_sbp': { + 'is_enabled': settings.is_kassa_ai_sbp_enabled, + 'display_name': settings.get_kassa_ai_sbp_display_name, + 'unavailable_text': 'KassaAI СБП временно недоступен', + }, + 'kassa_ai_card': { + 'is_enabled': settings.is_kassa_ai_card_enabled, + 'display_name': settings.get_kassa_ai_card_display_name, + 'unavailable_text': 'KassaAI Карта временно недоступна', + }, +} + + +async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User) -> bool: + """Check if user has topup restriction. Returns True if restricted (handler should abort).""" + if not getattr(db_user, 'restriction_topup', False): + return False + texts = get_texts(db_user.language) + 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 True + + 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. - - Args: - message_or_callback: Either a Message or CallbackQuery object - db_user: User object - db: Database session - amount_kopeks: Amount in kopeks - edit_message: Whether to edit existing message or send new one - """ + """Common logic for creating KassaAI payment and sending response.""" texts = get_texts(db_user.language) amount_rub = amount_kopeks / 100 @@ -46,6 +79,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 +89,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 +111,8 @@ async def _create_kassa_ai_payment_and_respond( return payment_url = result.get('payment_url') - display_name = settings.get_kassa_ai_display_name() + cfg = _KASSA_AI_METHOD_CONFIG.get(payment_method, _KASSA_AI_METHOD_CONFIG['kassa_ai']) + display_name = cfg['display_name']() # Create keyboard with payment button keyboard = InlineKeyboardMarkup( @@ -128,10 +166,9 @@ 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). - """ + """Process payment amount directly (called from custom_amount and quick_amount handlers).""" texts = get_texts(db_user.language) # Проверка ограничения на пополнение @@ -183,53 +220,40 @@ async def process_kassa_ai_payment_amount( db=db, amount_kopeks=amount_kopeks, edit_message=False, + payment_method=payment_method, ) -@error_handler -async def start_kassa_ai_topup( +# --- Generic start/quick-amount implementations --- + + +async def _start_kassa_ai_sub_topup( callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, + payment_method: str, ): - """ - Start KassaAI top-up process - ask for amount. - """ + """Generic start topup handler for any KassaAI sub-method.""" + cfg = _KASSA_AI_METHOD_CONFIG[payment_method] 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')]) + if not cfg['is_enabled'](): + await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), show_alert=True) + return - await callback.message.edit_text( - f'🚫 Пополнение ограничено\n\n{reason}', - parse_mode='HTML', - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), - ) + if await _check_topup_restriction(callback, db_user): return await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method='kassa_ai') + await state.update_data(payment_method=payment_method) min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100 max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100 - display_name = settings.get_kassa_ai_display_name() + display_name = cfg['display_name']() keyboard = InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t('BACK_BUTTON', '◀️ Назад'), - callback_data='menu_balance', - ) - ] - ] + inline_keyboard=[[InlineKeyboardButton(text=texts.t('BACK_BUTTON', '◀️ Назад'), callback_data='menu_balance')]] ) await callback.message.edit_text( @@ -249,6 +273,68 @@ async def start_kassa_ai_topup( ) +async def _process_kassa_ai_sub_quick_amount( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + payment_method: str, +): + """Generic quick amount handler for any KassaAI sub-method.""" + cfg = _KASSA_AI_METHOD_CONFIG[payment_method] + texts = get_texts(db_user.language) + + if not cfg['is_enabled'](): + await callback.answer(texts.t('KASSA_AI_NOT_AVAILABLE', cfg['unavailable_text']), 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 await _check_topup_restriction(callback, db_user): + 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=payment_method, + ) + + +# --- Public handler functions (registered in main.py) --- + + +@error_handler +async def start_kassa_ai_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Start KassaAI top-up process - ask for amount.""" + await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai') + + @error_handler async def process_kassa_ai_custom_amount( message: types.Message, @@ -256,11 +342,10 @@ async def process_kassa_ai_custom_amount( db: AsyncSession, state: FSMContext, ): - """ - Process custom amount input for KassaAI payment. - """ + """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_METHOD_CONFIG: return texts = get_texts(db_user.language) @@ -285,6 +370,7 @@ async def process_kassa_ai_custom_amount( db=db, amount_kopeks=amount_kopeks, state=state, + payment_method=pm, ) @@ -295,72 +381,49 @@ async def process_kassa_ai_quick_amount( db: AsyncSession, state: FSMContext, ): - """ - Process quick amount selection for KassaAI payment. - Called when user clicks a predefined amount button. - """ - texts = get_texts(db_user.language) + """Process quick amount selection for KassaAI payment.""" + await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai') - if not settings.is_kassa_ai_enabled(): - await callback.answer( - texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI временно недоступен'), - show_alert=True, - ) - return - # Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks} - try: - parts = callback.data.split('|') - if len(parts) >= 3: - amount_kopeks = int(parts[2]) - else: - await callback.answer('Invalid callback data', show_alert=True) - return - except (ValueError, IndexError): - await callback.answer('Invalid amount', show_alert=True) - return +@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.""" + await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp') - # Проверка ограничения на пополнение - 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 +@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.""" + await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_sbp') - # Validate amount - 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 +@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.""" + await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card') - 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, - ) +@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.""" + await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai_card') diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 3fdd1f15..df127607 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -131,11 +131,13 @@ 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 +792,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/kassa_ai_service.py b/app/services/kassa_ai_service.py index 3b37f838..24d33b42 100644 --- a/app/services/kassa_ai_service.py +++ b/app/services/kassa_ai_service.py @@ -14,6 +14,12 @@ from app.config import settings logger = structlog.get_logger(__name__) +# Sub-method to payment_system_id mapping +KASSA_AI_SUB_METHODS = { + 'kassa_ai_sbp': {'payment_system_id': 44}, + 'kassa_ai_card': {'payment_system_id': 36}, +} + # Кэш для публичного IP _cached_public_ip: str | None = None _ip_fetch_lock = asyncio.Lock() diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 9f7273c8..7a88162d 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,9 @@ 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 +121,9 @@ 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..c952030b 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -666,23 +666,29 @@ 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.services.kassa_ai_service 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') + await _patch_guest_metadata(result['local_payment_id'], payment_method) return { 'payment_url': result.get('payment_url'), 'payment_id': result.get('order_id'), - 'provider': 'kassa_ai', + 'provider': payment_method, } return None