Реалізація Billing Retry Logic при неудачному платеже підписки
Платіж по підписці упав з кодом insufficient_funds або card_declined. Що дальше? Заблокувати користувача — він уйде. Нескінченно повторювати — банк позначить карту як скомпрометовану. Правильна retry-стратегія балансує між поверненням грошей та збереженням користувача.
Розумна експоненціальна затримка з jitter
Стандарт де-факто для 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-ітu, що ні
Не всі коди помилок 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 день. Вартість розраховується індивідуально.







