Чек-лист тестирования оплаты и чекаута — кейсы, на которых ломаются деньги
Чекаут — самый дорогой экран в продукте. Здесь сходятся деньги и доверие: одна ошибка — это не «некрасиво выглядит», а реальные списанные (или несписанные) рубли, возвраты, чарджбеки и тикеты в саппорт с приложенным скриншотом банковского приложения. При этом тестируют оплату чаще всего одинаково: тестовая карта, «Оплатить», зелёный экран, готово. А всё интересное живёт не в happy path — в отказах, двойных списаниях, брошенном 3-D Secure и оборванной сети между списанием и ответом.
Разберу, что я проверяю в оплате и почему.
Деньги — это целые минорные единицы, а не float
Первое правило: деньги не хранятся и не считаются во float. 0.1 + 0.2 в двоичной арифметике — это 0.30000000000000004, и на большом обороте эти хвосты превращаются в расхождение копеек между заказом, чеком и выпиской банка. Правильно — целые минорные единицы (копейки, центы) в int, а форматирование в рубли/доллары только на отображении.
Что проверять: округление после скидок и налогов (банковское округление vs математическое), сумма позиций сходится с итогом до копейки, отображение по локали (1 234,56 ₽ vs $1,234.56), три знака после запятой в валютах вроде динара и ноль знаков в йене. И отдельно — что показанная сумма ровно равна списанной. Расхождение «на экране 990, списали 1000» — это не косметика, это возврат и потеря доверия.
Двойное списание: идемпотентность на каждом шаге
Самый дорогой класс багов. Юзер жмёт «Оплатить» дважды, интернет отвалился между запросом и ответом, клиент ретраит по таймауту, юзер вернулся кнопкой «Назад» и отправил форму снова, вебхук от провайдера пришёл дважды. Любой из сценариев не должен приводить ко второму списанию.
Защита — идемпотентный ключ на операции оплаты: клиент генерирует Idempotency-Key, сервер по нему отдаёт результат первой попытки, а не создаёт вторую (так это устроено у Stripe). Это ровно тот же класс проверок, что в посте про idempotency и retry-storms: двойной тап, kill приложения между запросом и ответом, повтор с другого девайса, дабл-сабмит формы. Проверять надо не «а есть ли ключ», а поведение: два одинаковых запроса → одно списание, один заказ, один чек.
Отказы банка — это норма, а не ошибка сервера
На проде отказов больше, чем кажется на стенде: недостаточно средств, do not honor, истёкшая карта, неверный CVC, лимит, антифрод банка. Каждый — отдельный сценарий, и у платёжных провайдеров есть тестовые карты под каждый код отказа. Их и надо прогонять, а не только «успешную» 4242 4242 4242 4242.
Что проверять на отказе: юзер видит понятное сообщение («банк отклонил, попробуйте другую карту»), а не «Error 500» и не сырой код провайдера; можно ли повторить, не перезаполняя всё заново; и главное — заказ не переходит в «оплачен». Отказ, при котором в системе всё равно создаётся оплаченный заказ, — это деньги из воздуха и разбор с бухгалтерией.
3-D Secure: тестируйте брошенный челлендж
Оплата картой в ЕС и всё чаще везде проходит через 3-D Secure / SCA: редирект или модалка банка с кодом. Happy path — ввёл код, вернулся, оплата прошла. А тестировать надо провалы: юзер закрыл окно 3DS, нажал «Отмена», словил таймаут кода, вернулся кнопкой «Назад» в середине челленджа, у него frictionless-поток вместо challenge (банк не спросил код). В каждом случае вопрос один: в каком состоянии заказ и деньги, если юзер не дошёл до конца? Зависший pending без списания — приемлемо; списание без подтверждения 3DS или «оплачено» при брошенном челлендже — нет.
Вебхук — источник истины, а не редирект
Ключевая архитектурная вещь, которую часто ломают: подтверждать оплату надо по серверному вебхуку от провайдера, а не по тому, что юзера редиректнуло на /success. Юзер может закрыть вкладку сразу после списания и не увидеть /success — заказ всё равно обязан оформиться. И наоборот: открыть /success руками, ничего не заплатив, — заказ оформляться не должен.
Что проверять: заказ оформляется по вебхуку даже если юзер не вернулся; дубли вебхуков дедуплицируются по event_id; события могут прийти не по порядку (payment_succeeded раньше payment_created); подпись вебхука проверяется, поддельный запрос отбивается; вебхук ретраится провайдером, если ваш сервер ответил не 200. Тестировать удобно провайдерским CLI/тестером вебхуков и перехватом трафика (Proxyman/Charles), чтобы видеть, что реально прилетает.
Суммы и промокоды считает сервер, не клиент
Всё, что влияет на итог, пересчитывается на сервере и не берётся на веру с клиента — иначе это tampering из OWASP API Top-10: подменил сумму в запросе — купил за рубль. Проверять: итог = сумма позиций − скидка + налог + доставка, пересчитанный на бэке; налог/VAT по региону; доставка по правилам.
Промокоды — отдельное минное поле: невалидный, истёкший, исчерпавший лимит использований, применённый дважды, суммирование двух кодов (разрешено?), код ниже минимальной суммы заказа, код в другой валюте, отрицательный или 100% итог (оплата на 0 — что делает платёжка?). И перебор промокодов — это уже про rate limiting: без лимита чужие коды подберут скриптом.
PCI и данные карты: чего не должно быть в ваших логах
Границу PCI DSS проще всего держать так: данные карты не касаются вашего сервера. Поля карты — в iframe/SDK провайдера, вы получаете токен, а не PAN. Что проверяю: полный номер карты и CVV не попадают в логи, аналитику, sentry, БД; CVV не хранится никогда (даже у сохранённых карт); сохранённые карты — это токен провайдера, не номер; чекаут только по HTTPS; поля карты не автозаполняются чужими данными и не логируются на фронте. Это не «фича безопасности на потом» — утёкший PAN в логах это инцидент и штраф (PCI SSC).
Сеть и состояния: обрыв на каждом шаге
Оплата — это цепочка сетевых вызовов, и рвётся она на каждом звене. Проверяю обрыв: до отправки, во время списания (самое опасное — двойное списание), на шаге 3DS, между списанием и вебхуком. Плюс состояния: истёкшая сессия/корзина на моменте оплаты; товар кончился, пока юзер платил; цена изменилась во время оплаты (платим старую или показываем новую?); две вкладки с одним заказом; медленная сеть и спиннер, который нельзя обойти двойным тапом.
И заказ как конечный автомат: pending → paid → failed / refunded. Никогда не показывать «оплачено», пока не подтверждено вебхуком; сверять свой статус со статусом у провайдера (расхождение = алерт); поддержать возврат и частичный возврат; отмену до оплаты. Это тот же принцип, что в чек-листе форм — двойной сабмит и серверная валидация, только цена ошибки выше.
Чек-лист
- Деньги — целые минорные единицы, не float; округление после скидок/налогов проверено
- Показанная сумма ровно равна списанной; формат по локали и знаки после запятой по валюте
- Двойной тап / ретрай / обрыв / «Назад» → одно списание (идемпотентный ключ)
- Прогнаны тестовые карты под КАЖДЫЙ код отказа, не только успешная
- На отказе — понятное сообщение, повтор без перезаполнения, заказ НЕ «оплачен»
- 3DS: отмена, закрытие окна, таймаут, «Назад», frictentless — заказ и деньги в валидном состоянии
- Подтверждение по вебхуку, а не по редиректу; закрыл вкладку → заказ оформился
- Вебхуки: дедуп по event_id, разный порядок, проверка подписи, ретрай на не-200
- Итог, налоги, скидки, доставка пересчитаны на СЕРВЕРЕ; сумму с клиента не принимаем
- Промокод: невалидный, истёкший, лимит, суммирование, мин. сумма, чужая валюта, 0/отрицательный
- Перебор промокодов ограничен (rate limit)
- PAN/CVV не в логах/аналитике/БД; CVV не хранится; сохранённая карта = токен
- Чекаут по HTTPS; поля карты в iframe/SDK провайдера
- Обрыв сети на каждом шаге цепочки; истёкшая сессия/корзина; товар кончился; цена изменилась
- Заказ = конечный автомат pending→paid→failed/refunded; «оплачено» только после вебхука
- Свой статус сверяется со статусом провайдера; поддержаны возврат и частичный возврат
Чекаут коварен тем, что на demo-карте всё зелёное, а ломается он там, куда обычно не смотрят: в отказе банка, в оборванной сети, в брошенном 3DS, в вебхуке, который пришёл дважды или не пришёл вовсе. Здесь цена пропущенного бага измеряется не в тикетах, а в деньгах и доверии — поэтому happy path тут проходит первым, а заканчивается всё остальным.