Stripe-style payment system case: API gateway with WAF and Redis idempotency cache, payment core with charge service, saga orchestrator, HSM tokenization, Postgres double-entry ledger and outbox, external Visa/Mastercard/SEPA rails via processor adapter, async fan-out via Kafka to webhook dispatcher, reconciliation cron, and payouts. Five animated scenarios: happy-path card charge with idempotency key, retry duplicate caught at idempotency layer, refund with compensating ledger entry, subscription rebill via cron with saved token, and nightly reconciliation cron catching webhook-lost mismatches. Two ADRs: idempotency-key dual-storage strategy (Redis fast path + Postgres unique-index backstop) and ledger storage choice (Postgres double-entry with outbox vs append-only event store). Capacity hints throughout.
Платежная система выглядит как обычный CRUD только до первого сбоя. В реальности это система, где одна потерянная запись, один повторный retry или один неверно обработанный webhook превращаются в реальные деньги: двойное списание, потерянный capture, неправильный refund, расхождение с банком и ручной разбор в поддержке. Поэтому кейс учит не просто подключать PSP, а проектировать финансовый workflow с нулевой терпимостью к потере данных.
В этом разборе целевой масштаб близок к Stripe-class mid-tier процессору: около 1M merchants, 100M transactions/day, средний core charge RPS около 1.2K и peak до 12K, а вместе с dashboard/API поверхностью до 120K peak RPS. Запись одного payment event с metadata занимает примерно 4 KB, но реальный storage растет быстрее из-за double-entry ledger, outbox, webhook attempts, audit trail и семилетнего хранения. Для финансовых данных целевые RPO = 0 и RTO меньше 5 минут важнее, чем идеальная p99 latency.
Главная польза кейса: он связывает ::concept{slug="idempotency"}, ::concept{slug="saga-orchestration"}, ::concept{slug="transactional-outbox"}, ::concept{slug="consistency-models"} и ::concept{slug="postgres-internals"} в одну практическую архитектуру. Здесь хорошо видно, почему сильная консистентность нужна в ledger, почему внешние вызовы нельзя держать внутри долгой SQL-транзакции, и почему retry должен быть штатным сценарием, а не исключением.
Думайте о payment system как о state machine вокруг денежного намерения. payment_intent или payment проходит состояния pending, authorized, captured, failed, refunded; каждый переход должен быть идемпотентным, аудируемым и совместимым с задержками внешнего процессора. API отвечает клиенту быстро, но истина о деньгах живет не в HTTP response, а в ledger и reconciled provider state.
Второй mental model: ledger не является журналом логов. Это бухгалтерская модель, где каждое движение денег имеет как минимум две стороны: debit и credit. Если пользователь платит 1000 рублей, у merchant balance появляется приход, у platform fee может появиться комиссия, а у pending или clearing account меняется обязательство. Refund не удаляет исходный charge, а добавляет компенсирующую проводку. Такой подход делает аудит и reconciliation объяснимыми.
Третий mental model: внешний PSP, card network или bank rail недетерминированы. Они могут вернуть timeout, потом прислать webhook success, могут принять duplicate request с другим idempotency key, могут задержать settlement на день. Поэтому payment core должен принимать, что локальная база и провайдер временно расходятся, а reconciliation nightly job приводит их к одному состоянию.
Диаграмма разделяет систему на ingress, payment core, external rails и async downstream. На входе API Gateway и WAF принимают запрос POST /charges, а Redis idempotency cache быстро отсекает повтор с тем же ключом. Но cache не считается единственной защитой: в Postgres есть unique constraint по (merchant_id, idempotency_key), чтобы Redis eviction или race не дали провести второй charge.
В core находятся Charge Service, Saga Orchestrator, Tokenization/HSM, Processor Adapter, Postgres Ledger и Outbox. Сырой PAN не сохраняется; карточные данные превращаются в token, а работа с HSM/KMS вынесена в отдельный защищенный компонент. Orchestrator управляет шагами: проверить idempotency, токенизировать метод оплаты, обратиться к processor adapter, записать payment state и ledger entries, положить событие в outbox.
Внешний Processor Adapter отделяет доменную модель от Stripe, Adyen, CloudPayments, Visa/Mastercard, SEPA или локальных rails. Это важно: provider-specific поля и ошибки не должны протекать в ledger. Downstream представлен Kafka, Webhook Dispatcher, Reconciliation, Analytics и Payouts. Событие payment.succeeded сначала атомарно фиксируется в outbox рядом с ledger transaction, затем Debezium или poller публикует его в Kafka. Так webhook не может быть отправлен без сохраненного платежа и сохраненный платеж не потеряется без события.
Happy path показывает card charge с idempotency key. Клиент отправляет запрос, gateway проверяет ключ, orchestrator получает authorization от банка, затем в одной локальной транзакции пишет payment state, double-entry ledger и outbox event. Важный урок: успех для клиента должен соответствовать durable state в базе, а не только успешному ответу PSP.
Idempotent replay показывает retry того же запроса после сетевого timeout. Система возвращает сохраненный результат, а не делает второй auth. В production это не edge case, а норма: mobile network пропадает, browser повторяет запрос, merchant SDK retry-ит 500, load balancer закрывает соединение. ::concept{slug="idempotency"} здесь защищает и пользователя, и merchant, и поддержку.
Refund сценарий учит, что возврат не стирает charge. Orchestrator вызывает provider refund, затем пишет компенсирующие ledger entries и outbox payment.refunded. Partial refund должен хранить amount, currency, reason, provider_refund_id и связь с исходным payment. Несколько partial refunds суммарно не должны превысить captured amount; это проверяется доменной логикой и ограничениями БД.
Subscription rebill показывает saved token и cron-driven charge. Тут важны retry policy, dunning flow, soft decline vs hard decline, idempotency key на период подписки и защита от повторного списания при перезапуске cron. Nightly reconciliation показывает потерянный webhook: provider считает платеж captured, локальная система зависла в authorized. Reconciliation job сравнивает provider reports с локальным ledger и создает корректирующие задачи или автоматические state transitions.
Первый trade-off: Redis idempotency fast path против Postgres-only idempotency. Redis дает быстрый ответ на retry и разгружает БД, но не является durable источником истины. Поэтому правильная стратегия двойная: Redis для latency, Postgres unique index как backstop. Цена: две записи и сложнее invalidation, зато нет double-charge при race или eviction.
Второй trade-off: Postgres double-entry ledger против append-only event store. Event store хорошо хранит факты и replay, но бухгалтерские балансы, constraints, transactional reports и ad-hoc audit проще держать в relational ledger. Для Stripe-style системы разумно выбрать Postgres ledger + outbox: ACID на денежном core, Kafka для downstream. Event sourcing можно добавить для отдельных audit streams, но не делать его единственным способом узнать balance.
Третий trade-off: synchronous provider call в user request против async processing. Синхронный charge проще для UX, но внешний RTT 200-500ms и 3DS могут сломать latency budget. Async intent model сложнее, зато лучше переносит delayed confirmation. Часто используют гибрид: простой card charge пытается завершиться синхронно, но API возвращает requires_action или processing, если нужна 3DS, bank transfer или delayed capture.
Четвертый trade-off: strong consistency в ledger против eventual consistency в analytics/webhooks. Ledger и payment state требуют ACID и RPO=0. Webhook delivery, email, analytics и dashboard aggregates могут быть eventually consistent, но должны быть replayable и idempotent.
Stripe популяризировал PaymentIntents, idempotency keys, webhooks и балансную модель, где money movement отделен от API response. Adyen и Checkout.com похожи тем, что дают unified adapter к множеству rails, но их settlement и reconciliation сильно зависят от региона. PayPal и CloudPayments добавляют особенности wallets, recurring payments и merchant risk.
В банковских системах похожие паттерны видны в card authorization/capture, clearing и settlement. Авторизация резервирует средства, capture подтверждает списание, settlement может произойти позже. Поэтому payment API должен различать authorized, captured и settled, иначе merchant dashboard будет показывать деньги, которых еще нет на банковском счете.
В e-commerce платформе payment system редко живет одна. Она связана с order service, fraud scoring, fulfillment, subscription billing, tax, payouts и support tools. Но все эти сервисы не должны напрямую менять ledger. Они отправляют команды или события, а payment core принимает решение, фиксирует состояние и публикует результат.
Самая опасная ошибка: считать HTTP retry новым платежом. Если idempotency key не обязателен для mutating endpoints или хранится только в памяти, повторный запрос может списать деньги дважды. Вторая ошибка: писать payment.succeeded в Kafka до commit ledger transaction. При rollback downstream уже отправит webhook merchant-у о несуществующем платеже.
Третья ошибка: хранить деньги в float. Все суммы должны быть integer minor units: cents, kopecks, satoshi-like minor units, плюс ISO currency. Четвертая: удалять или перезаписывать финансовые записи вместо append-only correction. Audit trail должен объяснять, кто, когда и почему сделал refund, adjustment или chargeback.
Пятая ошибка: доверять webhook без signature verification и idempotent event_id. Provider будет ретраить webhook, attacker может отправить поддельный callback, а network может изменить порядок delivery. Шестая: держать DB transaction открытой во время внешнего PSP call. Это приводит к lock contention и connection pool exhaustion.
Не стоит строить собственный payment core для маленького продукта, если достаточно hosted checkout от Stripe, CloudPayments, YooKassa или другого PSP. Если нет требований к marketplace balances, split payments, complex refunds, multi-currency ledger и regulator audit, лучше делегировать PCI scope и reconciliation провайдеру.
Не нужен сложный saga orchestrator для одноразового invoice, который создается вручную и оплачивается через внешнюю ссылку. Там достаточно provider checkout session, webhook receiver, idempotent update order status и ручной reconciliation report. Также не стоит вводить Kafka и outbox, если весь downstream состоит из одного email и нагрузка мала; можно начать с transactional table notifications_to_send и worker-а.
Нельзя использовать эту архитектуру как оправдание для хранения raw card data. Если бизнес не сертифицирован под PCI DSS на нужном уровне, card PAN должен обрабатываться только hosted fields/tokenization provider-ом.
::concept{slug="idempotency"} для защиты mutating API от retry и duplicate submit.::concept{slug="saga-orchestration"} для long-running workflows с внешними шагами.::concept{slug="transactional-outbox"} для атомарной связки БД и событий.::concept{slug="consistency-models"} чтобы отделять ACID core от eventually consistent downstream.::concept{slug="postgres-internals"} для понимания locks, unique indexes, isolation и WAL.