Интеграция платёжных систем в high-risk вертикалях: архитектура, риски и практический опыт

от автора

Последние несколько лет я собираю платёжные интеграции для продуктов, которые банки вежливо называют «high-risk». Под «high-risk» обычно попадают: iGaming / betting dating (особенно adult dating) crypto onboarding / exchange ramps nutra / supplements travel / subscriptions с free-trial моделью forex / trading / микрозаймы

Один проект — подписочный сервис на NestJS/TypeORM с рекуррентными списаниями через SOAP-шлюз прибалтийского банка-эквайера. Второй — кредитный продукт на Python/FastAPI, где под капотом крутятся три локальных PSP одновременно. Обе системы живут в проде, обе списывают реальные деньги с реальных карт, и обе научили меня вещам, которых нет ни в одной документации шлюза.

Эта статья — не теория из учебника по финтеху. Это то, что я бы хотел прочитать до того, как первый раз задеплоил cron, который умеет списывать деньги.

Что такое high-risk и почему платежи там — отдельная боль?

High-risk вертикали формально это просто набор MCC-кодов, по которым у эквайеров статистически высокий chargeback ratio, много фрода и регуляторных рисков. Практически это означает вот что:

Stripe и Adyen вас не возьмут. Точнее, возьмут, проведут пару месяцев транзакций, а потом заморозят баланс «до выяснения». Те, кто возьмёт осознанно, выставят rolling reserve 5–10% (часть выручки замораживается на 90–180 дней), расчёты T+3…T+7 вместо привычного T+1, комиссию в разы выше и анкету KYC, после которой вы будете знать о своём бизнесе больше, чем ваш бухгалтер.

И главное — вас могут отключить письмом в пятницу вечером. Без предупреждения, без «давайте обсудим». Просто MID перестаёт принимать транзакции, а деньги за последнюю неделю зависают. Из этого факта вырастает вся архитектура, о которой пойдёт речь ниже.

Основные проблемы

Фрод. Card testing — боты прогоняют через вашу форму сотни краденых карт мелкими суммами, чтобы проверить, какие живые. Вы для них бесплатный валидатор. Каждая такая «проверка» — это авторизация, которая портит вашу статистику у эквайера, даже если деньги не списались. Как вы понимаете для каждой интеграции необходимо уточнить карту таких кодов, а потом установить внутренние механизмы для дополнения этой карты, все новые коды должны периодически анализироваться и пополнять ветку DEAD при необходимости.

Чарджбеки — клиент оспаривает платеж по карте через банк. Порог у Visa и Mastercard — в районе 0.9–1% от оборота. Превысили — попадаете в monitoring program (VDMP, ECM), это штрафы и план исправления. Не исправились — расторжение договора и запись в MATCH-list (он же TMF). Это чёрный список эквайеров, из которого не выходят пять лет. Для платёжного бизнеса MATCH — это смерть, а не проблема.

Отдельная категория — «дружественный фрод»: клиент реально платил, но в выписке видит непонятный дескриптор и оспаривает транзакцию. Поэтому дескриптор должен совпадать с тем, что клиент видел на сайте. Это скучное требование экономит больше денег, чем половина антифрода.

Банки и комплаенс

PCI DSS определяет, что вам вообще можно делать с картами. Наш путь только hosted payment form: шлюз показывает свою форму, вы карту не видите никогда. 3DS — это не про «дополнительную защиту», это про liability shift: при полной аутентификации (ECI 05/02) ответственность за чарджбек уходит на банк-эмитент, при её отсутствии (ECI 07) — остаётся на вас. В high-risk разница между этими двумя цифрами — это разница между бизнесом и его отсутствием.

Блокировки и внезапные отключения

Лимиты MID кончаются («gateway limits exceeded»), банк меняет риск-аппетит, шлюз проводит техработы в три ночи. Любой провайдер может умереть сегодня — проектировать нужно из этого предположения.

Архитектура платёжной интеграции

Сначала терминология, потому что её путают постоянно. Acquirer— банк-эквайер, у которого открыт ваш merchant account (MID), он двигает деньги. Gateway— техническая труба между вами и эквайером: API, шифрование, 3DS-оркестрация. PSP— обычно gateway + эквайринг + риск-менеджмент в одном окне. В high-risk чаще работаешь через IPSP/gateway с отдельным договором с эквайером, потому что «одно окно» вас не берёт.

Дальше — четыре вещи, на которых система реально держится.

State machine — не опция, а фундамент

Статусы платежа — это явная машина состояний, а не набор if-ов, размазанных по сервисам:

Initiated(0) → Requested(1) → Approved(3) → Deposited(6) → Processed(7) ↓ ↓ ↓ Declined(2) Unapproved(4) Cancelled(5) / Reversed(8)

Три правила, выстраданные на проде

  1. Вы точно должны знать разницу между approved (hold — деньги ещё не ваши), и charged — деньги успешно списаны. Часто Processed подразумевает гарантированный approved -> charged, поэтому транзакцию можно засчитывать как успешную.

  2. Каждый переход валидируется на сервере перед операцией. Пришёл Deposit-запрос по платежу в статусе Declined — это баг или атака, а не «ну попробуем».

  3. И таймаут шлюза — не отказ. Платёж мог пройти на той стороне, а ответ потеряться. Единственная легальная реакция на таймаут — вызвать GetPayment и узнать фактическое состояние. Считать таймаут отказом — потенциальное двойное списание. Считать успехом — подарок фродерам.

Идемпотентность — на трёх уровнях

Серверные колбэки шлюз ретраит до пяти раз, если не получил ответ за пару секунд. Значит, ваш обработчик обязан переживать дубликаты:

Здесь три слоя защиты. Первый — уникальный Order.ID на каждую попытку платежа; никогда не повторяйте потенциально успешную операцию с новым ID, шлюз должен уметь ответить «уже проведено». Второй — сырые колбэки пишутся только через INSERT, без UPDATE: дубликаты в этой таблице — фича, а не баг, это ваш лог для реконсиляции спорных транзакций. Третий — UNIQUE constraint на paymentId в таблице зачислений как последний рубеж, когда всё остальное не сработало.

Про последний рубеж — боевой кейс. У нас зачисление средств пользователю жило в одной транзакции, а перевод платежа в финальный статус — в другой, без блокировки. Ретрай колбэка от шлюза стабильно успевал проскочить в зазор между ними: первая обработка ещё не флипнула статус, вторая видела «необработанный» платёж и зачисляла ещё раз. Двойной credit, живые деньги. Лечится это скучно: одна транзакция на всё, SELECT ... FOR UPDATE на строку платежа, и UNIQUE на зачислении — потому что race condition вы когда-нибудь всё равно пропустите, а constraint не пропустит.

Сетевой вызов и транзакция БД — враги Самый дорогой антипаттерн

SOAP/HTTP-вызов к шлюзу внутри транзакции БД. Выглядит невинно — «атомарно же». На деле, если процесс упал между вызовом шлюза и коммитом, транзакция откатилась, и у вас в базе нет никаких следов того, что вы вообще ходили списывать деньги. Ретрай — и второе списание.

Правильная схема — три фазы: prepareChargeAttempt() // REQUIRES_NEW: INSERT попытки со статусом INTENT + commit ↓ SOAP charge // строго ВНЕ любой транзакции ↓ finalizeChargeAttempt() // отдельная транзакция: статус по фактическому ответу

Запись о намерении коммитится до сетевого вызова. Упали после вызова — при рестарте видим попытку в статусе INTENT, дёргаем GetPayment, узнаём правду. INSERT-before-call — это и есть идемпотентность на уровне списаний, всё остальное — украшения.

Retry-стратегии

Отказ отказу рознь, и retry-политика обязана это различать. У нас это выглядит примерно так: Ветка A — hard stop, очень важно блокировать дальнейшие списания с карт, которые выдали такие ошибки, рейтинг мерчанта на дороге не валяется. AUTHENTICATION_DECLINED EXPIRED_CARD
SUSPECTED_FRAUD UNSUPPORTED_CARD CLOSED_ACCOUNT
LOST_CARD
STOLEN_CARD
INVALID_TRANSACTION
SYSTEM_MALFUNCTION

Ветка B — soft retry, проблема не в карте и не в клиенте, а в “состоянии системы”, подождать и попробовать снова, желательно от 24 часов. TRANSACTION_DECLINED
WITHDRAWAL_FREQUENCY_LIMIT ISSUER_UNAVAILABLE
RECONCILE_ERROR

Ветка C — денег нет / лимиты, транзакция “почти ок”, но нужно изменить условия платежа, изменить сумму (если соглашение позволят) или способ оплаты, потом retry, желательно от 8 часов, зависит от rate limits. INSUFFICIENT_BALANCE
EXCEED_AMOUNT_LIMIT

На практике оказалось, что ветка для insufficient funds окупается лучше всего: у аудитории кредитных и подписочных продуктов деньги на карте появляются волнами (зарплата, переводы), и терпеливый ретрай раз в 12 часов дособирает заметный процент выручки. А вот ретрай hard decline — это способ испортить fraud ratio и получить вопросы от эквайера.

Санкционные коды выделены отдельно не для красоты: по ним останавливается не попытка, а вся подписка, независимо от того, насколько вертикаль «хочет» деньги. Это та граница, где технический вопрос становится юридическим.

Криптография: какая есть, а не какая хочется

Классический конверт SOAP-шлюза: генерируем одноразовый AES-256 session key, шифруем им XML-payload (поле DATA), сам ключ шифруем RSA публичным ключом шлюза (поле KEY), оригинальный payload подписываем своим приватным ключом (SIGNATURE). На ответе — сначала проверка подписи шлюза, потом расшифровка. Session key одноразовый всегда.

Ваша зона ответственности в этой ситуации — то, что вокруг: приватные ключи в secrets manager, поле KEY никогда не попадает в логи, а в сырых XML перед записью в БД маскируется PAN (формат 411111XXXXXX1111) — у нас для этого отдельная функция, через которую обязан пройти любой XML до INSERT. Это прямое требование PCI DSS, и аудитор его проверит первым делом.

Рекуррентные платежи: initial и MIT

Подписки строятся из двух разных операций. Initial — пользователь на hosted-форме шлюза, полный 3DS, из колбэка забираем Recurring.ID — токен карты. Recurring — merchant-initiated списание по токену через SOAP, у нас его крутит двухфазный cron: первая фаза смотрит циклы подписок (кому и когда пора), вторая — списывает, с той самой трёхфазной атомарностью выше.

Регуляторный момент, о который спотыкаются: сумма рекуррентного списания фиксируется в момент регистрации соглашения, снапшотом. Не читается «вживую» из текущего тарифа. Иначе продакт поднял цену в админке — и вы списали с людей больше, чем они акцептовали при подписке. Это уже не баг, это претензия. Заодно: регуляторы (ICO/FCA и их аналоги) ожидают, что по каждому соглашению хранятся Recurring.ID, Frequency, EndDate и человекочитаемое описание подписки — это ваша защита в спорах по рекуррентному биллингу. Изменять сумму можно, если это предусмотрено пользовательским соглашением, но sucsess rate снизится.

Практика: мульти-PSP routing и failover

Почему один PSP почти никогда не работает стабильно. Не потому, что провайдеры плохие. Конверсия скачет по BIN-диапазонам: карты одного банка-эмитента отлично проходят через провайдера A и массово отклоняются через B, у другого банка — наоборот. Лимиты MID конечны. Риск-аппетит эквайера меняется без предупреждения. Техработы случаются. В кредитном проекте мы в итоге держим трёх провайдеров одновременно, и за год каждый из них хотя бы раз становился «тем самым, который лежит».

Circuit breaker на провайдера: success rate в скользящем окне упал ниже порога — провайдер выключается из ротации автоматически, ops получает алерт, трафик перетекает на остальных. Возвращение — тоже автоматическое, через пробные транзакции. Никакой магии, обычный health-based routing, но именно он превращает «провайдер лёг в пятницу» из инцидента в строчку в отчёте.

Failover-каскад — с двумя жёсткими оговорками. Каскадировать (перекинуть транзакцию на следующего провайдера) можно только системные отказы: таймаут, пятисотки, превышение лимитов. И только после проверки через GetPayment, что первый провайдер реально не списал — иначе каскад превращается в генератор двойных списаний. Вторая оговорка важнее: hard decline не каскадируется никогда. Код 100 или antifraud-блок — это сигнал «с картой что-то не так»; прогнать её тут же через второго эквайера — значит перенести фрод-статистику и туда. Каскадирование антифрод-отказов — самый быстрый известный мне способ потерять оба MID-а сразу.

И про дескрипторы с MCC, раз уж зашла речь о нескольких эквайерах: несколько MID под разные валюты и регионы — нормальная практика. А вот игры с несоответствием заявленного MCC реальному бизнесу эквайеры ловят на раз (у них для этого целые отделы), и заканчивается это не штрафом, а MATCH-листом. Не надо.

Риски и антифрод

Velocity checks — самое дешёвое и самое эффективное. Считаем частоту попыток по карте, IP, email и device fingerprint в скользящих окнах: больше N попыток разными картами с одного устройства за час — карантин. Именно velocity останавливает card testing, потому что боту нужны десятки попыток, а живому человеку — одна-две.

Device fingerprinting, здесь не про слежку, а про то, чтобы отличить «человек опечатался в CVV и пробует снова» от «один браузер перебирает двухсотую карту». Canvas, набор шрифтов, часовой пояс, поведенческие сигналы — готовые SDK это делают лучше самописных, брать своё имеет смысл только из-за цены.

Blacklist/whitelist, чёрный список — хэши PAN, email и fingerprint после чарджбека или antifraud-кода от шлюза. В белый — верифицированные плательщики, которых глупо гонять через полный скоринг. Списки без TTL и процесса разбора со временем превращаются в свалку, которая режет конверсию — чистка раз в квартал обязательна.

Ошибки, которые совершают почти все

Один провайдер и «второго добавим потом». Потом наступает в пятницу вечером, вместе с заблокированным MID, и оказывается, что onboarding у резервного эквайера занимает шесть недель.

Бизнес-логика синхронно в webhook-обработчике. Шлюз ждёт ответ две секунды, вы за это время начисляете бонусы, шлёте письмо и пересчитываете статистику. Не успели — шлюз ретраит — здравствуйте, дубли. Принял, записал сырьё, ответил, обработал асинхронно. Всегда в этом порядке.

UPDATE вместо append-only для входящих колбэков. Через полгода прилетает спорная транзакция, а восстановить, что именно и в каком порядке присылал шлюз, уже не по чему.

Отсутствие мониторинга подвисших транзакций. Один SQL — «платежи в промежуточных статусах старше 30 минут» — плюс алерт в Grafana ловят проблемы раньше и дешевле, чем любой антифрод. Деньги теряются не в громких инцидентах, а в тихо застрявших платежах, которых никто не видит.

Таймаут интерпретируют как отказ (см. выше, почему это двойное списание в перспективе). И ретраи без idempotency-guard — по той же причине.

Что реально работает в 2025–2026

Минимальный живучий стек платёжной системы для high-risk, по моему опыту, выглядит скучно: явная state machine с валидацией переходов; идемпотентность на трёх уровнях (уникальный Order.ID на попытку, INSERT-before-call, UNIQUE на зачислении); append-only лог всех входящих колбэков; типизированные коды ответов с ветками hard/soft/system и разными retry-политиками; минимум два провайдера с health-based routing и circuit breaker; мониторинг stuck-транзакций; kill switches на все исходящие денежные операции.

Заметьте, чего в списке нет: Kafka, микросервисов, event sourcing на старте. Postgres или MySQL, очередь задач, cron и дисциплина в транзакциях спокойно везут до оборотов, при которых у вас уже будет команда, чтобы усложнять осознанно. В платёжке скучная технология — это комплимент: скука означает предсказуемость, а предсказуемость означает, что деньги доезжают до счёта.

High-risk не прощает оптимизма. Проектируйте из предположения, что шлюз ответит непонятным статусом, колбэк придёт дважды, таймаут скроет успешное списание, а провайдер умрёт в пятницу — и тогда всё перечисленное станет штатными ситуациями с готовым сценарием, а не инцидентами с постмортемом.

ссылка на оригинал статьи https://habr.com/ru/articles/1056698/