Files
remnawave-bedolaga-telegram…/app/handlers/balance/heleket.py
T
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00

339 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import UTC, datetime
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
@error_handler
async def start_heleket_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await callback.message.edit_text(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n'
'Если вы считаете это ошибкой, вы можете обжаловать решение.',
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
return
if not settings.is_heleket_enabled():
await callback.answer('❌ Оплата через Heleket недоступна', show_alert=True)
return
markup = settings.get_heleket_markup_percent()
markup_text: str | None
if markup > 0:
label = texts.t('PAYMENT_HELEKET_MARKUP_LABEL', 'Наценка провайдера')
markup_text = f'{label}: {markup:.0f}%'
elif markup < 0:
label = texts.t('PAYMENT_HELEKET_DISCOUNT_LABEL', 'Скидка провайдера')
markup_text = f'{label}: {abs(markup):.0f}%'
else:
markup_text = None
message_lines = [
'🪙 <b>Пополнение через Heleket</b>',
'\n',
'Введите сумму пополнения от 100 до 100,000 ₽:',
'',
'⚡ Мгновенное зачисление',
'🔒 Безопасная оплата',
]
if markup_text:
message_lines.extend(['', markup_text])
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,
parse_mode='HTML',
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
payment_method='heleket',
heleket_prompt_message_id=callback.message.message_id,
heleket_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_heleket_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
await message.answer(
f'🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n'
'Если вы считаете это ошибкой, вы можете обжаловать решение.',
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode='HTML',
)
await state.clear()
return
if not settings.is_heleket_enabled():
await message.answer('❌ Оплата через Heleket недоступна')
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer('Минимальная сумма пополнения: 100 ₽')
return
if amount_rubles > 100000:
await message.answer('Максимальная сумма пополнения: 100,000 ₽')
return
payment_service = PaymentService(message.bot)
result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f'Пополнение баланса на {amount_rubles:.0f}',
language=db_user.language,
)
if not result:
await message.answer('❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку.')
await state.clear()
return
payment_url = result.get('payment_url')
if not payment_url:
await message.answer('❌ Не удалось получить ссылку для оплаты Heleket')
await state.clear()
return
payer_amount = result.get('payer_amount')
payer_currency = result.get('payer_currency')
result.get('exchange_rate')
discount_percent = result.get('discount_percent')
details = [
'🪙 <b>Оплата через Heleket</b>',
'',
f'💰 Сумма к зачислению: {amount_rubles:.0f}',
]
if payer_amount and payer_currency:
details.append(f'🪙 К оплате: {payer_amount} {payer_currency}')
markup_percent: float | None = None
if discount_percent is not None:
try:
discount_int = int(discount_percent)
markup_percent = -discount_int
except (TypeError, ValueError):
markup_percent = None
if markup_percent:
label_markup = texts.t('PAYMENT_HELEKET_MARKUP_LABEL', 'Наценка провайдера')
label_discount = texts.t('PAYMENT_HELEKET_DISCOUNT_LABEL', 'Скидка провайдера')
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f'📈 {label_markup}: +{absolute}%')
else:
details.append(f'📉 {label_discount}: {absolute}%')
if payer_amount and payer_currency:
try:
payer_amount_float = float(payer_amount)
if payer_amount_float > 0:
rub_per_currency = amount_rubles / payer_amount_float
details.append(f'💱 Курс: 1 {payer_currency}{rub_per_currency:.2f}')
except (TypeError, ValueError, ZeroDivisionError):
pass
details.extend(
[
'',
'📱 Инструкция:',
"1. Нажмите кнопку 'Оплатить'",
'2. Перейдите на страницу Heleket',
'3. Оплатите указанную сумму',
'4. Баланс пополнится автоматически',
]
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t('PAY_WITH_COINS_BUTTON', '🪙 Оплатить'), url=payment_url)],
[
types.InlineKeyboardButton(
text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'),
callback_data=f'check_heleket_{result["local_payment_id"]}',
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')],
]
)
state_data = await state.get_data()
prompt_message_id = state_data.get('heleket_prompt_message_id')
prompt_chat_id = state_data.get('heleket_prompt_chat_id', message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning('Не удалось удалить сообщение с суммой Heleket', delete_error=delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning('Не удалось удалить сообщение с запросом суммы Heleket', delete_error=delete_error)
invoice_message = await message.answer('\n'.join(details), parse_mode='HTML', reply_markup=keyboard)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_heleket_payment_by_id(db, result['local_payment_id'])
if payment:
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
metadata['invoice_message'] = {
'chat_id': invoice_message.chat.id,
'message_id': invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.now(UTC))
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning('Не удалось сохранить сообщение Heleket', error=error)
await state.update_data(
heleket_invoice_message_id=invoice_message.message_id,
heleket_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
@error_handler
async def check_heleket_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
) -> None:
try:
local_payment_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer('Некорректный идентификатор платежа', show_alert=True)
return
from app.database.crud.heleket import get_heleket_payment_by_id
payment = await get_heleket_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer('Платёж не найден', show_alert=True)
return
language = getattr(payment.user, 'language', None) or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
if payment.is_paid:
message = texts.t('HELEKET_PAYMENT_ALREADY_PAID', '✅ Платёж уже зачислен')
await callback.answer(message, show_alert=True)
return
payment_service = PaymentService(callback.bot)
updated_payment = await payment_service.sync_heleket_payment_status(
db,
local_payment_id=local_payment_id,
)
if updated_payment:
payment = updated_payment
if payment.is_paid:
message = texts.t('HELEKET_PAYMENT_SUCCESS', '✅ Платёж зачислен на баланс')
await callback.answer(message, show_alert=True)
return
status_normalized = (payment.status or '').lower()
status_messages = {
'check': texts.t('HELEKET_STATUS_CHECK', '⏳ Ожидание оплаты'),
'process': texts.t('HELEKET_STATUS_PROCESS', '⚙️ Платёж обрабатывается'),
'confirm_check': texts.t('HELEKET_STATUS_CONFIRM_CHECK', '⛓ Ожидание подтверждений сети'),
'wrong_amount': texts.t('HELEKET_STATUS_WRONG_AMOUNT', '❗️ Оплачена неверная сумма'),
'wrong_amount_waiting': texts.t(
'HELEKET_STATUS_WRONG_AMOUNT_WAITING',
'❗️ Недостаточная сумма, ожидаем доплату',
),
'paid_over': texts.t('HELEKET_STATUS_PAID_OVER', '✅ Платёж зачислен (с переплатой)'),
'paid': texts.t('HELEKET_STATUS_PAID', '✅ Платёж зачислен'),
'cancel': texts.t('HELEKET_STATUS_CANCEL', '🚫 Платёж отменён'),
'fail': texts.t('HELEKET_STATUS_FAIL', '❌ Ошибка при оплате'),
'system_fail': texts.t('HELEKET_STATUS_SYSTEM_FAIL', '❌ Системная ошибка Heleket'),
'refund_process': texts.t('HELEKET_STATUS_REFUND_PROCESS', '↩️ Возврат обрабатывается'),
'refund_fail': texts.t('HELEKET_STATUS_REFUND_FAIL', '⚠️ Ошибка возврата'),
'refund_paid': texts.t('HELEKET_STATUS_REFUND_PAID', '✅ Возврат выполнен'),
'locked': texts.t('HELEKET_STATUS_LOCKED', '🔒 Средства заблокированы'),
}
message = status_messages.get(status_normalized)
if message is None:
template = texts.t('HELEKET_STATUS_UNKNOWN', '️ Статус платежа: {status}')
status_value = payment.status or status_normalized or ''
try:
message = template.format(status=status_value)
except Exception: # pragma: no cover - defensive formatting
message = f'️ Статус платежа: {status_value}'
await callback.answer(message, show_alert=True)