Последние несколько лет я собираю платёжные интеграции для продуктов, которые банки вежливо называют «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» обычно попадают: 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 с отдельным договором с эквайером, потому что «одно окно» вас не берёт.
Дальше — четыре вещи, на которых система реально держится.
Статусы платежа — это явная машина состояний, а не набор if-ов, размазанных по сервисам:
Initiated(0) → Requested(1) → Approved(3) → Deposited(6) → Processed(7) ↓ ↓ ↓ Declined(2) Unapproved(4) Cancelled(5) / Reversed(8)
Вы точно должны знать разницу между approved (hold - деньги ещё не ваши), и charged - деньги успешно списаны. Часто Processed подразумевает гарантированный approved -> charged, поэтому транзакцию можно засчитывать как успешную.
Каждый переход валидируется на сервере перед операцией. Пришёл Deposit-запрос по платежу в статусе Declined — это баг или атака, а не «ну попробуем».
И таймаут шлюза — не отказ. Платёж мог пройти на той стороне, а ответ потеряться. Единственная легальная реакция на таймаут — вызвать 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-политика обязана это различать. У нас это выглядит примерно так: Ветка 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 — пользователь на hosted-форме шлюза, полный 3DS, из колбэка забираем Recurring.ID — токен карты. Recurring — merchant-initiated списание по токену через SOAP, у нас его крутит двухфазный cron: первая фаза смотрит циклы подписок (кому и когда пора), вторая — списывает, с той самой трёхфазной атомарностью выше.
Регуляторный момент, о который спотыкаются: сумма рекуррентного списания фиксируется в момент регистрации соглашения, снапшотом. Не читается «вживую» из текущего тарифа. Иначе продакт поднял цену в админке — и вы списали с людей больше, чем они акцептовали при подписке. Это уже не баг, это претензия. Заодно: регуляторы (ICO/FCA и их аналоги) ожидают, что по каждому соглашению хранятся Recurring.ID, Frequency, EndDate и человекочитаемое описание подписки — это ваша защита в спорах по рекуррентному биллингу. Изменять сумму можно, если это предусмотрено пользовательским соглашением, но sucsess rate снизится.
Почему один 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 — по той же причине.
Минимальный живучий стек платёжной системы для 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 не прощает оптимизма. Проектируйте из предположения, что шлюз ответит непонятным статусом, колбэк придёт дважды, таймаут скроет успешное списание, а провайдер умрёт в пятницу — и тогда всё перечисленное станет штатными ситуациями с готовым сценарием, а не инцидентами с постмортемом.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 2 | Нейросеть-автопилот вместо 400 Playwright-тестов | 0 | 7 | 07-07-2026 |
| 3 | Две недели я делал народную карту заправок. Написать код оказалось самой скучной частью работы | 0 | 5 | 07-07-2026 |
| 4 | Материалы по хакингу на русском | 5 | 7 | 07-07-2026 |
| 5 | Пирамида Поток Cash (он же CashFlow): проследил по блокчейну, куда уходят деньги «бесконечного потока» и когда рухнет | 0 | 7 | 07-07-2026 |
| 6 | Внедрение цифрового рубля: что протестировать уже сейчас, чтобы избежать сбоев и не напороться на штрафы | 0 | 7 | 07-07-2026 |
| 7 | In-memory база врёт: 5 расхождений с продовой БД | 0 | 7 | 07-07-2026 |
| 8 | Когда может пригодиться экзотика в ООП: миксины/трейты/аспекты | 0 | 5 | 08-07-2026 |
| 9 | Токенная диета для ИИ-агентов: Caveman, Ponytail и Headroom | 0 | 7 | 07-07-2026 |
| 10 | Spring Security: аутентификация через REST | 0 | 5 | 07-07-2026 |