fix(payments): drop the notification sender-IP gate; re-check on its own cadence
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s

A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.

The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.

The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.

Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.

The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.

Decisions D48 and D49 revised.
This commit is contained in:
Ilia Denisov
2026-07-28 12:00:03 +02:00
parent 12e616ceae
commit 4cac09c9f3
12 changed files with 174 additions and 155 deletions
+19 -9
View File
@@ -261,16 +261,26 @@ which is what later lets the order be re-checked and refunded. The browser retur
nothing in the body may be acted on. It only names a payment, which the server then re-reads with
`GET /v3/payments/{id}`; only that answer is trusted. Two guards ride on it: the payment's metadata
must name the order being credited, and its `test` flag must match the shop's, so a test-shop payment
can never credit real chips (nor a live payment be credited against test credentials). As defence in
depth — and to stop a forger turning each fabricated notification into an outbound call of ours — the
sender address is first checked against YooKassa's published ranges. The reply tells the provider
whether to redeliver: 200 for anything durably decided, including a duplicate and a permanent
rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
can never credit real chips (nor a live payment be credited against test credentials). The reply
tells the provider whether to redeliver: 200 for anything durably decided, including a duplicate and
a permanent rejection, and 5xx only for a transient failure (YooKassa redelivers for 24 hours).
**Expiry-time reconciliation (D49).** No continuous polling. But a notification that is lost for good
would leave the money taken and the chips unowed, silently, so the existing pending-order reaper asks
the provider what became of each order that reached its expiry age carrying a payment id, and credits
the ones that were in fact paid. One request per order over its whole life.
The sender address is **not** checked (D48 rev). It would add no security — the confirming read is
the whole boundary — and the one thing it bought, keeping a forger from turning each fabricated
notification into an outbound call of ours, is already bought earlier and far more tightly: the order
is resolved from the notification's metadata **before** any provider call, so an id matching no order
costs one indexed read and stops there, and guessing a live order id means guessing a uuid. An
address check is also actively harmful wherever the deployment cannot observe real client addresses —
a contour behind a tunnel sees only its own — where it rejects genuine notifications.
**Reconciliation on a short cadence (D49 rev).** No open-ended polling, but the check is **not** tied
to the order lifetime: that governs when an unpaid order is written off, which answers "how long may
a customer take to pay" — a different question from "how soon should we notice a lost callback".
Tying them together would make a customer wait a full lifetime for chips whenever the notification
path fails. The pending-order reaper instead asks the provider about every pending order older than a
minute that carries a payment id, and credits the ones that were in fact paid. That bounds the calls
one order can cause to its lifetime divided by the sweep interval — a handful — while a failure of
the primary path costs minutes rather than half an hour.
**A declined payment is surfaced.** A YooKassa `payment.canceled` records a `failed` payment event,
so the customer is told the attempt did not go through instead of watching a balance that never
+19 -8
View File
@@ -296,16 +296,27 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
уведомления — только подсказка: оно называет платёж, который сервер перечитывает запросом
`GET /v3/payments/{id}`, и действует **исключительно** по ответу API. Дополнительно: метаданные
платежа должны называть тот самый заказ; флаг `test` платежа должен совпадать с флагом магазина
(платёж тестового магазина не начислит настоящие Фишки, и наоборот); адрес отправителя сверяется с
опубликованными диапазонами ЮKassa — эшелонированная защита, которая не даёт превратить каждое
фальшивое уведомление в наш исходящий запрос. Ответ провайдеру: 200 на всё окончательно решённое
(включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa повторяет 24 часа).
- **D49. Сверка — одна проверка на истечении заказа, без постоянного опроса.** «Или» в документации
(платёж тестового магазина не начислит настоящие Фишки, и наоборот). Ответ провайдеру: 200 на всё
окончательно решённое (включая дубль и неустранимый отказ), 5xx только на временный сбой (ЮKassa
повторяет 24 часа).
**Ревизия: адрес отправителя не проверяем.** Изначально сверяли с опубликованными диапазонами
ЮKassa как эшелонированную защиту. Оказалось лишним и вредным: единственное, что она давала —
не дать подделывателю превратить фальшивое уведомление в наш исходящий запрос — уже обеспечено
раньше и жёстче, потому что заказ ищется по метаданным ДО обращения к провайдеру (несуществующий
order_id стоит одного чтения по индексу; угадать живой — значит угадать uuid). А на тестовом
контуре, который за туннелем видит только свой внутренний адрес, проверка отбивала настоящие
уведомления — ровно как IP-баны, сделанные в репозитории prod-only по той же причине.
- **D49. Сверка — без постоянного опроса, но с коротким шагом (ревизия).** «Или» в документации
ЮKassa адресовано тем, кто не хочет вебхуки; у нас вебхуки основные. Но безвозвратно потерянное
уведомление оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому существующий
жнец просроченных заказов перед списанием в `expired` спрашивает провайдера о судьбе каждого заказа,
дожившего до срока с идентификатором платежа, и начисляет реально оплаченные. Один запрос на заказ
за всю его жизнь; отдельный воркер не заводим.
жнец спрашивает провайдера о судьбе незакрытых заказов с идентификатором платежа и начисляет реально
оплаченные; отдельный воркер не заводим.
**Ревизия порога:** сперва проверка была привязана к возрасту истечения заказа (30 минут), и это
была ошибка — время жизни заказа отвечает на вопрос «сколько покупателю позволено думать», а не «как
быстро заметить потерянный колбэк». На практике это дало покупателю 30 минут ожидания Фишек при
первом же сбое доставки. Порог развязан: проверяем заказы старше **минуты**, на каждом тике жнеца.
Ограниченность сохраняется — запросов на заказ не больше, чем время его жизни, делённое на шаг
жнеца.
- **D50. Возвраты на direct-рельсе — через API ЮKassa, одним действием.** Кнопка возврата в `/_gm`
сперва двигает деньги (`POST /v3/refunds`, `Idempotence-Key` = идентификатор заказа, с чеком
возврата) и только потом пишет реверс в журнал; неудачный вызов не пишет **ничего** — журнал не
+18 -10
View File
@@ -254,17 +254,25 @@ durable).
действовать по содержимому тела нельзя. Оно лишь называет платёж, который сервер затем перечитывает
запросом `GET /v3/payments/{id}`; доверяем только этому ответу. На нём же держатся две проверки:
метаданные платежа должны называть тот самый заказ, а его флаг `test` — совпадать с флагом магазина,
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). В качестве
эшелонированной защиты — и чтобы подделыватель не превращал каждое фальшивое уведомление в наш
исходящий запрос — адрес отправителя сперва сверяется с опубликованными диапазонами ЮKassa. Ответ
сообщает провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и
неустранимый отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
поэтому платёж тестового магазина никогда не начислит настоящие Фишки (и наоборот). Ответ сообщает
провайдеру, повторять ли доставку: 200 на всё, что решено окончательно, включая дубль и неустранимый
отказ, и 5xx только на временный сбой (ЮKassa повторяет доставку 24 часа).
**Сверка на истечении заказа (D49).** Постоянного опроса нет. Но безвозвратно потерянное уведомление
оставило бы деньги списанными, а Фишки — не выданными, и молча. Поэтому уже существующий жнец
просроченных заказов спрашивает у провайдера судьбу каждого заказа, который дожил до своего срока с
идентификатором платежа, и начисляет те, что на самом деле оплачены. Один запрос на заказ за всю его
жизнь.
Адрес отправителя **не проверяем** (ревизия D48). Безопасности это не добавляет — вся граница доверия
в подтверждающем чтении, — а единственное, что такая проверка давала (чтобы подделыватель не
превращал каждое фальшивое уведомление в наш исходящий запрос), уже обеспечено раньше и жёстче: заказ
находится по метаданным **до** любого обращения к провайдеру, поэтому идентификатор, которому не
соответствует ни один заказ, стоит одного чтения по индексу и на этом всё, а угадать живой order_id —
значит угадать uuid. Вдобавок проверка адреса вредна везде, где развёртывание не видит настоящих
адресов клиентов (контур за туннелем видит только свой), — там она отбивает настоящие уведомления.
**Сверка с коротким шагом (ревизия D49).** Бесконечного опроса нет, но проверка **не привязана** к
времени жизни заказа: оно отвечает на вопрос «сколько покупателю позволено думать», а не «как быстро
мы должны заметить потерянный колбэк». Связав их, мы заставили бы покупателя ждать Фишки всё время
жизни заказа всякий раз, когда ломается доставка уведомлений. Вместо этого жнец спрашивает провайдера
о каждом незакрытом заказе старше минуты, у которого есть идентификатор платежа, и начисляет реально
оплаченные. Число запросов на один заказ при этом ограничено его временем жизни, делённым на шаг
жнеца, — единицы, — а сбой основного пути стоит минут, а не получаса.
**Об отклонённой оплате сообщаем.** `payment.canceled` от ЮKassa пишет событие `failed`, поэтому
покупатель узнаёт, что попытка не прошла, вместо разглядывания неменяющегося баланса. Просто