Содержание
- Почему «прикрутить оплату» — это не 20 минут работы
- Как устроен платёж end-to-end
- Идемпотентность: главное требование процессинга
- Состояние транзакции и согласованность
- Интеграция платёжных шлюзов
- Обработка ошибок и реконсиляция
- Безопасность: PCI DSS, токенизация, 152-ФЗ
- Highload: очереди, outbox, ретраи
- Импортозамещение платёжной инфраструктуры
- FAQ
Платёжная система выглядит просто ровно до того момента, пока вы не начинаете её строить. Снаружи это «пользователь выбирает тариф и оплачивает через эквайринг» — а внутри десятки состояний, гарантии согласованности, реконсиляция с банком, требования регулятора и инфраструктура, которая не имеет права упасть в пятницу вечером. В наших корпоративных каналах постоянно всплывает один и тот же сюжет: задача, которая «на 20 минут», на деле оборачивается шестью-семью часами на один запрос — и это только интеграция, без учёта эксплуатации.
Эта статья — практический разбор того, как устроены платёжные и финтех-системы с точки зрения инженера: как проходит платёж от мерчанта до банка-эмитента, какие требования критичны (идемпотентность, согласованность, аудит, отказоустойчивость, SLA), как интегрировать платёжные шлюзы, обрабатывать ошибки и сводить деньги в реконсиляции, и как всё это держать под нагрузкой. Примеры — на Java/Kotlin и Spring, потому что именно на этом стеке мы в Новакоме чаще всего и проектируем финтех-контур.
Почему «прикрутить оплату» — это не 20 минут работы
Начнём с трезвого взгляда на сложность, потому что недооценка здесь стоит дороже всего.
Типичный запрос из реальной практики звучит так: «нам нужна интеграция платёжного сервиса с облачной кассой — чтобы после покупки данные автоматически уходили в кассу, формировался чек и отправлялся покупателю; сейчас делаем вручную, это неудобно». В общих чертах задача кажется тривиальной: вебхук, пара HTTP-запросов, готово. Но как только начинаешь углубляться, всплывает фискализация по 54-ФЗ, форматы чеков, повторная отправка при сбое ОФД, дедупликация вебхуков, сверка сумм — и «20 минут» превращаются в полноценный проект.
Второй слой сложности — юридический и продуктовый. B2C-сценарий действительно прост: оферта, эквайринг, оплата тарифа. Но стоит выйти в B2B — и нужны договор как основание платежа, закрывающие документы (УПД для бухгалтерии), сверка взаиморасчётов. Платёжная логика перестаёт быть «кнопкой оплатить» и становится частью документооборота.
Третий слой — экономика и выбор провайдера. Комиссии за онлайн-эквайринг гуляют в районе 0,8–2,5% и сильно зависят от банка и оборота; правильный выбор эквайера может ощутимо снизить расходы. На рынке десятки шлюзов с разным покрытием карточных систем, разной поддержкой фискализации и разными SLA. Кто-то берёт фискализацию на себя и шлёт онлайн-чеки сам, кто-то требует связки с ОФД и онлайн-кассой. Это архитектурное решение, а не строчка в конфиге.
Вывод простой: платёжная система — это не интеграция, а распределённая система с деньгами внутри, где цена ошибки измеряется не в багрепортах, а в рублях и в претензиях регулятора.
Как устроен платёж end-to-end
Чтобы проектировать платёжный контур, нужно держать в голове полную цепочку участников. Карточный платёж проходит примерно так:
- Мерчант (ваш бэкенд). Инициирует платёж: создаёт заказ, формирует сумму, дёргает платёжный шлюз. Своих данных карты не хранит и в идеале вообще не видит.
- Платёжный шлюз (payment gateway). Принимает запрос, собирает данные карты на своей стороне (через платёжную форму или токен), маршрутизирует транзакцию к эквайеру. Это та «прослойка», которую обычно и интегрирует разработчик: ЮKassa, CloudPayments, Робокасса, банковский эквайринг напрямую.
- Банк-эквайер (acquirer). Банк, обслуживающий мерчанта. Отправляет авторизационный запрос в платёжную систему.
- Платёжная система (card scheme). МИР, Visa, Mastercard — маршрутизирует запрос к банку, выпустившему карту. По сути это «паритет возможности» ускоренных и упрощённых расчётов между банками, выстроенный десятилетиями.
- Банк-эмитент (issuer). Банк держателя карты. Проверяет баланс, лимиты, антифрод, 3-D Secure и отвечает: одобрить или отклонить.
Ответ возвращается по той же цепочке обратно. Важно понимать ключевую развилку платёжной механики — двухстадийность:
- Авторизация (authorization / hold). Эмитент замораживает сумму на карте, но деньги ещё не списаны. Мерчант получает подтверждение, что средства есть.
- Списание (capture / clearing). Подтверждение реального перевода. Может произойти сразу (одностадийная схема) или позже — например, после отгрузки товара (двухстадийная).
- Отмена/возврат (void / refund). Снятие холда до клиринга или возврат уже списанных средств.
Отдельно живёт расчётный цикл (settlement): фактические деньги между банками двигаются не в момент авторизации, а пакетами, по итогам дня. Именно поэтому в финтех-системах сеттлмент-данные — отдельная сущность, и сверка дашбордов по 20 миллионам транзакций в месяц — это самостоятельная аналитическая задача, а не побочный эффект процессинга.
Для инженера из этой картины следует главное: между «пользователь нажал оплатить» и «деньги на счёте мерчанта» лежит распределённая транзакция через 4–5 независимых систем, каждая из которых может ответить с задержкой, ответить ошибкой или не ответить вовсе. Вся архитектура платёжного сервиса строится вокруг этого факта.
Идемпотентность: главное требование процессинга
Самая частая и самая дорогая ошибка в платёжных системах — двойное списание. Возникает оно банально: клиент нажал «оплатить» дважды, мобильное приложение повторило запрос по таймауту, балансировщик отправил ретрай, вебхук от шлюза пришёл трижды. В обычном CRUD это раздражает; в платежах это потерянные деньги и разбирательство с клиентом.
Решение — идемпотентность: повторный запрос с тем же ключом должен приводить к тому же результату, не создавая новый платёж. Практически это реализуется через идемпотентный ключ (idempotency key), который клиент генерирует один раз на одну попытку оплаты и передаёт во всех ретраях.
@Service
class PaymentService(
private val payments: PaymentRepository,
private val gateway: PaymentGatewayClient,
) {
/**
* Возвращает существующий платёж по ключу идемпотентности
* либо атомарно создаёт новый. Повторный вызов с тем же ключом
* никогда не приводит к двойному списанию.
*/
@Transactional
fun authorize(command: AuthorizeCommand): Payment {
// Быстрый путь: платёж по этому ключу уже есть
payments.findByIdempotencyKey(command.idempotencyKey)?.let { existing ->
require(existing.amount == command.amount) {
"Idempotency key reuse with different amount"
}
return existing
}
// Резервируем запись со статусом PENDING.
// Уникальный индекс на idempotency_key — последняя линия обороны
// от гонки двух параллельных запросов.
val payment = try {
payments.save(Payment.pending(command))
} catch (e: DataIntegrityViolationException) {
return payments.findByIdempotencyKey(command.idempotencyKey)
?: throw e
}
val result = gateway.authorize(payment.toGatewayRequest())
payment.applyAuthorization(result)
return payments.save(payment)
}
}
Ключевые моменты, которые часто упускают:
- Уникальный индекс в БД на
idempotency_key— не «оптимизация», а обязательное условие корректности. Проверка «есть ли уже платёж» в коде не спасает от гонки двух одновременных запросов; спасает констрейнт базы. - Ключ привязан к параметрам платежа. Если по тому же ключу пришла другая сумма — это ошибка клиента, а не повтор. Такой запрос нужно отклонять.
- Идемпотентность сквозная. Её надо протягивать и в вызов шлюза (большинство серьёзных провайдеров принимают
Idempotency-Keyв заголовке), и в обработку вебхуков, и в фискализацию.
Состояние транзакции и согласованность
Платёж — это конечный автомат (state machine). Описать его явно и запретить недопустимые переходы — половина успеха. Размазанные по коду if (status == ...) рано или поздно приведут к платежу, который «завис» — а зависший платёж это всегда либо недополученные, либо дважды списанные деньги.
enum class PaymentStatus { PENDING, AUTHORIZED, CAPTURED, FAILED, REFUNDED, CANCELLED }
private val allowedTransitions: Map<PaymentStatus, Set<PaymentStatus>> = mapOf(
PaymentStatus.PENDING to setOf(PaymentStatus.AUTHORIZED, PaymentStatus.FAILED, PaymentStatus.CANCELLED),
PaymentStatus.AUTHORIZED to setOf(PaymentStatus.CAPTURED, PaymentStatus.CANCELLED),
PaymentStatus.CAPTURED to setOf(PaymentStatus.REFUNDED),
PaymentStatus.FAILED to emptySet(),
PaymentStatus.REFUNDED to emptySet(),
PaymentStatus.CANCELLED to emptySet(),
)
fun Payment.transitionTo(target: PaymentStatus) {
check(target in allowedTransitions.getValue(status)) {
"Illegal transition $status -> $target for payment $id"
}
status = target
updatedAt = Instant.now()
}
Второй важный аспект — согласованность под конкурентным доступом. Когда вебхук об успешной авторизации и фоновый процесс отмены по таймауту приходят одновременно, без блокировки они затрут друг друга. Здесь работает оптимистичная блокировка через версионирование (@Version в JPA): транзакция, чья версия устарела, откатывается и повторяется на свежих данных.
@Entity
class Payment(
@Id val id: UUID,
@Version var version: Long = 0,
@Enumerated(EnumType.STRING) var status: PaymentStatus = PaymentStatus.PENDING,
val amount: Money,
val idempotencyKey: String,
)
И третье — аудит. В платёжной системе каждое изменение состояния должно быть записано неизменяемой строкой в журнал: кто, когда, с какого состояния на какое, по какому событию. Это требование не только ИБ, но и реконсиляции: когда деньги «не сходятся», восстанавливать историю приходится именно по аудит-логу. Здесь не бывает «100% надёжных систем без сбоев» — поэтому возможность достоверно восстановить, что произошло, важнее, чем вера в то, что сбоев не будет.
Интеграция платёжных шлюзов
На практике почти никто не подключается напрямую к платёжной системе — это лицензии, сертификация и полмиллиона евро с годом ожидания первой транзакции. Подключаются к шлюзу. И здесь главный архитектурный приём — не зашивать конкретного провайдера в бизнес-логику, а спрятать его за абстракцией. Сегодня у вас один эквайер, завтра вы добавляете второй для маршрутизации по картам (классическая боль: один шлюз работает только с Mastercard, для Visa нужен другой), послезавтра меняете провайдера ради комиссии на 0,3% ниже.
interface PaymentGateway {
fun authorize(request: AuthorizeRequest): GatewayResult
fun capture(transactionId: String, amount: Money): GatewayResult
fun refund(transactionId: String, amount: Money): GatewayResult
fun parseWebhook(payload: ByteArray, signature: String): WebhookEvent
}
@Component
class GatewayRouter(private val gateways: Map<CardScheme, PaymentGateway>) {
fun forCard(scheme: CardScheme): PaymentGateway =
gateways[scheme] ?: error("No gateway configured for $scheme")
}
Несколько правил, которые экономят недели на интеграции:
- Вебхуки — основной источник истины о финальном статусе, а не ответ на синхронный запрос. Сеть может оборвать соединение после того, как платёж прошёл; статус вы узнаете из вебхука. Поэтому синхронный ответ обрабатываем оптимистично, но финал фиксируем по асинхронному уведомлению.
- Каждый вебхук проверяем по подписи и дедуплицируем. Провайдеры доставляют уведомления «хотя бы один раз» — дубли неизбежны. Спасает тот же идемпотентный подход: уникальный ключ события и проверка «не обработали ли уже».
- Не доверяем суммам из вебхука вслепую — сверяем с тем, что инициировали. Это и защита от ошибок, и часть антифрода.
- Таймауты и параметры ретраев настраиваются на каждый шлюз отдельно. У провайдеров разный SLA, и одинаковый таймаут на всех — путь к ложным «зависаниям».
Подробнее об организации биллинговой логики поверх шлюзов мы писали в разборе архитектуры SaaS-биллинга на Spring Boot — там акцент на тарифах, подписках и закрывающих документах, которые в B2B неотделимы от платежа.
Обработка ошибок и реконсиляция
В платежах ошибки делятся на два класса, и путать их нельзя.
Детерминированные отказы — недостаточно средств, неверный CVV, карта заблокирована, лимит эмитента. Их ретраить бессмысленно: ответ не изменится. Такой платёж переводим в FAILED и честно сообщаем пользователю причину.
Недетерминированные сбои — таймаут, обрыв сети, 503 от шлюза, отсутствие ответа. Здесь самое опасное состояние во всём процессинге: вы не знаете, прошёл платёж или нет. Деньги могли списаться, а ответ — потеряться. Нельзя ни считать платёж успешным, ни просто повторить его (рискуя двойным списанием). Правильная стратегия — повторный запрос с тем же идемпотентным ключом либо явный запрос статуса у провайдера (status reconciliation), пока не получим определённый ответ.
Отсюда вырастает реконсиляция — регулярная сверка вашего состояния платежей с выпиской провайдера и банка. Это не опция, а обязательный процесс: банки обновляют данные в своих системах с задержкой, и расхождения накапливаются. Типовая реконсиляция выглядит так:
| Сценарий расхождения | Что значит | Действие |
|---|---|---|
У нас CAPTURED, у банка нет | Списание не дошло или потеряно | Расследование, запрос статуса, при необходимости — повторный capture |
У банка списание, у нас PENDING/FAILED | Потерян вебхук или сбой на нашей стороне | Дослать в учёт, при невозможности — возврат |
| Суммы не совпадают | Частичный возврат, комиссия, ошибка | Ручной разбор по аудит-логу |
У нас REFUNDED, у банка нет возврата | Возврат не инициировался реально | Повторить refund |
Практический подход — ночной батч, который выгружает реестр операций провайдера, сопоставляет с базой по внешнему ID транзакции и формирует отчёт о расхождениях. То, что свелось автоматически, закрывается; остальное уходит на ручной разбор операторам. Без такого процесса деньги «теряются» тихо, и обнаруживается это уже на уровне бухгалтерии в конце месяца.
Безопасность: PCI DSS, токенизация, 152-ФЗ
Деньги притягивают атаки, поэтому безопасность в платёжной системе — это не «слой поверх», а часть архитектуры с первого дня.
PCI DSS и принцип «не хранить то, что не обязан». Самое дорогое требование стандарта — защита данных карты (PAN, CVV). Лучший способ его выполнить — вообще не пропускать данные карты через свой бэкенд. Здесь работает токенизация: платёжная форма шлюза собирает карту на своей стороне и возвращает вам токен — безопасный суррогат, которым можно проводить повторные списания, но из которого нельзя восстановить номер карты. Так вы радикально сокращаете область PCI DSS-аудита: ваш сервер видит токены, а не карты.
Токены вместо карт для рекуррентных платежей. Подписки, автосписания, сохранённые карты — всё это строится на токенах, а не на хранении PAN. Токен привязан к мерчанту и провайдеру, и его утечка не даёт злоумышленнику возможности расплатиться картой в другом месте.
3-D Secure и антифрод. Дополнительная аутентификация держателя (редирект на страницу банка, push, OTP) переносит ответственность за мошеннические транзакции на эмитента и режет фрод. Антифрод-правила (лимиты на частоту, гео, суммы) — обязательный контур: операции вроде регулярного снятия крупных сумм или нетипичной географии должны как минимум флагироваться.
152-ФЗ и контур данных. Для российского рынка персональные и платёжные данные граждан РФ должны обрабатываться и храниться в инфраструктуре на территории России. Это влияет на выбор хостинга, провайдеров и архитектуру: критичные данные не должны утекать во внешние SaaS за периметром. Self-hosted-компоненты и хранение событий под собственным контролем здесь не идеология, а требование закона.
Отдельно стоит помнить про принцип минимизации доступа и сегментацию: контур, где ходят платёжные данные, изолируется от остального, доступ — по ролям (RBAC), а каждое обращение к чувствительным данным логируется. Истории вроде многократных взломов хранилищ паролей напоминают простую вещь: один инцидент — случайность, система инцидентов — приговор сервису. В платежах второго шанса обычно не дают.
Highload: очереди, outbox, ретраи
Платёжная система обязана быть быстрой и не падать под пиком — будь то распродажа, зарплатный день или массовая рассылка счетов. Архитектура отказоустойчивости здесь стоит на нескольких опорах.
Балансировка и горизонтальное масштабирование. Stateless-сервисы процессинга за балансировщиком, реплики для отказоустойчивости, шлюз авторизации как отдельный компонент со своей БД, контроль лимитов как отдельный сервис — типовая схема, которую подтверждает практика highload-платёжных контуров. Если один сервис «затормозит», он не должен ронять остальные — поэтому критичны изоляция и контроль лимитов.
Transactional outbox — решение фундаментальной проблемы: как атомарно и записать платёж в БД, и отправить событие в Kafka? Двухфазный коммит дорог и хрупок; вместо него событие пишется в таблицу outbox в той же транзакции, что и изменение платежа, а отдельный процесс публикует его в брокер с гарантией at-least-once.
@Transactional
fun capturePayment(paymentId: UUID): Payment {
val payment = payments.findByIdOrNull(paymentId) ?: error("not found")
payment.transitionTo(PaymentStatus.CAPTURED)
// Платёж и событие пишутся в одной транзакции —
// либо оба, либо ничего. Публикацию в Kafka берёт на себя
// отдельный релейер, читающий таблицу outbox.
outbox.save(
OutboxEvent(
aggregateId = payment.id,
type = "PaymentCaptured",
payload = payment.toEventJson(),
)
)
return payments.save(payment)
}
Детальный разбор этого паттерна с релейером и Kafka — в статье про outbox в Spring Boot на проде. Для платежей это базовый кирпич: он гарантирует, что после успешного списания фискализация, уведомление и реконсиляция точно получат своё событие, даже если сервис упал сразу после коммита.
Очереди для развязки. Фискализация чека, отправка уведомлений, выгрузка в учётные системы — всё это асинхронно через очередь. Синхронный путь оплаты должен быть максимально коротким; всё, что можно сделать после подтверждения, делается после. Это и про скорость, и про устойчивость: если ОФД временно недоступен, чеки копятся в очереди и досылаются, а не блокируют платежи.
Circuit breaker и грамотные ретраи. Когда внешний шлюз деградирует, нельзя долбить его ретраями — это усугубляет аварию и копит таймауты. Размыкатель цепи (circuit breaker) временно отсекает обращения к упавшему провайдеру, давая ему восстановиться, а ретраи делаются с экспоненциальной задержкой и джиттером — и только для недетерминированных ошибок. Как это собирается на практике, мы показывали в гайде по Resilience4j и circuit breaker в Spring Boot. Более широкий взгляд на проектирование нагруженных систем — в материале про highload-архитектуру на Java.
Импортозамещение платёжной инфраструктуры
Для российского финтеха к общим требованиям добавляется ещё один вектор — независимость от зарубежной инфраструктуры. И это не абстракция: проблемы приёма карт Visa за рубежом, сложности с выводом средств между юрисдикциями, необходимость работать только через МИР для части операций — всё это реальные ограничения, с которыми сталкиваются команды.
Что это значит для архитектуры:
- Процессинг через МИР и российские платёжные системы как основной контур, с абстракцией над шлюзами, которая позволяет добавлять и переключать эквайеров без переписывания бизнес-логики.
- Self-hosted-компоненты вместо внешних SaaS там, где речь о критичных данных. Зависеть в приёме платежей от сервиса, который может отключить доступ, — недопустимый единичный риск.
- Отечественные средства безопасности — токены, HSM, криптография по российским стандартам там, где это требуется регулятором.
- Данные внутри периметра — хранение событий, сессий и платёжных данных под собственным контролем, что одновременно закрывает и 152-ФЗ, и риск внешней точки отказа.
Здесь же стоит трезво оценивать сборку «своей платёжки». Построить полноценное платёжное учреждение с нуля — это лицензии, год до первой транзакции и серьёзный капитал; для большинства бизнесов правильный путь — не строить процессинг с нуля, а грамотно собрать систему поверх существующих эквайеров и шлюзов, заложив в неё корректную обработку денег, отказоустойчивость и возможность менять провайдеров. Импортозамещение здесь — это про архитектурную независимость и контроль над данными, а не про переписывание платёжных систем целиком.
FAQ
Чем финтех-сервис отличается от банка? Банк работает на основе банковской лицензии и сам является конечным звеном расчётов. Финтех-сервис, как правило, лицензии не имеет и работает поверх банковской инфраструктуры — он добавляет технологичность, удобство и автоматизацию (онбординг, API, аналитику, диспуты), но опирается на банки и платёжные системы в основе. Масштабировать платёжный инструмент без банковской инфраструктуры невозможно — поэтому серьёзный финтех всегда строится в связке с банками.
Зачем нужна идемпотентность, если есть транзакции в БД? Транзакция БД гарантирует атомарность внутри вашей базы, но не защищает от повторного вызова всей операции — например, когда клиент дважды нажал «оплатить» или шлюз прислал дубль вебхука. Идемпотентный ключ гарантирует, что повтор приведёт к тому же результату и не создаст второй платёж. Это разные уровни защиты, и нужны оба.
Можно ли хранить данные карт у себя, чтобы делать повторные списания? На практике — нет, не нужно. Для рекуррентных платежей используется токенизация: шлюз возвращает токен, которым можно проводить списания, не храня номер карты. Это радикально сокращает область PCI DSS-аудита и снимает с вас самые тяжёлые требования по защите карточных данных.
Что делать, если платёж «завис» — ответа от шлюза нет? Нельзя считать его ни успешным, ни неуспешным. Нужно повторить запрос с тем же идемпотентным ключом или явно запросить статус транзакции у провайдера, пока не придёт определённый ответ. Окончательную истину дают вебхук и ночная реконсиляция с выпиской провайдера.
Нужна ли реконсиляция, если шлюз присылает вебхуки? Да. Вебхуки теряются, приходят с задержкой, дублируются; банки обновляют данные в своих системах не мгновенно. Реконсиляция — независимая сверка вашего состояния с реестром провайдера и банка — единственный способ гарантированно поймать расхождения до того, как они всплывут в бухгалтерии.
Если вы проектируете платёжный или финтех-контур — процессинг, эквайринг, биллинг или интеграцию шлюзов — мы в Новакоме строим такие системы на Java/Kotlin: от модели транзакций и идемпотентности до highload-инфраструктуры, реконсиляции и требований 152-ФЗ и PCI DSS. Посмотрите наши услуги по разработке на Spring и заказной разработке ПО или напишите нам с описанием вашей задачи — оценим объём честно, без «это на 20 минут».