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:
Fringg
2026-04-22 04:05:46 +03:00
parent b71e58c8d2
commit 7be404b918
5 changed files with 88 additions and 18 deletions
@@ -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()
+16 -7
View File
@@ -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()
+5 -2
View File
@@ -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)
# Проверяем есть ли у пользователя скидки по периодам
+28 -7
View File
@@ -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(