@@ -491,16 +491,11 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
|
||||
YOOKASSA_RECURRENT_ENABLED=false
|
||||
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
|
||||
YOOKASSA_RECURRENT_REQUIRED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
|
||||
@@ -4107,7 +4107,7 @@ async def switch_tariff(
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
# Reset device limit to new tariff base (extra purchased devices are not carried over)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
|
||||
@@ -353,10 +353,8 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ''
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
@@ -1254,10 +1252,6 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
|
||||
@@ -40,23 +40,18 @@ def calc_device_limit_on_tariff_switch(
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
|
||||
"""Calculate device_limit when switching tariffs.
|
||||
|
||||
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
|
||||
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
|
||||
Resets to new tariff base device limit — previously purchased
|
||||
extra devices are NOT carried over. Capped at max_device_limit.
|
||||
"""
|
||||
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
|
||||
current = current_device_limit if current_device_limit is not None else old_base
|
||||
extra = max(0, current - old_base)
|
||||
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
total = new_base + extra
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and total > effective_max:
|
||||
total = effective_max
|
||||
if effective_max and new_base > effective_max:
|
||||
new_base = effective_max
|
||||
|
||||
return total
|
||||
return new_base
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
|
||||
Vendored
+27
-10
@@ -1,5 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -122,22 +123,38 @@ class CryptoBotService:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
if not self.webhook_secret:
|
||||
logger.warning('CryptoBot webhook secret не настроен')
|
||||
# По документации CryptoBot, ключ — SHA256 от API токена
|
||||
token = self.webhook_secret or self.api_token
|
||||
if not token:
|
||||
logger.warning('CryptoBot webhook secret и API token не настроены')
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
# 1. Raw body — CryptoBot скорее всего шлёт compact JSON
|
||||
expected = hmac.new(secret_hash, body.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected):
|
||||
logger.info('CryptoBot webhook подпись валидна')
|
||||
return True
|
||||
|
||||
if is_valid:
|
||||
logger.info('✅ CryptoBot webhook подпись валидна')
|
||||
else:
|
||||
logger.error('❌ Неверная подпись CryptoBot webhook')
|
||||
# 2. Fallback: JSON.stringify(body) как в доке — re-serialize compact
|
||||
parsed = json.loads(body)
|
||||
check_string = json.dumps(parsed, separators=(',', ':'), ensure_ascii=False)
|
||||
expected = hmac.new(secret_hash, check_string.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected):
|
||||
logger.info('CryptoBot webhook подпись валидна (re-serialized)')
|
||||
return True
|
||||
|
||||
return is_valid
|
||||
# 3. Fallback: ensure_ascii=True (если CryptoBot эскейпит unicode)
|
||||
check_string_ascii = json.dumps(parsed, separators=(',', ':'), ensure_ascii=True)
|
||||
expected = hmac.new(secret_hash, check_string_ascii.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(signature, expected):
|
||||
logger.info('CryptoBot webhook подпись валидна (ascii-escaped)')
|
||||
return True
|
||||
|
||||
logger.error('Неверная подпись CryptoBot webhook')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
|
||||
|
||||
@@ -129,9 +129,9 @@ async def process_cloudpayments_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
|
||||
Similar to process_heleket_payment_amount and other payment handlers.
|
||||
Similar to other payment amount handlers.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -197,7 +197,7 @@ async def start_cloudpayments_payment(
|
||||
"""
|
||||
Start CloudPayments payment flow.
|
||||
|
||||
Shows amount input prompt or quick amount buttons.
|
||||
Shows amount input prompt.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -375,123 +375,3 @@ async def process_cloudpayments_amount(
|
||||
)
|
||||
|
||||
logger.info('CloudPayments payment created: user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_cloudpayments_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Handle quick amount selection for CloudPayments.
|
||||
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await callback.answer(
|
||||
texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|cloudpayments|{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
|
||||
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'PAYMENT_CREATE_ERROR',
|
||||
'Не удалось создать платёж. Попробуйте позже.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
'PAY_BUTTON',
|
||||
'💳 Оплатить {amount}₽',
|
||||
).format(amount=f'{amount_rub:.0f}'),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'CLOUDPAYMENTS_PAYMENT_CREATED',
|
||||
'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(amount=f'{amount_rub:.2f}'),
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'CloudPayments payment created (quick): user amount=₽', telegram_id=db_user.telegram_id, amount_rub=amount_rub
|
||||
)
|
||||
|
||||
@@ -53,41 +53,18 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User,
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ', '.join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
message_text = (
|
||||
f'🪙 <b>Пополнение криптовалютой</b>\n\n'
|
||||
f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n'
|
||||
f'💰 Доступные активы: {assets_text}\n'
|
||||
f'⚡ Мгновенное зачисление на баланс\n'
|
||||
f'🔒 Безопасная оплата через CryptoBot\n\n'
|
||||
f'{rate_text}\n'
|
||||
f'Сумма будет автоматически конвертирована в USD для оплаты.'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
@@ -154,7 +154,7 @@ async def process_freekassa_payment_amount(
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -370,127 +370,3 @@ async def process_freekassa_custom_amount(
|
||||
state=state,
|
||||
payment_method=data.get('payment_method'),
|
||||
)
|
||||
|
||||
|
||||
async def _process_freekassa_quick_amount_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|{method}|{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
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
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'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.FREEKASSA_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_freekassa_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,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
|
||||
|
||||
@@ -72,13 +72,6 @@ async def start_heleket_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -168,7 +168,7 @@ async def process_kassa_ai_payment_amount(
|
||||
state: FSMContext,
|
||||
payment_method: str = 'kassa_ai',
|
||||
):
|
||||
"""Process payment amount directly (called from custom_amount and quick_amount handlers)."""
|
||||
"""Process payment amount directly (called from custom_amount handlers)."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
@@ -275,54 +275,6 @@ async def _start_kassa_ai_sub_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) ---
|
||||
|
||||
|
||||
@@ -376,17 +328,6 @@ async def process_kassa_ai_custom_amount(
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_kassa_ai_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Process quick amount selection for KassaAI payment."""
|
||||
await _process_kassa_ai_sub_quick_amount(callback, db_user, db, state, 'kassa_ai')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -398,17 +339,6 @@ async def start_kassa_ai_sbp_topup(
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_sbp')
|
||||
|
||||
|
||||
@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')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_kassa_ai_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -418,14 +348,3 @@ async def start_kassa_ai_card_topup(
|
||||
):
|
||||
"""Start KassaAI Card top-up process."""
|
||||
await _start_kassa_ai_sub_topup(callback, db_user, db, state, 'kassa_ai_card')
|
||||
|
||||
|
||||
@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')
|
||||
|
||||
+17
-149
@@ -157,102 +157,6 @@ async def route_payment_by_method(
|
||||
return False
|
||||
|
||||
|
||||
async def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Uses PricingEngine as the single source of truth for all price calculations,
|
||||
including base period price, devices, servers, traffic, and per-category discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.is_quick_amount_buttons_enabled():
|
||||
return []
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons = []
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
tariff = None
|
||||
tariff_periods = None
|
||||
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
|
||||
tariff = subscription.tariff
|
||||
if tariff and tariff.period_prices:
|
||||
tariff_periods = sorted(int(k) for k in tariff.period_prices.keys())
|
||||
|
||||
if tariff_periods:
|
||||
periods = tariff_periods[:6]
|
||||
else:
|
||||
periods = settings.get_available_subscription_periods()[:6]
|
||||
|
||||
for period in periods:
|
||||
try:
|
||||
if tariff and tariff_periods and period in tariff_periods:
|
||||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
period,
|
||||
device_limit=subscription.device_limit if subscription else None,
|
||||
user=user,
|
||||
)
|
||||
elif subscription:
|
||||
result = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
else:
|
||||
result = await pricing_engine.calculate_classic_new_subscription_price(
|
||||
db,
|
||||
period,
|
||||
[],
|
||||
0,
|
||||
settings.DEFAULT_DEVICE_LIMIT,
|
||||
user=user,
|
||||
)
|
||||
|
||||
total_price = result.final_total
|
||||
original_total = result.original_total
|
||||
|
||||
if total_price <= 0:
|
||||
continue
|
||||
|
||||
callback_data = f'quick_amount_{total_price}'
|
||||
period_label = f'{period} дней'
|
||||
|
||||
has_discount = original_total > total_price and original_total > 0
|
||||
if has_discount:
|
||||
discount_pct = round((original_total - total_price) * 100 / original_total)
|
||||
if discount_pct > 0:
|
||||
button_text = (
|
||||
f'{texts.format_price(original_total)} ➜ '
|
||||
f'{texts.format_price(total_price)} '
|
||||
f'(-{discount_pct}%) • {period_label}'
|
||||
)
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
else:
|
||||
button_text = f'{texts.format_price(total_price)} • {period_label}'
|
||||
|
||||
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
|
||||
except Exception:
|
||||
logger.warning('Failed to calculate price for period', period=period)
|
||||
continue
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i : i + 2])
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, доступно ли сообщение
|
||||
@@ -379,9 +283,22 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Проверяем сохранённую корзину для автоподстановки суммы пополнения
|
||||
amount_kopeks = 0
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(db_user.id)
|
||||
if cart_data and cart_data.get('saved_cart'):
|
||||
missing = cart_data.get('missing_amount', 0)
|
||||
if missing > 0:
|
||||
amount_kopeks = missing
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
keyboard = get_payment_methods_keyboard(amount_kopeks, db_user.language)
|
||||
|
||||
# Если сообщение недоступно, отправляем новое
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
@@ -627,37 +544,6 @@ async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession):
|
||||
await callback.answer('❌ Ошибка обработки платежа', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Проверяем, что пользователь в правильном состоянии FSM
|
||||
current_state = await state.get_state()
|
||||
if current_state != BalanceStates.waiting_for_amount:
|
||||
await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get('payment_method', 'yookassa')
|
||||
|
||||
# Роутим платеж на соответствующий обработчик
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
except ValueError:
|
||||
await callback.answer('❌ Ошибка обработки суммы', show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обработки быстрого выбора суммы', error=e)
|
||||
await callback.answer('❌ Ошибка обработки запроса', show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -784,52 +670,37 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket')
|
||||
dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_'))
|
||||
|
||||
from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment
|
||||
from .cloudpayments import start_cloudpayments_payment
|
||||
|
||||
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
||||
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
||||
|
||||
from .freekassa import (
|
||||
process_freekassa_card_quick_amount,
|
||||
process_freekassa_quick_amount,
|
||||
process_freekassa_sbp_quick_amount,
|
||||
start_freekassa_card_topup,
|
||||
start_freekassa_sbp_topup,
|
||||
start_freekassa_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
||||
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
||||
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
||||
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
||||
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_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
|
||||
from .riopay import start_riopay_topup
|
||||
|
||||
dp.callback_query.register(start_riopay_topup, F.data == 'topup_riopay')
|
||||
dp.callback_query.register(process_riopay_quick_amount, F.data.startswith('topup_amount|riopay|'))
|
||||
|
||||
from .severpay import process_severpay_quick_amount, start_severpay_topup
|
||||
from .severpay import start_severpay_topup
|
||||
|
||||
dp.callback_query.register(start_severpay_topup, F.data == 'topup_severpay')
|
||||
dp.callback_query.register(process_severpay_quick_amount, F.data.startswith('topup_amount|severpay|'))
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
@@ -849,9 +720,6 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable')
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_'))
|
||||
|
||||
dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|'))
|
||||
|
||||
dp.callback_query.register(handle_saved_cards_list, F.data == 'saved_cards_list')
|
||||
|
||||
@@ -65,13 +65,6 @@ async def start_mulenpay_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -303,13 +303,6 @@ async def start_pal24_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -71,13 +71,6 @@ async def _prompt_amount(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_riopay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -282,75 +282,3 @@ async def process_riopay_custom_amount(
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_riopay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for RioPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('RIOPAY_NOT_AVAILABLE', 'RioPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|riopay|{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
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.RIOPAY_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_riopay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ async def process_severpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
Process payment amount directly.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -243,75 +243,3 @@ async def start_severpay_topup(
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_severpay_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for SeverPay payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_severpay_enabled():
|
||||
await callback.answer(
|
||||
texts.t('SEVERPAY_NOT_AVAILABLE', 'SeverPay временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|severpay|{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
|
||||
|
||||
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||
if restriction_kb:
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
await callback.message.edit_text(
|
||||
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=restriction_kb,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
min_amount = settings.SEVERPAY_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.SEVERPAY_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_severpay_payment_and_respond(
|
||||
message_or_callback=callback.message,
|
||||
db_user=db_user,
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
)
|
||||
|
||||
@@ -40,24 +40,10 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = '⭐ <b>Пополнение через Telegram Stars</b>\n\nВыберите сумму пополнения или введите вручную:'
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
|
||||
await state.update_data(
|
||||
|
||||
@@ -61,13 +61,6 @@ async def start_wata_payment(
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
|
||||
@@ -46,31 +46,13 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'💳 <b>Оплата банковской картой</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
@@ -110,31 +92,13 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Выберите сумму пополнения или введите вручную сумму '
|
||||
f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
message_text = (
|
||||
f'🏦 <b>Оплата через СБП</b>\n\n'
|
||||
f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:'
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.is_quick_amount_buttons_enabled():
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
@@ -497,6 +497,12 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
if not subscription:
|
||||
await callback.answer(
|
||||
texts.t('NO_ACTIVE_SUBSCRIPTION', '⚠️ У вас нет активной подписки'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
# Проверяем тариф подписки
|
||||
|
||||
@@ -1469,7 +1469,7 @@ async def confirm_daily_tariff_purchase(
|
||||
try:
|
||||
if existing_subscription:
|
||||
# Обновляем существующую подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = (
|
||||
@@ -2697,7 +2697,7 @@ async def confirm_daily_tariff_switch(
|
||||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
# Обновляем подписку на суточный тариф
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
@@ -3288,7 +3288,7 @@ async def confirm_instant_switch(
|
||||
is_new_daily = getattr(new_tariff, 'is_daily', False)
|
||||
|
||||
# Обновляем подписку с новыми параметрами тарифа
|
||||
# Сохраняем докупленные устройства при смене тарифа
|
||||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
|
||||
@@ -87,7 +87,6 @@ class RioPayPaymentMixin:
|
||||
# Генерируем уникальный order_id с telegram_id для удобного поиска
|
||||
order_id = f'rp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.RIOPAY_CURRENCY
|
||||
|
||||
# Срок действия платежа (1 час по умолчанию)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
@@ -105,11 +104,9 @@ class RioPayPaymentMixin:
|
||||
# Используем API для создания заказа
|
||||
result = await riopay_service.create_order(
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
external_id=order_id,
|
||||
purpose=description,
|
||||
success_url=success_url or settings.RIOPAY_SUCCESS_URL,
|
||||
fail_url=fail_url or settings.RIOPAY_FAIL_URL,
|
||||
)
|
||||
|
||||
payment_url = result.get('paymentLink')
|
||||
|
||||
@@ -654,13 +654,14 @@ class RemnaWaveWebhookService:
|
||||
changed = True
|
||||
|
||||
# Sync subscription crypto link (for HAPP_CRYPT4_LINK)
|
||||
subscription_crypto_link = data.get('subscriptionCryptoLink')
|
||||
if (
|
||||
subscription_crypto_link
|
||||
and self._is_valid_link(subscription_crypto_link)
|
||||
and subscription.subscription_crypto_link != subscription_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = subscription_crypto_link
|
||||
subscription_crypto_link = data.get('subscriptionCryptoLink') or (data.get('happ') or {}).get('cryptoLink', '')
|
||||
if subscription_crypto_link and self._is_valid_link(subscription_crypto_link):
|
||||
if subscription.subscription_crypto_link != subscription_crypto_link:
|
||||
subscription.subscription_crypto_link = subscription_crypto_link
|
||||
changed = True
|
||||
elif subscription_url and subscription.subscription_crypto_link:
|
||||
# URL обновился, а крипто-ссылка не пришла — сбрасываем старую
|
||||
subscription.subscription_crypto_link = None
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
@@ -743,18 +744,18 @@ class RemnaWaveWebhookService:
|
||||
) -> None:
|
||||
if subscription:
|
||||
new_url = data.get('subscriptionUrl')
|
||||
new_crypto_link = data.get('subscriptionCryptoLink')
|
||||
new_crypto_link = data.get('subscriptionCryptoLink') or (data.get('happ') or {}).get('cryptoLink', '')
|
||||
changed = False
|
||||
|
||||
if new_url and self._is_valid_url(new_url) and subscription.subscription_url != new_url:
|
||||
subscription.subscription_url = new_url
|
||||
changed = True
|
||||
if (
|
||||
new_crypto_link
|
||||
and self._is_valid_link(new_crypto_link)
|
||||
and subscription.subscription_crypto_link != new_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
if new_crypto_link and self._is_valid_link(new_crypto_link):
|
||||
if subscription.subscription_crypto_link != new_crypto_link:
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
changed = True
|
||||
elif new_url and subscription.subscription_crypto_link:
|
||||
subscription.subscription_crypto_link = None
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite
|
||||
|
||||
@@ -45,7 +45,7 @@ class RioPayService:
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""Формирует заголовки для API запросов."""
|
||||
return {
|
||||
'x-api-token': self.api_token,
|
||||
'X-Api-Token': self.api_token,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
@@ -67,11 +67,9 @@ class RioPayService:
|
||||
self,
|
||||
*,
|
||||
amount: float,
|
||||
currency: str = 'RUB',
|
||||
external_id: str,
|
||||
purpose: str = 'Пополнение баланса',
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Создает заказ через API RioPay.
|
||||
@@ -82,21 +80,17 @@ class RioPayService:
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
'amount': str(amount),
|
||||
'currency': currency,
|
||||
'externalId': external_id,
|
||||
'purpose': purpose,
|
||||
}
|
||||
|
||||
if success_url:
|
||||
payload['successUrl'] = success_url
|
||||
if fail_url:
|
||||
payload['failUrl'] = fail_url
|
||||
|
||||
logger.info(
|
||||
'RioPay API create_order',
|
||||
external_id=external_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -275,7 +275,6 @@ class BotConfigurationService:
|
||||
'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_SQUAD_UUID': 'SIMPLE_SUBSCRIPTION',
|
||||
'DISABLE_TOPUP_BUTTONS': 'PAYMENT',
|
||||
'SUPPORT_TOPUP_ENABLED': 'PAYMENT',
|
||||
'ENABLE_NOTIFICATIONS': 'NOTIFICATIONS',
|
||||
'NOTIFICATION_RETRY_ATTEMPTS': 'NOTIFICATIONS',
|
||||
|
||||
@@ -312,7 +312,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
)
|
||||
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
secret = settings.CRYPTOBOT_WEBHOOK_SECRET or settings.CRYPTOBOT_API_TOKEN
|
||||
if secret:
|
||||
if not signature:
|
||||
return JSONResponse(
|
||||
|
||||
Reference in New Issue
Block a user