company_banner

История разработки SDK для приема платежей в мобильном приложении на Flutter

    Привет, Хабр!

    На связи разработчики из Mad Brains. Мы специализируемся на разработке сервисов для мобильных устройств. Имеем опыт в реализации интеграционных решений, собственные продукты в сфере мобильной электронной коммерции, а также входим в 20-ку лучших мобильных разработчиков России и СНГ. Среди наших клиентов Магнит, DNS, Яндекс, Home Credit Bank, QIWI, Pfizer, OneTwoTrip! и другие крупные бренды.

    Сегодня мы хотим рассказать, как разработали SDK с открытым исходным кодом для оплаты в мобильном приложении на Flutter на основе Tinkoff Acquiring SDK для нативных приложений. 

    Как и полагается, начать стоит с истории. Мы в Mad Brains разрабатываем мобильные приложения с 2014 года и в настоящее время большую часть из них мы реализуем Flutter SDK, разработанным Google. Следили за его развитием еще с beta-версий. Мы выбираем Flutter за производительность, гибкость решений, универсальность, а также за то, что преимущества Flutter можно доступно объяснить заказчикам, что качественно выделяет нас на этапе переговоров.

    Разрабатывая новое приложение на Flutter, перед нами встала задача —  реализовать возможность оплаты товаров с помощью банковской карты, Google Pay, Apple Pay через российский банк. Готовых открытых решений на рынке не оказалось, однако к тому времени мы достаточно хорошо освоили язык программирования Dart и сам SDK, поэтому приняли решение написать собственное. 

    За основу мы взяли нативную библиотеку Тинькофф Банка и реализовали решение на языке Dart. Настало время рассмотреть все предметно. 

    Tinkoff Acquiring SDK для Flutter — это открытая библиотека, созданная специально для Flutter-разработчиков, которая позволяет внедрить интернет-эквайринг Тинькофф в мобильное приложение.

    Что мы сделали, чтобы реализовать SDK оплаты на Flutter:

    • проанализировали нативные SDK на Swift и Kotlin

    • разработали архитектуру будущего SDK

    • реализовали Acquiring SDK

    • написали инструкции по расширению функционала

    • покрыли код тестами.

    Для чего нужен Tinkoff Acquiring SDK

    SDK интегрирует в любое мобильное приложение интернет-эквайринг и подходит бизнесу, который принимает онлайн-платежи в приложениях: интернет-магазинам, маркетплейсам, тревел-компаниям, клинингу, стриминговым площадкам, фриланс-биржам и т.п.

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

    Возможности SDK:

    • Интеграция с онлайн-кассами

    • Проведение платежей (в том числе рекуррентных)

    • Создание/управление/получение информации о клиенте

    • Сохранение/управление банковскими картами клиента

    • Поддержка Proxy-server и подпись запросов на стороне вашего сервера

    • Поддержка карт с проверкой 3-D Secure 1.0 и 3-D Secure 2.0

    • Поддержка Apple Pay и Google Pay.

    Как устроена библиотека внутри?

    Основная бизнес-логика работы SDK заключается в конфигурировании и организации клиент-серверного взаимодействия с Tinkoff Acquiring API, поэтому в качестве архитектуры была выбрана довольно классическая организация.

    Диаграмма классов ядра библиотеки
    Диаграмма классов ядра библиотеки

    TinkoffAcquiring — сore-класс, реализует в себе основные публичные функции библиотеки: настройку терминала, привязку и получения списка банковских карт, создание и подтверждение платежа.

    AcquiringRequest — абстрактный класс запроса, который включает в себя следующие методы:

    • apiMethod() возвращает путь, по которому необходимо произвести обращение на сервере, используется при отправке запроса.

    • validate() выполняется проверку корректности введенных пользователем данных, таких данных как: номер телефона, e-mail, и т.д.

    • signToken() возвращает подпись запроса, используется при реализации подписи запросов на стороне вашего сервера.

    • toJson() метод сериализует объект в JSON, используется при реализации proxy-server или подписи запросов на стороне вашего сервера.

    • equals() интерфейс сравнения объектов.

    AcquiringResponse — абстрактный класс ответа, включает в себя методы:

    • метод сериализует объект в JSON.

    • equals() интерфейс сравнения объектов.

    Comparer — это расширенная версия компонента Equatable, в основе которого лежит Jenkins Hash Functions и Deep Comparison (или Deep equality), для генерации в Runtime хеш-кода объекта (hashCode), оператора сравнения (==) и метода по преобразованию в строку (toString). Мы оптимизировали код, адаптировали компонент для работы с коллекциями и переделали вывод строки.

    NetworkClient — нужен для работы с сетью, в основе него лежит пакет http.

    Logger позволяет журналировать данные при запросах. Он полезен при отладке оплаты, либо когда запросы идут через ваш сервер. Его можно переопределить на собственный логгер, для этого достаточно определить наследника от BaseLogger и реализовать его.

    CryptoUtils — вспомогательный класс, включает в себя функции для работы с такими алгоритмами шифрования, как RSA и SHA256.

    Так как Flutter является UI-фреймворком, который дает возможность делать UI любой сложности, мы не стали ограничивать других разработчиков нашим интерфейсом. По этому из UI у нас только два виджета:

    1. CollectData виджет-оверлей, который вы никогда не увидите, так как он собирает данные для 3-D Secure 2.0

    2. WebView3DS виджет WebView для прохождения 3-D Secure 1.0 и 3-D Secure 2.0

    Как интегрировать библиотеку в свое приложение?

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

    Затем подключаем библиотеку в проект, добавляя следующую строчку в pubspec.yaml:

    dependencies:
      tinkoff_acquiring: 1.0.2

    Инициализируем экземпляр класса TinkoffAcquiring с полученным ключем терминала и паролем продавца.

    final TinkoffAcquiring acquiring = TinkoffAcquiring(
        terminalKey: 'terminalKey', // ключ от терминала продавца
        password: 'password', // пароль продавца
    );

    Следующим этапом нам необходимо создать сессию. Отправляем запрос на создание новой платежной сессии.

    final InitResponse init = await acquiring.init(InitRequest(
        180, // id заказа
        customerKey: customerKey, // ключ продавца
        amount: amount, // сумма в копейках
        … // другие параметры
    ));

    В ответ нам придет информация о статусе выполнения запроса и информация о платежной сессии. Самое главное на данном этапе получить paymentId, так как он отвечает за всю платежную сессию и только через него можно узнать ее статус. 

    Следующим шагом нам необходимо пройти 3-D Secure. Это дополнительный шаг аутентификации используется при проведении любых онлайн-платежей с карты, позволяет банкам убедиться, что платеж совершает именно держатель карты и защититься от мошеннических операций. Подробнее о протоколе можно прочитать здесь.

    На текущий момент существует две версии протокола 3-D Secure. Обе они состоят из трех шагов проверки: на стороне эквайера, на стороне эмитента и на стороне платежной системы, отсюда и название 3D, три стороны. На финальном этапе проверки у пользователя запрашивается одноразовый код. Основное отличие версии 2.0 в том, что при выполнении аутентификации вместе с криптограммой карты высылаются мета-данные об устройстве, которые позволяют упростить проверку и запрашивать код подтверждения только в 5% случаев.

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

    // Создадим криптограмму карточных данных
    String cardData = CardData(
        2201382000000047, // номер карты
        '1220', // месяц год, слитно
        '123', // cvv
        cardHolder: 'T. TESTING', // имя держателя, как на карте
    ).encode(publicKey); // для того чтобы зашифровать это все нужно иметь RSA publicKey, который можно получить в ЛК терминала
    
    final Check3DSVersionResponse check3DSVersion =
        await acquiring.check3DSVersion(Check3DSVersionRequest(
            int.parse(init.paymentId), // id из платежной сессии
            cardData, // криптограмма карточных данных
        ));

    Дальше может быть 2 варианта:

    1. У карты нет проверки по 3DS или она версии 1.0, можно заканчивать оплату методом finishAuthorize().

    2. У карты 3DS версии 2.0, необходимо запустить CollectData() (сбор данных о пользователе и его гаджете), после чего можно выполнить  оплату методом FinishAuthorize() и передать в него информацию из CollectData().

    Мы обработаем сразу два исхода.

    final Completer<Map<String, String>> data =
            Completer<Map<String, String>>();
       // Если 3DS второй версии, собираем данные
        if (check3DSVersion.is3DsVersion2) {
          CollectData(
            context: context, // BuildContext необходим, так как проверка происходит через WebView
            acquiring: acquiring,
            serverTransId: check3DSVersion.serverTransId, // данные пришедшие из проверки версии 3DS
            threeDsMethodUrl: check3DSVersion.threeDsMethodUrl, // данные пришедшие из проверки версии 3DS
            onFinished: (Map<String, String> v) {
              data.complete(v); // получаем информацию о проверки 3DS
            },
          );
        } else {
           // Если 3DS не второй версии, записываем null
          data.complete(null);
        }
    
        // производим оплату
        final FinishAuthorizeResponse fa =
            await acquiring.finishAuthorize(FinishAuthorizeRequest(
          int.parse(init.paymentId), // id из платежной сессии
          cardData: cardData, // криптограмма карточных данных
          data: await data.future, // данные из 3ds второй версии
          … // другие параметры
        ));

    После того как мы создали запрос на оплату в ответ на FinishAuthorize() нам может прийти запрос на классическую проверку 3DS (первой версии). Так происходит, когда у нас карты изначально была с первой версией 3DS, либо проверка по второй версии не подтвердилась и требуется подтверждение. В любом случае без этой проверки оплата не засчитывается банком, поэтому обработаем и этот кейс.

    // Проверяем есть ли запрос на прохождение классической проверки
    if (fa.status == Status.threeDsChecking) {
          // Открываем любым способом окно с WebView3DS и передаем в него параметры
          Navigator.of(context).push(MaterialPageRoute<void>(
            builder: (BuildContext context) => Scaffold(
              body: WebView3DS(
                acquiring: acquiring,
                is3DsVersion2: fa.is3DsVersion2 || check3DSVersion.is3DsVersion2,
                serverTransId: fa.serverTransId ?? check3DSVersion.serverTransId,
                acsUrl: fa.acsUrl,
                md: fa.md,
                paReq: fa.paReq,
                acsTransId: fa.acsTransId,
                version: check3DSVersion.version,
                onLoad: (bool v) {...},
                onFinished: (Submit3DSAuthorizationResponse v) {...},
              ),
            ),
          ));
        }

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

    final ConfirmResponse value = await acquiring.confirm(
        ConfirmRequest(int.parse(getState.paymentId))
    );

    Если все прошло успешно, то оплата принимается банком, деньги списываются с карты, а товар считается оплаченным. Чтобы узнать статус платежной сессии, можно выполнить метод GetState().

    final GetStateResponse getState = await acquiring.getState(
            GetStateRequest(int.parse(init.paymentId)),
          );

    Google Pay и Apple Pay

    Google Pay и Apple Pay
    Google Pay и Apple Pay

    В случае оплаты через Google Pay и Apple Pay вместо запроса у сервера метода аутентификации через Check3DSVersionResponse(), мы обращаемся к системному SDK, которое хранит токен карты, и получаем эту информацию оттуда, а проверка по 3DS (да, она тут тоже есть) происходит после FinishAuthorize (без CollectData).

    final InitResponse init = await acquiring.init(InitRequest(
          180, // id заказа
          customerKey: customerKey, // ключ продовца
          amount: amount, // сумма в копейках
          … // другие параметры
        ));
    
    // производим оплату
    final FinishAuthorizeResponse fa =
            await acquiring.finishAuthorize(FinishAuthorizeRequest(
          int.parse(init.paymentId), // id из платежной сессии
          encryptedPaymentData: encryptedPaymentData, // данные из Apple/Google Pay
          route: 'ACQ',
          source: GooglePay // ApplePay или GooglePay
          … // другие параметры
        ));
    
    // Проверяем есть ли запрос на прохождение классической проверки
    if (fa.status == Status.threeDsChecking) {
          // Открываем любым способом окно с WebView3DS и передаем в него параметры
          Navigator.of(context).push(MaterialPageRoute<void>(
            builder: (BuildContext context) => Scaffold(
              body: WebView3DS(
                acquiring: acquiring,
                is3DsVersion2: fa.is3DsVersion2,
                serverTransId: fa.serverTransId,
                acsUrl: fa.acsUrl,
                md: fa.md,
                paReq: fa.paReq,
                acsTransId: fa.acsTransId,
                onLoad: (bool v) {...},
                onFinished: (Submit3DSAuthorizationResponse v) {...},
              ),
            ),
          ))

    Для поддержки бесконтактной оплаты мы выпустили отдельную библиотеку mad_pay. Она позволяет легко реализовать поддержку Google Pay / Apple Pay с любым банком-эквайером на Flutter. От разработчика требуется реализовать только транспорт данных до терминала.

    С какими проблемами столкнулись

    • Неактуальная и неполная документация. Большинства методов API нет в документации, часто не совпадали типы и названия полей.

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

    • Нет набора тестовых карт. Точнее они есть в личном кабинете, но это тесты конкретных случаев (и то не всех) для конкретного терминала.

    Пример живой интеграции

    В октябре мы выпустили в релиз мобильное приложение для сети кинотеатров «‎Мираж Синема»‎, где успешно реализовали портирование библиотек интернет-эквайринга на Flutter . 

    В приложении можно купить билет на конкретный фильм, выбрав сеанс, время и место, а также билет с открытой датой. Помимо этого через приложение можно купить специальные очки для просмотра 3D-фильмов, а также сделать и оплатить заказ из кинобара. Среди доступных способов есть возможность оплатить банковской картой, указав данные карты, а также через провести оплату через Apple и Google Pay. 

    Userstory оплаты c использованием Tinkoff Acquiring SDK
    Userstory оплаты c использованием Tinkoff Acquiring SDK

    Планы на будущее

    На этом работа с библиотекой не закончена. Мы уже выпустили дополнения и дальше планируем поддерживать и добавлять новый функционал в SDK.

    В скором времени мы планируем добавить следующие возможности: 

    • Оплата по QR-коду

    • Сканирование и распознавание номеров банковских карт с помощью камеры и NFC

    Полезные ссылки

    • Пакет для оплаты банковской картой tinkoff_acquiring на pub.dev

    • Пакет для поддержки оплаты через Google Pay и Apple Pay mad_pay на pub.dev

    • Исходный код библиотеки Tinkoff Acquiring SDK на GitHub

    • Исходный код библиотеки Mad Pay на GitHub

    • Исходный код примера интеграции на Github

    Надеюсь, статья оказалась полезной для вас. Подписывайтесь на наш YouTube-канал, где мы каждую неделю выкладываем доклады о мобильной разработке на Flutter.

    TINKOFF
    IT’s Tinkoff — просто о сложном

    Комментарии 2

      +1
      Хорошая статья. Подробно расписано как работать с либой. Спасибо.
        0
        Спасибо за приятные слова.

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

      Самое читаемое