Платежный код обычно выглядит ровным ровно до первого реального сбоя. Пока платежи идут по ожидаемому сценарию, кажется, что достаточно создать оплату, дождаться вебхука и обновить локальный статус. Но как только вебхук приходит повторно, приходит позже нужного, прилетает от не того IP, или удаленный платеж уже живет в одном статусе, а локальная база в другом, становится ясно, что платежный контур без защит почти всегда врет.
Проблема в том, что вебхук нельзя считать истиной без проверки, нельзя применять без журнала событий, нельзя подтверждать capture случайным ключом, и нельзя оставлять систему без аварийного пути, если автоматический сценарий где-то разошелся.
В одном из проектов этот узел был собран так, первый платеж создается с capture=False, входящий webhook проверяется по IP, каждое событие сначала пишется в журнал, потом маршрутизируется в обработчик, capture подтверждается стабильным idempotence key, успешный платеж валидируется по сумме, валюте и metadata, а на случай расхождения остается отдельный ручной confirm, который умеет дочитать фактический статус из ЮKassa и синхронизировать локальную базу.
То есть задача тут не просто принять webhook, а построить платежный контур, которому можно верить.
Где обычно ломается наивная схема
Самый короткий путь выглядит так. Фронт создает платеж, ЮKassa присылает webhook, backend помечает запись как успешную. На демо этого достаточно. В живом продукте нет.
Webhook может прийти несколько раз. Может прийти позже. Может прийти в статусе waiting_for_capture, а не succeeded. Может быть повторная доставка того же payment.succeeded. Может быть внешний сбой в момент между получением webhook и обновлением локальной записи. Может случиться так, что удаленный платеж уже успешно завершен, а локальная база об этом не знает.
Если в такой схеме нет отдельного журнала событий, нет проверки, что webhook вообще пришел от ЮKassa, нет идемпотентного capture и нет ручного rescue-пути, платежный контур быстро становится непрозрачным. В админке одно, в API другое, у пользователя третье.
Локальная модель платежа и отдельный журнал событий
Для начала полезно разделить две сущности. Первая отвечает за сам платеж и его жизненный цикл. Вторая отвечает за журнал входящих событий.
В проекте платеж хранится в KassaPayment, а журнал событий в PaymentEventLog.
# payment/models.pyclass KassaPayment(models.Model): user = models.ForeignKey(User, related_name='kassa_payments', on_delete=models.CASCADE) kassa_payment_id = models.CharField(max_length=250, blank=True, null=True) amount = models.DecimalField(max_digits=10, decimal_places=2) subscription_type = models.CharField( choices=[('monthly', 'Monthly'), ('yearly', 'Yearly'), ('forever', 'Forever')], max_length=10 ) status = models.CharField( choices=[('pending', 'Pending'), ('completed', 'Completed'), ('failed', 'Failed'), ('refund', 'Refund'), ('refund_failed', 'Refund Failed')], max_length=20, default='pending' ) kassa_payment_status = models.CharField( choices=[('waiting_for_capture', 'Waiting for capture'), ('succeeded', 'Succeeded'), ('failed', 'Failed'), ('canceled', 'Canceled'), ('refund_succeeded', 'Refund Succeeded')], max_length=20, default='waiting_for_capture' ) income_amount = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True) capture_idem_key = models.CharField(max_length=64, blank=True, null=True)class PaymentEventLog(models.Model): event_id = models.CharField(max_length=64, db_index=True) event_type = models.CharField(max_length=64, db_index=True) payload = models.JSONField() received_at = models.DateTimeField(auto_now_add=True) applied = models.BooleanField(default=False) note = models.TextField(blank=True, null=True)
Это разделение дает сразу несколько профитов. Сам платеж хранит итоговое локальное состояние. Журнал хранит каждый входящий webhook как отдельный факт. Даже если событие дублируется, журнал это покажет. Даже если обработчик не смог применить изменения, сам факт прихода не теряется. Потом это делает расследование сбоев чтением истории.
Почему первый платеж лучше разделять на waiting_for_capture и succeeded
Первый платеж создается не как мгновенно подтвержденный, а как capture=False. То есть проект сначала получает авторизацию, а затем уже отдельно делает capture.
В коде это видно в сборке платежных данных:
# payment/utils.pydef create_payment_data(amount, description, local_payment_id, subscription_type, receipt, payment_method_id=None): save_pm = (subscription_type in ('monthly', 'yearly')) data = { "amount": {"value": str(amount), "currency": "RUB"}, "capture": False, "confirmation": {"type": "redirect", "return_url": f"{settings.FRONT_URL}/payment/success"}, "description": description, "receipt": receipt, "metadata": {"payment_id": local_payment_id, "subscription_type": subscription_type}, } if save_pm: data["save_payment_method"] = True if payment_method_id: data["payment_method_id"] = payment_method_id data.pop("confirmation", None) data["capture"] = True return data
Для первого платежа это нужно по двум причинам. Во-первых, появляется контролируемая точка между авторизацией и окончательным захватом средств. Во-вторых, webhook payment.waiting_for_capture становится отдельным этапом, который можно безопасно и идемпотентно обработать.
Если свалить waiting_for_capture и succeeded в одну кучу, система теряет один важный слой контроля. Снаружи кажется, что статусов просто два. На практике это два разных этапа с разной бизнес-логикой.
Capture нельзя подтверждать случайным ключом
Когда приходит payment.waiting_for_capture, backend должен подтвердить платеж. И вот здесь часто появляется проблема. Если делать capture с новым случайным idempotence_key на каждом повторе, то повторная доставка одного и того же webhook уже не выглядит идемпотентной.
В проекте для этого используется устойчивый ключ вида capture:{payment_id} и он хранится в самой записи платежа.
# payment/utils.pydef confirm_payment_in_kassa(payment_id, amount=None): try: kp = KassaPayment.objects.filter(kassa_payment_id=payment_id).first() if kp: if not kp.capture_idem_key: kp.capture_idem_key = f"capture:{payment_id}" kp.save(update_fields=["capture_idem_key"]) value = str(kp.amount) idem_key = kp.capture_idem_key else: if amount is None: return None value = str(amount) idem_key = f"capture:{payment_id}" resp = Payment.capture( payment_id, {"amount": {"value": value, "currency": "RUB"}}, idem_key, ) return resp except Exception: return None
Небольшая деталь решает важную задачу. Один и тот же удаленный платеж всегда подтверждается одним и тем же ключом. Значит, повторные вызовы не создают новый смысл, а повторяют старый.
Вокруг платежей идемпотентность нужна как конкретная страховка от повторной доставки и повторного запуска обработки.
Webhook нужно фильтровать до бизнес-логики
Следующий слой доверия начинается еще до разбора payload. Вебхук сначала должен пройти IP-check. Для локальной разработки это можно ослабить, для продакшена нет.
# payment/utils.pydef is_valid_webhook_signature(request): ALLOWED_IP_RANGES = [ '185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.156.11', '77.75.156.35', '77.75.154.128/25', '2a02:5180::/32', ] if getattr(settings, 'DJANGO_ENV', 'local') == 'local': return True raw_xff = (request.META.get('HTTP_X_FORWARDED_FOR') or '').strip() x_real = (request.META.get('HTTP_X_REAL_IP') or '').strip() remote = (request.META.get('REMOTE_ADDR') or '').strip() ip = raw_xff.split(',')[0].strip() if raw_xff else (x_real or remote) if ip.startswith('::ffff:'): ip = ip[7:] try: ip_obj = ipaddress.ip_address(ip) except ValueError: return False for rng in ALLOWED_IP_RANGES: if '/' in rng and ip_obj in ipaddress.ip_network(rng, strict=False): return True if ip == rng: return True return False
Если входящий запрос еще не прошел сетевую проверку, его не надо считать нормальным webhook и тащить в бизнес-обработку. Что подтверждается и тестами проекта. В прод-режиме запрос без валидного IP получает 403, а запись в PaymentEventLog даже не создается. То есть до журнала событий доходит уже только то, что прошло минимальный фильтр доверия.
Журналировать лучше до применения и после него
После IP-check webhook не надо сразу применять к базе. Сначала лучше записать входящее событие как факт. Потом передать его в обработчик. Потом уже отметить, применилось оно или нет.
В проекте webhook устроен именно так:
# payment/views.py@csrf_exempt@api_view(['POST'])@permission_classes([AllowAny])def kassa_webhook(request): if not is_valid_webhook_signature(request): return Response(status=403) try: data = json.loads(request.body.decode('utf-8')) except Exception: return Response(status=400) event = (data.get('event') or '').strip() obj = data.get('object', {}) or {} obj_id = obj.get('id') or '' log = PaymentEventLog.objects.create( event_id=obj_id, event_type=event, payload=data, applied=False, note=None, ) if event == 'payment.waiting_for_capture': resp = webhook_waiting_for_capture(data) elif event == 'payment.succeeded': resp = webhook_succeeded(data) elif event == 'payment.canceled': resp = webhook_canceled(data) elif event == 'refund.succeeded': resp = webhook_refund(data) else: PaymentEventLog.objects.filter(pk=log.pk).update(note='unknown_event') return Response(status=200) status_code = getattr(resp, 'status_code', 200) if status_code == 200: PaymentEventLog.objects.filter(pk=log.pk).update(applied=True) else: PaymentEventLog.objects.filter(pk=log.pk).update(note=f'handler_status={status_code}') return resp
Это дает удобную двухшаговую картину. Сначала видно, что событие пришло. Потом видно, удалось ли его применить. Если нет, остается note. Если да, applied=True.
Для платежных систем это лучше, чем просто логировать в stdout. В админке и базе появляется история, которую можно читать и анализировать.
waiting_for_capture и succeeded должны жить в разных обработчиках
После маршрутизации у каждого статуса своя работа. Для payment.waiting_for_capture задача проста: проверить, что локальный платеж еще действительно находится в waiting_for_capture, и только после этого делать идемпотентный capture.
# payment/hooks.pydef webhook_waiting_for_capture(data): obj = data.get('object', {}) or {} payment_id = obj.get('id') amount_value = (obj.get('amount', {}) or {}).get('value') if not payment_id: return Response(status=200) kp = KassaPayment.objects.filter(kassa_payment_id=payment_id).first() if not kp: return Response(status=200) if kp.kassa_payment_status != 'waiting_for_capture': return Response(status=200) if confirm_payment_in_kassa(payment_id, amount_value): return Response(status=200) return Response({"error": "Error confirming payment"}, status=500)
Эта проверка на текущий локальный статус важна. Если webhook пришел повторно или с опозданием, система не должна повторно подтверждать то, что уже ушло дальше по цепочке.
Для payment.succeeded логика уже другая. Тут нельзя просто поставить completed и разойтись. Сначала полезно убедиться, что сумма совпадает, валюта ожидаемая, а metadata указывает на правильную локальную запись.
# payment/hooks.pydef webhook_succeeded(data): obj = data.get('object', {}) or {} payment_id = obj.get('id') amount_obj = obj.get('amount', {}) or {} amount_value = amount_obj.get('value') currency = amount_obj.get('currency') meta = obj.get('metadata', {}) or {} kp = KassaPayment.objects.filter(kassa_payment_id=payment_id).first() if not kp: return Response(status=200) if kp.kassa_payment_status in ('succeeded', 'canceled', 'refund_succeeded'): return Response(status=200) amt = Decimal(str(amount_value)) if amt != kp.amount: return Response(status=200) if currency != 'RUB': return Response(status=200) if str(meta.get('payment_id')) != str(kp.id): return Response(status=200) kassa_resp = KPayment.find_one(payment_id) updated = update_payment_status(payment_id, kassa_resp)
Эти проверки не дают webhook превратиться в слепой триггер. До тех пор пока сумма, валюта и metadata не совпали с локальной записью, применять успех нельзя.
Успешный webhook должен синхронизировать не только платеж, но и подписку
После того как платеж синхронизирован локально, monthly и yearly-планы требуют еще одного шага. Нужно либо создать подписку, либо обновить существующую. Причем только если ЮKassa вернула сохраненный payment_method_id, пригодный для автосписаний.
В проекте этот контур выглядит так:
# payment/hooks.pysub_type = (meta.get('subscription_type') or '').lower()if updated and sub_type in ('monthly','yearly'): pm = getattr(kassa_resp, 'payment_method', None) pm_id = getattr(pm, 'id', None) pm_saved = getattr(pm, 'saved', False) if pm_id and pm_saved: sub, _ = Subscription.objects.get_or_create( user=kp.user, plan=sub_type, defaults={ 'status': 'active', 'amount': kp.amount, 'currency': 'RUB', 'payment_method_id': pm_id, 'fails_count': 0, 'last_payment_id': kp.kassa_payment_id, } ) sub.payment_method_id = pm_id sub.amount = kp.amount sub.currency = 'RUB' sub.status = 'active' sub.fails_count = 0 sub.last_payment_id = kp.kassa_payment_id sub.schedule_next() sub.save() else: kp.information_payment = (kp.information_payment or '') + " [autopay=off]" kp.save(update_fields=['information_payment'])
Сначала локально фиксируется успешный платеж. Потом поднимается или обновляется подписка. Потом сохраняется payment_method_id, если он действительно сохранен в ЮKassa. Если метод оплаты не сохранен, это не повод валить платеж, но это повод явно отметить, что автосписаний не будет.
Такой контур потом дает нормальную основу для recurring.
Отдельный аварийный confirm
Даже если автоматический сценарий выстроен хорошо, платежный контур желательно снабдить аварийным ручным слоем. Например, webhook мог потеряться по дороге, локальная запись могла не синхронизироваться, capture мог зависнуть в промежуточном состоянии.
Для этого в проекте есть отдельный admin endpoint, который делает три вещи. Сначала читает фактический статус удаленного платежа из ЮKassa. Потом, если там waiting_for_capture, выполняет идемпотентный capture. Если там уже succeeded, просто синхронизирует локальную запись. Во всех остальных случаях не ломает систему, а возвращает реальный удаленный статус.
# payment/views.py@api_view(['POST'])@permission_classes([IsAdminUser])def confirm_payment(request): payment_id = request.data.get('payment_id') if not payment_id: return Response({"error": "payment_id required"}, status=400) kp = KassaPayment.objects.filter(kassa_payment_id=payment_id).first() if not kp: return Response({"error": "Payment not found in DB"}, status=404) remote = Payment.find_one(payment_id) remote_status = getattr(remote, "status", None) if remote_status == "waiting_for_capture": capture_resp = confirm_payment_in_kassa(payment_id, kp.amount) if not capture_resp: return Response({"error": "Kassa capture error"}, status=502) if update_payment_status(payment_id, capture_resp): return Response({"status": "captured_and_synced"}, status=200) if remote_status == "succeeded": if update_payment_status(payment_id, remote): return Response({"status": "already_captured_synced"}, status=200) return Response({"status": "not_capturable", "remote_status": remote_status}, status=409)
Слой не нужен пользователю. Он нужен системе как страховочный контур. Хороший платежный поток должен уметь не только идти по основному пути, но и восстанавливаться после расхождений.
Локальная рекуррентка тоже должна жить по предсказуемым правилам
После первого успешного платежа monthly и yearly-подписки получают payment_method_id, а дальше могут списываться автоматически. В проекте для этого есть отдельная команда charge_subscriptions, которая ищет активные подписки с наступившим next_charge_at, создает локальный платеж и отправляет рекуррентный запрос в ЮKassa.
Интересно здесь то, что и этот контур тоже опирается на идемпотентность и на webhook-синхронизацию. Для рекуррентного платежа строится стабильный ключ из sub:{subscription_id}:{due_key}:{fingerprint}. Если удаленный статус уже сразу succeeded, локальная запись синхронизируется немедленно. Если нет, система спокойно ждет webhook.
# payment/management/commands/charge_subscriptions.pypayload_fingerprint = hashlib.sha1( json.dumps(payment_data, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]due_key = (s.next_charge_at or now).isoformat()idem = f"sub:{s.id}:{due_key}:{payload_fingerprint}"remote = KPayment.create(payment_data, idem)if getattr(remote, "status", None) == "succeeded": update_payment_status(remote.id, remote) s.fails_count = 0 s.schedule_next() s.save(update_fields=['fails_count', 'next_charge_at', 'updated_at'])else: # ждём вебхуков ...
Даже рекуррентка не делает вид, что у нее всегда один успешный сценарий. Она умеет либо синхронизировать успех сразу, либо ждать нормальное подтверждение по webhook.
Почему журнал событий лучше простого print
Иногда кажется, что для webhook достаточно принтов и серверных логов. Но как только нужен ответ на вопрос приходило ли событие вообще, пришло ли повторно, применилось ли оно, по какому id, с каким payload, такие логи быстро становятся неудобными.
PaymentEventLog хорош тем, что хранит не только факт прихода, но и этап применения. Повторная доставка одного и того же payment.succeeded не размножает подписку, но пишет оба события в журнал. Вебхук с неверным IP режется до создания записи. Это и есть база доверия к платежному контуру.
В таких вещах надежность обычно строится не одной большой идеей, а десятком небольших ограничений, каждое из которых убирает свой класс сбоев.
Надежность контура
Надежность появляется как несколько последовательных слоев защиты. Сначала IP-check. Потом pre-apply журналирование. Потом маршрутизация по типу события. Потом идемпотентный capture для waiting_for_capture. Потом валидация суммы, валюты и metadata для payment.succeeded. Потом синхронизация локального платежа и подписки. Потом аварийный ручной confirm, если автоматика разошлась с фактическим состоянием в ЮKassa.
Каждый слой по отдельности выглядит простым. Вместе они превращают платежный контур из оптимистичной интеграции в систему, которой можно верить.
Если этого нет, продукт легко живет в режиме локально кажется успешным, пока первый дубль webhook или первый подвисший capture не покажет обратное.
Для примеров в статье использован живой проект AI-Chat. Отдельная витрина на GitHub Pages пингует основной проект на спящем Render и содержит переход в рабочее приложение. В последовательной сборке с фронтом, бэкендом и их стыковкой этот контур можно посмотреть на Stepik курсе LLM-Chat + Mermaid + ЮKassa.
ссылка на оригинал статьи https://habr.com/ru/articles/1044796/