Merge pull request #2767 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-18 18:22:52 +03:00
committed by GitHub
27 changed files with 103 additions and 814 deletions
-5
View File
@@ -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
+1 -1
View File
@@ -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
-6
View File
@@ -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']
+6 -11
View File
@@ -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:
+27 -10
View File
@@ -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)
+3 -123
View File
@@ -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
)
+9 -32
View File
@@ -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)
+1 -125
View File
@@ -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')
-7
View File
@@ -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,
+1 -82
View File
@@ -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
View File
@@ -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')
-7
View File
@@ -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,
-7
View File
@@ -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,
-7
View File
@@ -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,
+1 -73
View File
@@ -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,
)
+1 -73
View File
@@ -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,
)
+1 -15
View File
@@ -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(
-7
View File
@@ -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,
+8 -44
View File
@@ -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)
+6
View File
@@ -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
# Проверяем тариф подписки
+3 -3
View File
@@ -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
-3
View File
@@ -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')
+15 -14
View File
@@ -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
+1 -7
View File
@@ -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:
-1
View File
@@ -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',
+1 -1
View File
@@ -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(
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.32.4"
version = "3.33.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },