Интегрируемся с банками: Saga бесконечности, или как мы начали проводить вклады онлайн

5acf6b161e997fe97fab5acbeeb8c4cd.png

Осенью 2020 года финтех в России несколько изменился: открывать вклады онлайн стало возможно не только банкам, но и сторонним финансовым платформам. В Сравни мы уже много лет помогаем клиентам сопоставлять условия по вкладам в различных банках. И с появлением новой возможности задумались о том, чтобы разработать свою платформу для проведения онлайн-вкладов. 

Под катом рассказываем о том, по каким принципам работает наша платформа, какую роль в её устройстве играет Saga MassTransit и как посредством решения происходят интеграции с банками. Плюс раскрываем процесс самой интеграции — на конкретном кейсе.

Привет, Хабр! Меня зовут Константин, я .net-разработчик команды «Депозиты» в Сравни. Над разработкой нашей платформы, позволяющей успешно интегрироваться с различными банками (и проводить вклады онлайн), мы на протяжении года работали командой из семи человек. Непосредственно я реализовывал интеграционную часть. Ниже поделюсь тем, как управление состояниями Saga помогает нам контролировать состояние онлайн-вклада на протяжении его жизненного цикла, а также в целом интегрироваться с банками. 

Для начала немного расскажу о цикле онлайн-вклада. В целом он выглядит так:

  1. Прежде чем открыть вклад, необходимо направить в банк-партнёр заявку с данными клиента и выбранными условиями вклада. Далее банк принимает решение об открытии вклада. (Перед заявкой мы как платформа ещё идентифицируем клиента, выполняем различные проверки, получаем все документы и согласия, —, но для логики статьи эти подробности уже не столь важны.)

  2. При положительном решении банк резервирует счета для вклада и фиксирует процентную ставку. 

  3. Клиент посредством платформы переводит деньги в банк, на зарезервированный для текущего вклада счёт, и после этого банк открывает счет. Если клиент не пополнит счёт по истечению некоторого времени, банк аннулирует вклад — на этом его жизненный цикл заканчивается.

  4. Далее клиент может дождаться закрытия вклада (плановое закрытие), либо закрыть вклад до окончания действия вклада (досрочное закрытие).

725f0d767691f6cd81ca065fa00d8fd7.png

Это самая простая схема, для общего понимания процесса работы вкладов. Но бывают и свои особенности:

  • Есть вклады с капитализацией, с выплатой начисленных процентов каждый месяц, с возможностью частичного снятия и пополнения, с минимальной суммой активации, бессрочные вклады — в случае с ними механика видоизменяется. 

  • В большинстве случаях, помимо синхронного ответа на запрос, банки требуют отчет об обработке запроса в виде асинхронного ответа. На простом примере: вы покупаете билеты на самолёт (запрос), авиакомпания возвращает вам номер брони (синхронный ответ), регистрирует её в глобальной дистрибутивной системе, и потом присылает вам билеты на почту (асинхронный ответ)

В команде мы решили не мешать интеграции с банками с основными процессами платформы (обозначим их «core»), поэтому выделили все интеграции в отдельный сервис. Задачи core-сервиса: выдача информации о вкладе на витрину, в личный кабинет, проверки пользователя и т.п. В результате схема выглядит уже намного сложнее.  

Про безопасность.

  • Сетевая связность. Обмен сообщениями происходит через криптошлюз. В двух словах, мы создаем отдельные туннели, привязанные к конкретным IP.

  • Достоверность сообщений. Подписывание отправляемых и проверка принимаемых сообщений с использованием ГОСТ-алгоритмов.

Авторизация между сервисами банка и маркетплейса организована по протоколу OAuth 2.0.

Исходя из всего этого нам предстояло создать платформу/систему сервисов, которая отвечала бы следующим условиям:

  • контроль за состояниями вклада, настройка правил перехода от одного состояния к другому

  • core-сервис не должен зависеть от интеграций —  у него единая модель и единый процесс

Работа с состояниями

Контроль за состоянием вклада было решено реализовать как конечный автомат, который мы представили в виде Saga-состояний от MassTransit (StateMachine).

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

Рассмотрим конечный автомат на примере среднестатического женатого мужчины — будем звать его Олег, — которого отправили в магазин за продуктами.

Начальная точка — Олег вошел в магазин.

Конечная точка — Олег купил продукты и успешно дошел до дома.


Правила смены состояний (покупок):

В сообщении от жены Олега было написано следующее:

Зайди по дороге в магазин, купи молока: если не будет безлактозного, то возьми обычное, с жирностью 0.05%.


Итак, начальная и конечная точки определены, есть список правил, а значит, мы можем построить конечный автомат:

  1. Олег вошел в магазин

  2. В магазине есть в наличии безлактозное молоко?
    2.1. Да, купить безлактозное молоко и перейти к шагу 3
    2.2. Нет. А есть ли в наличии обычное молоко с жирностью 0.05%?
    2.2.0. Да, а оно по акции?
    2.2.1. Да, купить обычное молоко и перейти к шагу 3
    2.2.2. Нет, перейти к шагу 4

  3. Олег оплачивает покупки

  4. Олег вернулся домой с продуктами или без

Saga состояний позволяет контролировать состояние вклада от его начальной точки —  заявки в банк на открытие, до конечной — закрытия или аннулирования вклада, а также, в редких случаях, отказа на открытие вклада от банка. Также Saga состояний позволяет нам возвращаться в текущее состояние (не конечное и не начальное) после некоторых переходов.

Это требуется для операций открытого вклада, таких как капитализация, выплата процентов с заданной периодичностью, частичное снятие или пополнение. К примеру, для обработки события капитализации открытого вклада мы поменяем его состояние на время обработки события, заблокировав обработку других событий по этому вкладу, и вернём его обратно в состояние открытого вклада.

Схематично смена состояний выглядит так:

ВкладОткрыт -> ПолученоСобытиеКапитализации -> ОбработкаСобытияКапитализации -> ОтправкаРезультатОбработкиСобытияКапитализацииВБанк -> ВкладЗакрыт

Обе части платформы (core и сервис интеграций) общаются между собой исключительно через Saga. Сервис интеграции отправляет событие вместе с данными в Saga — та принимает событие, меняет состояние и отправляет новое событие с данными из предыдущего в сервис core. Таким образом на обработку одного события создаётся два состояния:  

ОбработкаВСервисеCore,ОжиданиеОтправкиРезультатаОбработкиВБанк ,

и пять событий:

  • ПолученоСообщениеИзбанка 

  • ЗапросНаОбработкуСообщения

  • ОбработкаЗавершена

  • ЗапросНаОтправкуРезультатОбрбаоткиВБанк

  • РезультатОбработкиОтправленВбанк 

Это без учёта состояний/событий для ошибок обработки и отправки сообщения.

Немного о Saga состояний от MassTransit.

Правила переходов из одного состояния в другое описаны в одном классе, который наследуется от класса MassTransitStateMachine. Для него регистрируется очередь брокера сообщений (в нашем случае RabbitMq).

Сами состояния декларируем с типом State, события, меняющие состояние, декларируем с типом Event, где T — модель передаваемых данных.

Смена состояния происходит путем публикации события (T — модель передаваемых данных) в очередь брокера сообщений. При попытке совершить не декларируемый переход Saga отправит сообщение об ошибке в очередь.

Более подробнее (и с примерами) о Saga расскажем в отдельной статье.

Примерно так выглядит часть Saga, которая отвечает за обработку события аннулирования заявки:  

// Ожидаем поступления д/с
During(AwaitMoney,
    When(DealCancellationResponded) // д/с так и не пришли, от банка пришло событие Аннулирования
        // ниже посылаем команду на обработку Аннулирования
        .PublishAsync(x => x.Init(new
        {
            // здесь передаем всю необходимую информацию
        }))
        // меняем состояние
        .TransitionTo(AwaitDealCancellationProcessCompleted));

// ожидаем завершение обработки события
During(AwaitDealCancellationProcessCompleted,
    // а оно завершится в любом случае
    When(DealCancellationProcessResponded)
        // посылаем команду на отправку результата обработки события в наш интеграционный сервис,
        // который сформирует и передаст сообщение в банк
        .PublishAsync(x => x.Init(new
        {
            // здесь передаем всю необходимую информацию
        }))
        // меняем состояние
        .TransitionTo(AwaitDealCancellationTransferCompleted));

// ожидаем завершение отправки результата обработки события в банк
During(AwaitDealCancellationTransferCompleted,
    // сообщение успешно доставлено, тут все, дальше пути для заявки нет
    When(DealCancellationTransferResponded)
        .TransitionTo(DealCancelled)
        .Finalize(), // заканчиваем это все, переводим состояние в конечное, сага завершена
    // если во время отправки сообщения в банк возникла ошибка
    When(DealCancellationTransferError)
        // меняем состояние, тут еще посылается алерт в слак 
        .TransitionTo(DealCancellationTransferErrorRaised));

Жизненные пути вклада могут быть разными, в зависимости от банков. В связи с этим некоторые переходы состояний могут быть не нужны.

И тут встаёт вопрос: добавлять новое правило перехода от одного состояния к другому, минуя ненужное для конкретного банка состояние, либо имитировать этот переход к ненужному состоянию и выход из него?

Давайте рассмотрим этот вопрос на конкретных примерах.

В нашей Saga, после согласия банка на открытие вклада, мы ожидаем от клиента перевода денег на вклад — состояние Saga в этот момент AwaitMoney.

Некоторые банки отправляют нам событие о том, что деньги до них дошли (переходим в состояние AwaitActivation), а после того, как они откроют вклад, отправляют нам событие об открытии вклада (состояние DealOpen).

Другие же банки не отправляют нам событие о поступлении денег, минуя этот шаг: они отправляют событие об открытии вклада.

Так как поступить? Добавить новое правило перехода Saga от AwaitMoney к DealOpen?
Либо при поступлении события об открытии вклада от банка, отправлять фейковое событие о поступлении денег в банк, которое инициирует переход Saga в состояние AwaitActivation, а уже потом — событие об открытии вклада?

Мы выбрали второй вариант: накладываем поддержку текущей Saga на сторону интеграции с банком, т. е. имитируем фейковое событие о поступлении денег.

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

Кажется, легче всего добавить новое правило перехода для Saga, но в таком случае она будет очень запутанная, её будет сложно читать и поддерживать, а Saga — это сердце нашей платформы. Надо все-таки придерживаться принципа KISS время от времени.

С помощью Saga-состояний мы можем блокировать неожиданные действия со вкладом. К примеру, Saga не позволит обработать запрос на досрочное закрытие вклада, если он ещё не открыт. Конечно, мы не даём такой возможности пользователю на уровне веб-интерфейса, но случаи бывают разные.

fbeac2aa0a5902b9168f51de08fee50b.png

А если и придётся разбираться в жизненном пути вклада, то мы навесили трекер изменений на модель Saga (ChangeTracker), чтобы хранить историю изменений её состояний.

Независимость от интеграций

Мы построили свой сервис интеграции так, что прямое взаимодействие с адаптером банка находится на входных и выходных точках сервиса.

Мы вынесли все общее в один класс (MainClass):

  • получение команды для начала обработки сообщения от банка

  • сохранение сообщения от банка

  • отправка сообщения в Saga для последующей обработки

  • приём результата обработки от core-сервиса

  • сохранение результата обработки в базу

  • отправка в Saga результат отправки результата обработки сообщения от банка

На классы интеграции мы навесили общий интерфейс (IBankClient) для вызовов из общего класса, а также небольшой абстрактный класс для дефолтных обработок (BaseBankClient).

Теперь следим за руками — как происходит процесс обработки одного сообщения от банка:

  1. На входную точку получаем сообщение от банка, сериализуем сообщение, одновременно отправляем в MainClass и отдаем банку синхронный ответ

  2. MainClass сохраняет сообщение в БД, определяет тип сообщения и тип клиента (пусть будет MockBankClient), далее вызывает метод для обработки данного сообщения (метод интерфейса IBankClient, реализация IMockBankClient)

  3. MockBankClient получает сообщение, десериализует его в модель банка, валидирует и преобразует его в общую модель

  4. MainClass получает от MockBankClient общую модель и передает ее в сагу

  5. Обработка в сервисе core, после обработки передает команду в Saga, а та передаёт это сообщение сервису интеграций

  6. MainClass получает на вход сообщение от Saga (именно от Saga, а не от core сервиса — всё общение через Saga), отправляет результат в MockBankClient

  7. MockBankClient преобразует результат обработки в модель банка для асинхронного ответа на сообщение, сериализует его и отправляет обратно в MainClass

  8. MainClass сохраняет это сообщение в БД и вновь идет в MockBankClient

  9. MockBankClient десериализует сообщение и отправляет его в банк, результат отправки передает в MainClass

  10. MainClass передает результат отправки асинхронного ответа в банк в Saga

Посмотреть подробнее: https://app.diagrams.net/#G1tKrFdjy4UxtL7FeOElnSxW4qzkBewpEJ#%7B
Посмотреть подробнее: https://app.diagrams.net/#G1tKrFdjy4UxtL7FeOElnSxW4qzkBewpEJ#%7B

В итоге что нам необходимо сделать, чтобы заинтегрировать новый банк?

  1. Определить жизненный цикл вклада, сопоставить его с нашей Saga, добавить необходимые для нового банка шаги в Saga, замокать отправку событий, которых в новом банке нет

  2. Реализовать входящую и исходящую точки взаимодействия с банком

  3. Добавить и описать все модели банка

  4. Реализовать валидацию сообщений от банка, обработку ошибок

  5. Реализовать конвертацию моделей банка в общие модели

Первый шаг самый сложный, если жизненный цикл нового банка будет отличаться от нашего общего. На данный момент у нас есть 4 разные интеграции, под которые мы стараемся подстроить новые банки-партнеров. Но это не так просто сделать в виду того, что банкам сложнее, дороже и дольше осуществлять изменения в своих процессах.

Непосредственно интеграция: кейс с Банком ДОМ.РФ

Расскажем на реальном примере, как мы интегрировали Банк ДОМ.РФ в нашу платформу вкладов. Нам была поставлена амбициозная задача — заинтегрироваться в кратчайшие сроки.

Идем по гайду, описанному выше:

Шаг 1. Сравниваем жизненный цикл вклада от Банка ДОМ.РФ с нашей Saga.

Видим два отличия:

  • Отсутствие события поступления денег на счет в банке — «плавали, знаем»; добавляем фейковое событие.

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

    Здесь решаем заменить асинхронный ответ на согласие банка об открытии вклада на событие подписания договора клиентов — в Saga ничего не меняется, только модель и ее логическое наполнение.

Отправка асинхронного ответа на отказ банка об открытии вклада имитируем, не впервой.

С этим разобрались, Saga пережила интеграцию без изменений.

Шаг 2. Входящая и исходящая точки

С этим все прошло гладко, поскольку Банк ДОМ.РФ — первый на нашей платформе банк, использующий формат обмена сообщениями JSON, а не xml.

Согласен, что JSON, в отличие от xml, требует безмерного доверия потребителя и поставщику друг к другу, но зато удобнее. 

Шаг 3. Создание моделей 

Не заняло много времени в связи с тем, что мы договорились об использовании самого простого вклада, и дополнительные поля и варианты жизненного цикла вклада в документации не были представлены.

Шаг 4. Бизнес-правила валидаций 

Их было немного, плюс дефолтные валидации из базового абстрактного класса позволили быстро пройти этот шаг.

Шаг 5. Конвертация моделей

Здесь требовалось дополнительно подумать. Некоторых данных не хватало в разных состояниях Saga.

Первый кейс — это идентификаторы.

У нас идентификаторы хранятся в формате guid (буквы и цифры вперемешку с тире); в Банк ДОМ.РФ же необходимо передавать идентификатор в формате int (числовой, целочисленный). В ответе на заявку отдается еще один идентификатор в формате guid.

Второй кейс — номер счета для вывода средств при закрытии вклада.

В наших прошлых интеграциях, в сообщении от банка об открытии вклада передавались два банковских счета:

  • счёт самого вклада

  • счёт для вывода средств после закрытия вклада, который мы же и передали в заявке — это наш счёт в НРД

Здесь и кроется различие: счёт для вывода средств в Банк ДОМ.РФ — это не наш счёт в НРД, а счёт непосредственно в Банке ДОМ.РФ.

Деньги поступят на этот счёт, если клиент закроет вклад досрочно в ЛК банка. 

Но если клиент закроет вклад досрочно через нашу платформу или всё-таки дождется планового закрытия вклада, то деньги поступят на наш счет в НРД.

Что нам предстояло делать в итоге при обработке сообщения об открытии:

  • дополнительно передавать в общую модель номер счёта для вывода денег при досрочном закрытии на стороне банка

  • возвращать в общей модели номер счёта для обычного вывода средств, которого в сообщении нет, чтобы не нарушить общий процесс

С первым пунктом разобрались, расширив общую модель новым полем.

Со второй пунктом посложнее: для него решили выделить необходимые данные в отдельную таблицу в базе данных сервиса интеграции, в которой хранятся различные идентификаторы вклада + номер счёта для вывода средств.

Новая отдельная таблица исключительно для одного конкретного банка — это правильно выверенный шаг или костыль?

Ответить поможет только время и практика следующих интеграций.

Что ещё заслуживает внимания в нашем кейсе?

Разумеется, при подобных интеграция чрезвычайно важна отдача со стороны партнёра: быстрая и эффективная коммуникация с Банком ДОМ.РФ существенно облегчала нашу работу. 

Помимо этого хочу отметить 

  • наших сетевых гуру за их скорость и скрупулезность в настройке обеспечения сетевой связанности тестовых и продакшн сред

  • QA за четкие инструкции и кейсы, которые позволили быстро находить общий язык и общие требования в меняющихся в ходе интеграции процессах

  • разработчиков, которые эти самые требования и меняли, но что поделать

  • повелителей судеб, которые параллельно решали все юридические и бюрократические вопросы (и это в последнюю неделю перед Новым годом!)

Всё это позволило нам полностью интегрировать нашу платформу с Банком ДОМ.РФ в течение месяца, хотя изначально мы рассчитывали на первый квартал 2025.

27 декабря, в 16:06 руководитель нашей команды Илья написал в общий чат, что первые клиенты открыли вклады в Банке ДОМ.РФ через Сравни. Всё сделали, всё успели — считаем, что закрыли год однозначной победой!

В будущем планируем подготовить отдельную статью о том, как Saga MassTransit работает с состояниями. А пока готовы ответить на любые вопросы о нашей платформе и процессах интеграции — спрашивайте в комментах!

© Habrahabr.ru