Реализация Billing Retry Logic при неудачном платеже подписки
Платёж по подписке упал с кодом insufficient_funds или card_declined. Что дальше? Если сразу заблокировать пользователя — он уйдёт. Если бесконечно повторять без логики — банк пометит карту как скомпрометированную. Правильная retry-стратегия балансирует между возвратом денег и сохранением пользователя.
Умная экспоненциальная задержка
Стандарт де-факто для retry — экспоненциальный backoff с jitter:
import random
from datetime import datetime, timedelta
RETRY_SCHEDULE = [
timedelta(hours=1), # Попытка 2: через 1 час
timedelta(hours=24), # Попытка 3: через 1 день
timedelta(days=3), # Попытка 4: через 3 дня
timedelta(days=7), # Попытка 5: через 7 дней
]
def schedule_next_retry(subscription_id: str, attempt: int) -> datetime | None:
if attempt >= len(RETRY_SCHEDULE):
# Исчерпаны все попытки — переводим в grace period или отменяем
return None
base_delay = RETRY_SCHEDULE[attempt]
jitter = timedelta(minutes=random.randint(-30, 30))
next_attempt_at = datetime.utcnow() + base_delay + jitter
db.update_subscription_retry(
subscription_id=subscription_id,
next_retry_at=next_attempt_at,
attempt_number=attempt + 1
)
return next_attempt_at
Jitter важен: без него все подписки, упавшие одновременно (например, при сбое эквайера), будут повторяться синхронно и создадут пиковую нагрузку.
Классификация ошибок: что retry-ить, что нет
Не все коды ошибок Stripe одинаково полезны для retry:
| Код ошибки | Retry | Причина |
|---|---|---|
insufficient_funds |
Да | Средства появятся |
card_declined (generic) |
Да | Временный отказ банка |
do_not_honor |
Да, с задержкой | Временная блокировка |
stolen_card |
Нет | Карта заблокирована навсегда |
card_velocity_exceeded |
Да, через 24ч | Лимит операций |
expired_card |
Нет | Нужна новая карта |
incorrect_cvc |
Нет | Пользователь ввёл неверно |
NON_RETRYABLE_CODES = {
'card_declined': ['stolen_card', 'lost_card', 'fraudulent'],
'incorrect_cvc': None,
'expired_card': None,
'invalid_account': None,
}
def should_retry(stripe_error: dict) -> bool:
code = stripe_error.get('code', '')
decline_code = stripe_error.get('decline_code', '')
if code in NON_RETRYABLE_CODES:
blocked = NON_RETRYABLE_CODES[code]
if blocked is None or decline_code in blocked:
return False
return True
Grace Period: пользователь не теряет доступ немедленно
После первого неудачного платежа — не блокируем, а даём grace period (обычно 3-7 дней):
def handle_payment_failure(subscription_id: str, error: dict):
subscription = db.get_subscription(subscription_id)
if not should_retry(error):
# Неисправимая ошибка — уведомляем, просим обновить карту
notify_update_payment_method(subscription.user_id)
db.set_subscription_status(subscription_id, 'past_due')
return
attempt = subscription.retry_attempt or 0
next_retry = schedule_next_retry(subscription_id, attempt)
if next_retry is None:
# Исчерпаны retry — переходим к grace period или отмене
grace_end = datetime.utcnow() + timedelta(days=3)
db.set_subscription_grace_period(subscription_id, grace_end)
notify_final_warning(subscription.user_id, grace_end)
else:
db.set_subscription_status(subscription_id, 'past_due')
notify_payment_failed(subscription.user_id, next_retry, attempt + 1)
Stripe: автоматический Smart Retries
Stripe предоставляет встроенный механизм — Smart Retries — который использует ML для выбора оптимального времени повтора на основе паттернов успешных платежей. Включается в Dashboard → Billing → Subscriptions → Smart Retries.
Но Smart Retries не заменяет вашу бизнес-логику: Stripe не знает, сколько дней вы готовы давать grace period и какие уведомления отправлять пользователю.
Если используете Stripe Billing, подписывайтесь на webhook-события:
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
raise HTTPException(400)
match event['type']:
case 'invoice.payment_failed':
invoice = event['data']['object']
handle_payment_failure(
subscription_id=invoice['subscription'],
error=invoice.get('last_payment_error', {})
)
case 'invoice.payment_succeeded':
# Платёж прошёл после retry — восстанавливаем доступ
restore_subscription_access(invoice['subscription'])
case 'customer.subscription.deleted':
# Подписка окончательно отменена после всех попыток
handle_subscription_cancelled(invoice['subscription'])
Уведомления пользователю
Серия уведомлений важна: 42% пользователей обновляют платёжные данные после первого напоминания. Push через FCM/APNs + email — обязательная комбинация.
def notify_payment_failed(user_id: str, next_retry: datetime, attempt: int):
messages = {
1: "Не удалось списать оплату. Повторим попытку {date}.",
2: "Вторая попытка оплаты не прошла. Обновите карту или попробуем {date}.",
3: "Последняя попытка — {date}. После этого доступ будет ограничен."
}
template = messages.get(attempt, messages[3])
send_push(user_id, template.format(date=next_retry.strftime("%d.%m в %H:%M")))
send_email(user_id, subject="Проблема с оплатой подписки", body=template)
Сроки
2–3 дня. Логика retry с классификацией ошибок + grace period + webhook-обработка — 2 дня. Серия уведомлений + тестирование всех сценариев — 0,5–1 день. Стоимость рассчитывается индивидуально.







