Как проектировать платёжную систему на System Design Interview
Платёжная система — один из тех кейсов, где масштабирование системы не стоит на первом месте.
Если в чате или ленте рекомендаций ты потерял одно событие — неприятно.
Если в платёжной системе ты дважды списал деньги, потерял возврат или неправильно обработал возврат — это финансовый и юридический инцидент.
Поэтому главный инвариант такой системы не "быстро", а "надёжно".
Что обычно ждут от кандидата
На интервью в такой задаче проверяют: умеешь ли ты проектировать систему, где есть деньги, частичные сбои, повторы запросов, внешние провайдеры, вебхуки, ручные разборы и аудит.
Базовый сценарий выглядит так:
- пользователь оформляет заказ;
- система создаёт платёжное намерение;
- платёж уходит во внешний PSP (внешний платёжный провайдер);
- провайдер возвращает статус;
- система фиксирует результат;
- позже приходят вебхуки, отчёты, сверки, возвраты и спорные операции.
Кто хоть раз интегрировал платёжную систему, знает, что отлететь может что угодно на любом этапе. В самой платёжной системе ситуация не лучше. Вот всего лишь несколько примеров:
- Клиент может повторить запрос.
- PSP может ответить тайм-аутом, но деньги уже авторизовать.
- Webhook может прийти дважды.
- Возврат может быть частичным.
- Внутренняя БД и внешний провайдер могут временно показывать разные статусы.
Знакомо? Лично у меня аж глаз подёргивается. Много наелся с платёжными системами.
Вокруг этой неопределенности и нужно построить устойчивую архитектуру.
Требования
Давайте сформулируем что должна делать система, как она должна функционировать.
- создать payment intent (намерение) для заказа;
- провести авторизацию и списание;
- поддержать возвраты, включая частичные;
- принимать webhook’и от PSP;
- хранить историю платежа;
- дать поддержку и финансам прозрачный audit trail;
- обрабатывать статусы вроде authorized, captured, failed, refunded, chargeback.
Намерение платежа (payment intent) это способ отделить намерение оплатить от фактического финансового эффекта.
Заказ ещё не означает успешное списание. А нажатие на кнопку «Оплатить» ещё не означает деньги. Даже успешный ответ от PSP ещё не всегда финальная истина, пока не прошла сверка.
Нефункциональные
- не допустить двойного списания;
- выдерживать повторы запросов;
- не терять финансовые события;
- изолировать карточные данные;
- иметь понятную модель восстановления после сбоев;
- не завязывать всю систему на синхронный ответ внешнего провайдера.
Latency важна, но она вторична относительно корректности данных. Checkout должен быть быстрым, но мы не можем пренебрегать финансовой консистентности.
Чувствуете как запахло архитектурными вызовами?
Нужно разделить нашу платёжную систему на 2 контура:
- синхронный платёжный путь. Он нам нужен, что бы пользователь прошёл checkout и получил понятный результат: платёж сосздан, авторизован, отклонён и т.д.
- асинхронный контур. Это вебхуки, сверки, отчётности, антифроды, возвраты и т.д.
Зачем разделять? Просто представь, что будет, если мы всё сделаем синхронно: и чекаут, и обработку с внешним провайдером, и сверки. Причин, почему сё может пойти не так десятки.
Payment API
Входная точка синхронного контура. Принимает запросы от checkout, создаёт payment intent, проверяет idempotency key и возвращает клиенту текущее состояние платежа.
Ключевая задача API — не дать повторному запросу стать повторным списанием.
Если пользователь нажал кнопку два раза, мобильное приложение повторило запрос после тайм-ауза или API Gateway ретрайнул вызов, система должна вернуть уже существующий результат, а не создать новую финансовую операцию.
Payment Orchestrator / Core
Центральный компонент платёжной системы, владелец бизнес-логики и состояния платежа.
Через него проходят все изменения в системе.
Payment API -> Payment Core Webhook Handler -> Payment Core Reconciliation Job -> Payment Core Backoffice API -> Payment Core
Важно контролировать допустимые переходы, а не прихранивать конкретный статус. Это сложный компонент, включающий несколько подсистем.
Мы не можем себе позволить дать любому обработчику ставить любой статус, нужен какой-то конечный автомат, где провалидируется каждый переход.
PSP Adapter
Изолирует интеграцию с конкретным платёжным провайдером.
Собственно, потому что PSP отличаются API, статусами, ретраями, форматами webhook’ов, поведением на тайм-аутах и правилами возвратов, нам надо под каждый писать своё.
Домен платежей не должен знать все детали конкретного провайдера. Иначе замена PSP или добавление второго провайдера превращается в переписывание ядра платёжной системы.
Command Handlers
Часть Payment Core, которая принимает команды от входных адаптеров.
Idempotency / Deduplication
Payment State Machine
Должен обеспечить корректность переходов по статусам.
INITIATED -> AUTHORIZED -> CAPTURED INITIATED -> FAILED AUTHORIZED -> CANCELLED CAPTURED -> REFUNDED CAPTURED -> CHARGEBACK
Хранит текущее состояние payment intent и payment transaction.
payment_transactions - payment_id - order_id - customer_id - amount - currency - status - psp_reference - idempotency_key - created_at - updated_at
Этого, разумеется, недостаточно.
Если хранить только текущий статус платежа, будет сложно понять, что именно произошло: была авторизация, потом списание, потом частичный refund, потом chargeback или ручная корректировка.
Для денег нужен отдельный журнал. Это нерушимое правило.
Ledger
Ledger — это неизменяемый журнал финансовых эффектов, полная история движений.
ledger_entries - entry_id - payment_id - account_id - direction - amount - currency - entry_type - created_at
Важное свойство ledger — append-only. Мы не переписываем прошлое, а добавляем новые записи. Это тоже нерушимое правило.
Такой подход даёт аудит, восстановление, сверку и возможность объяснить финансовой команде, почему баланс выглядит именно так.
Webhook Handler
PSP будет присылать события: платёж авторизован, списан, отклонён, возвращён, оспорен.
Webhook’и нельзя считать идеально надёжными.
- прийти дважды;
- прийти не по порядку;
- прийти с задержкой;
- не прийти вообще;
- содержать статус, который уже устарел относительно внутреннего состояния.
Поэтому webhook handler должен быть идемпотентным и уметь дедуплицировать события.
Reconciliation Jobs
Сверка — обязательная часть платёжной системы.
Нельзя строить дизайн так, будто webhook’и всегда доставляются и всегда являются единственным источником истины.
- внутренние payment transactions;
- ledger;
- отчёты PSP;
- банковские/эквайринговые выгрузки;
- спорные и зависшие операции.
Если есть расхождение, оно должно уходить либо в автоматическую коррекцию, либо в очередь ручного разбора.
Используем exactly-once? А вот и нет.
Если тебе хочется ворваться с ноги и прокричать: «Нам нужна exactly-once обработка», то ты можешь сильно ошибиться.
В жизни, мой юный архитектор, между клиентом, backend’ом, очередью, БД и внешним PSP сложно получить честный exactly-once на всём пути.
И что ж тогда? Тогда нужен at-least-once.
at-least-once delivery + idempotency + deduplication + transactional outbox + reconciliation
То есть мы допускаем повторы, но делаем так, чтобы повтор не менял финансовый результат второй раз.
- Повторный webhook не создаёт вторую ledger-запись.
- Повторный refund request не возвращает деньги дважды.
- Повторный capture не делает второй capture, если операция уже была проведена.
Что делать с внешним PSP
PSP — ненадёжная внешняя зависимость в критичном пути.
Нужны таймауты, ретраи, circuit breaker, дедубликации, сохранение промежуточных состояний, ручной разбор зависших операций, периодическая сверка.
Если PSP ответил тайм-аутом, нельзя автоматически считать платёж неуспешным. Возможно, провайдер уже провёл авторизацию, а ответ потерялся по сети.
Безопасность и PCI DSS
В платёжной системе нужно минимизировать зону, где проходят карточные данные.
Нужно не хранить и не обрабатывать PAN/CVV внутри своей платформы, если можно использовать токенизацию на стороне PSP.
То есть клиентская форма или платёжный виджет получает токен от PSP, а наша система работает уже с токеном, а не с сырыми карточными данными.
- mTLS между внутренними сервисами;
- строгий RBAC для возвратов и ручных операций;
- журнал аудита для действий операторов;
- шифрование чувствительных данных;
- разграничение доступа для поддержки, финансов и админов.
Observability
За чем следить в нашей системе?
Golden Signals для Payment API
Это базовый слой: доступность, задержки, ошибки, нагрузка. При этом отдельно нужно смотреть на запись и на чтение.
Latency
Но смотреть нужно хвосты, а не среднюю задержку. При этом нужно раздербанить latency на части, чтобы понять, почему именно страдает.
Метрики жизненного цикла платежа
Это не столько технический, сколько бизнесовый показатель.
Например, платежи зависают в статусе. При этом важно смотреть на то, какой статус, как долго висят и вот это вот всё.
Успешность платежей
Если % успешных платежей падает, относительно тренда, то это явный признак, что есть проблемки.
А ещё есть смысл смотреть за интеграциями с PSP-провайдерами, вебхуками, свеками, аудитами, аутбоксом.