fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal
- balance/platega: re-set FSM state after min/max validation errors, set state before pending_amount path, use balance_topup callback for back button - balance/main: set FSM state and payment_method in handle_topup_amount_callback for all providers before routing, use balance_topup callback in validation errors - payment/paypear: fix confirmation_url key (was 'url'), add fallback, store charged amount with commission for correct webhook amount comparison - tariff_purchase: redirect to active tariff list when current tariff is inactive (hidden trial after promo code activation) - cabinet/renewal: check tariff.is_active in both GET and POST endpoints to prevent hidden trial tariff periods from appearing
This commit is contained in:
@@ -57,7 +57,14 @@ async def get_renewal_options(
|
||||
return []
|
||||
|
||||
# Determine available periods
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# не показываем его периоды, используем стандартные
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
@@ -128,7 +135,12 @@ async def renew_subscription(
|
||||
detail=f'Cannot renew subscription with status: {_actual_status}',
|
||||
)
|
||||
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
@@ -491,12 +491,16 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer('Минимальная сумма пополнения: 1 ₽', reply_markup=get_back_keyboard(db_user.language))
|
||||
await message.answer(
|
||||
'Минимальная сумма пополнения: 1 ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer(
|
||||
'Максимальная сумма пополнения: 50,000 ₽', reply_markup=get_back_keyboard(db_user.language)
|
||||
'Максимальная сумма пополнения: 50,000 ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -509,7 +513,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽',
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -517,7 +521,7 @@ async def process_topup_amount(message: types.Message, db_user: User, state: FSM
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' '),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -593,6 +597,7 @@ async def handle_topup_amount_callback(
|
||||
|
||||
platega_method_code = int(method[len('platega_m') :])
|
||||
await state.update_data(payment_method='platega', platega_method=platega_method_code)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
elif method == 'platega':
|
||||
@@ -604,6 +609,7 @@ async def handle_topup_amount_callback(
|
||||
method_code = int(data.get('platega_method', 0)) if data else 0
|
||||
|
||||
if method_code > 0:
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
@@ -615,9 +621,12 @@ async def handle_topup_amount_callback(
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
# Стандартные методы через роутер
|
||||
elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
else:
|
||||
await state.update_data(payment_method=method)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method):
|
||||
await callback.answer('❌ Неизвестный способ оплаты', show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ async def _prompt_amount(
|
||||
# Если сумма уже известна (например, после быстрого выбора),
|
||||
# сразу создаём платеж и сбрасываем временное значение.
|
||||
await state.update_data(platega_pending_amount=None)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
@@ -294,8 +295,9 @@ async def process_platega_payment_amount(
|
||||
'PLATEGA_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
@@ -304,8 +306,9 @@ async def process_platega_payment_amount(
|
||||
'PLATEGA_AMOUNT_TOO_HIGH',
|
||||
'Максимальная сумма для оплаты через Platega: {amount}',
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
reply_markup=get_back_keyboard(db_user.language, callback_data='balance_topup'),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
return
|
||||
|
||||
try:
|
||||
|
||||
@@ -2142,6 +2142,31 @@ async def show_tariff_extend(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# показываем список доступных тарифов вместо продления скрытого
|
||||
if not tariff.is_active:
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
active_tariffs = [t for t in tariffs if not t.is_daily]
|
||||
if not active_tariffs:
|
||||
await callback.answer('Нет доступных тарифов для продления', show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
for t in active_tariffs:
|
||||
keyboard.append([InlineKeyboardButton(text=f'📦 {t.name}', callback_data=f'tariff_select:{t.id}')])
|
||||
keyboard.append([InlineKeyboardButton(text='◀️ Назад', callback_data='back_to_menu')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🔄 <b>Выберите тариф для продления</b>\n\n'
|
||||
'Для продления подписки необходимо выбрать тариф.\n'
|
||||
'Подписка будет обновлена с параметрами выбранного тарифа.',
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
|
||||
@@ -106,18 +106,30 @@ class PayPearPaymentMixin:
|
||||
)
|
||||
|
||||
confirmation = result.get('confirmation', {})
|
||||
payment_url = confirmation.get('url') if isinstance(confirmation, dict) else None
|
||||
payment_url = (
|
||||
(confirmation.get('confirmation_url') or confirmation.get('url'))
|
||||
if isinstance(confirmation, dict)
|
||||
else None
|
||||
)
|
||||
paypear_id = result.get('id')
|
||||
|
||||
if not payment_url:
|
||||
logger.error('PayPear API не вернул URL платежа', result=result)
|
||||
logger.error('PayPear API не вернул confirmation_url', result=result)
|
||||
return None
|
||||
|
||||
# PayPear может добавить комиссию к сумме — сохраняем фактическую сумму
|
||||
# для корректной проверки в webhook (amount включает комиссию)
|
||||
api_amount = result.get('amount', {})
|
||||
if isinstance(api_amount, dict) and api_amount.get('value') is not None:
|
||||
charged_kopeks = round(float(api_amount['value']) * 100)
|
||||
metadata['paypear_charged_kopeks'] = charged_kopeks
|
||||
|
||||
logger.info(
|
||||
'PayPear API: создан платеж',
|
||||
order_id=order_id,
|
||||
paypear_id=paypear_id,
|
||||
payment_url=payment_url,
|
||||
charged_kopeks=metadata.get('paypear_charged_kopeks'),
|
||||
)
|
||||
|
||||
# Срок действия — 30 минут по умолчанию
|
||||
@@ -239,6 +251,8 @@ class PayPearPaymentMixin:
|
||||
}
|
||||
|
||||
# Проверка суммы ДО обновления статуса
|
||||
# PayPear добавляет комиссию к amount — сравниваем с сохранённой суммой
|
||||
# (paypear_charged_kopeks), а не с исходной суммой пополнения
|
||||
if is_paid:
|
||||
amount_info = obj.get('amount', {})
|
||||
if isinstance(amount_info, dict):
|
||||
@@ -248,11 +262,14 @@ class PayPearPaymentMixin:
|
||||
|
||||
if amount_value is not None:
|
||||
received_kopeks = round(float(amount_value) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
|
||||
if abs(received_kopeks - expected_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
expected_kopeks=expected_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
original_amount_kopeks=payment.amount_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
@@ -539,16 +556,20 @@ class PayPearPaymentMixin:
|
||||
internal_status, is_paid = status_info
|
||||
|
||||
if is_paid:
|
||||
# Проверка суммы
|
||||
# Проверка суммы — сравниваем с paypear_charged_kopeks
|
||||
# (amount включает комиссию PayPear)
|
||||
amount_info = order_data.get('amount', {})
|
||||
api_amount = amount_info.get('value') if isinstance(amount_info, dict) else amount_info
|
||||
if api_amount is not None:
|
||||
received_kopeks = round(float(api_amount) * 100)
|
||||
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
expected_kopeks = payment_metadata.get('paypear_charged_kopeks', payment.amount_kopeks)
|
||||
if abs(received_kopeks - expected_kopeks) > 1:
|
||||
logger.error(
|
||||
'PayPear amount mismatch (API check)',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
expected_kopeks=expected_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
original_amount_kopeks=payment.amount_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await paypear_crud.update_paypear_payment_status(
|
||||
|
||||
Reference in New Issue
Block a user