Всем привет! Это Мурат Насиров и Артем Баркалов, мы Flutter-разработчики в Friflex. Разрабатываем высоконагруженные мобильные приложения для бизнеса и специализируемся на Flutter. В этой статье мы собрали большую часть кейсов, с которыми вы можете столкнуться при интеграции кнопки оплаты SberPay в приложении на Flutter. Это поможет вам понять механику работы СберПэй и шаги, которые необходимо сделать для передачи событий из натива во Flutter.

Подготовка

Использование SberPay SDK в продакшене требует наличия установленного приложения «Сбербанк Онлайн» (СБОЛ), иначе в процессе оплаты произойдет ошибка. Тестирование можно провести либо на симуляторе iOS, либо на реальном устройстве iOS или Android. На эмуляторе Android тестирование не выйдет — при попытке оплаты возникает ошибка. Разработчики SDK используют аппаратные возможности смартфонов: геолокацию, bluetooth, Wi-Fi для снижения вероятности совершения мошеннических операций (условный антифрод).

Первым делом разработчик узнает от Сбербанка (по договору о подключении SberPay SDK в приложение), логин/пароль (credentials) и ссылку, которые указываются в файле build.gradle проекта (приложения) в части Android для получения библиотеки. Либо отдельно запрашивается aar-бандл. Для подключения SDK на iOS выдадут отдельную ссылку на репозиторий, хотя он публичный

Вместе с данными для получения SDK выдают тестовые данные для регистрации заказа в шлюзе Сбера, а также специальные apiKey и merchantLogin, которые используются для инициализации SberPay SDK в вашем приложении. Тестовые данные не подходят для проверки списания реальных денежных средств с реальной карты, они исключительно для тестирования.

Регистрация заказа в шлюзе Сбера

В документации описан процесс регистрации заказов в платежном шлюзе Сбера, откуда необходимо в случае успешного ответа получить значение ключа sbolBankInvoiceId. То есть при оформлении заказа бекенд вашего проекта должен отправить запрос с параметрами, которые указаны в документации, откуда затем получить sbolBankInvoiceId и передать его мобильному приложению. С помощью коллбэков, которые Сбер будет отсылать вашему бекенду, будет понятно, оплачен заказ или нет.

Для тестовой регистрации заказа достаточно отправлять запросы через Postman (файл):

POST https://3dsec.sberbank.ru/payment/rest/register.do, где register.do - одностадийная оплата:

{
  "userName": "testUserName", // логин ЛК Сбера, выдается по договору
  "password": "testPassword", // пароль ЛК Сбера, выдается по договору 
  "orderNumber": "e2574f1785324f1592d9029cb05adbbd", // уникальный номер заказа
  "amount": 19900, // сумма к оплате в копейках
  "returnUrl": "sbersdk://spay", // диплинк на приложение, возвращает к СДК
  "jsonParams": {
    "app2app": true, // Если true, в ответе придет sbolBankInvoiceId
    "app.osType" : "android", // Тип ОС (можно всегда запрашивать с таким)
    "app.deepLink": "sbersdk://spay" // диплинк на приложение, возвращает к СДК
  }
}

Пример ответа:

{
    "orderId": "1a8fb4ab-fe19-7372-94b6-2deb29335df0",
    "formUrl": "https://secure-payment-gateway.ru/payment/merchants/sbersafe_sberid/payment_ru.html?mdOrder=1a8fb4ab-fe19-7372-94b6-2deb29335df0",
    "externalParams": {
        "sbolInactive": "false",
        "sbolBankInvoiceId": "72e48b040afb4483b0a8c13c77e7e6f2",
        "sbolDeepLink": "sberpay://invoicing/v2?bankInvoiceId=72e48b040afb4483b0a8c13c77e7e6f2&operationType=app2app"
    }
}

Поля returnUrl и app.deeplink должны служить диплинком для перехода обратно в мобильное приложение после авторизации через Сбербанк (либо СБОЛ), однако по факту они нигде не используются. Тем не менее, лучше их указывать в таком формате и обязательно сообщать в беседе с разработчиками SDK, что будет использоваться диплинк формата <вашасхема>://spay. Его нужно зарегистрировать на бекенде Сбера. Дальше он пригодится в реализации iOS-части плагина.

Если по каким-то причинам во время оплаты через модальное окно СберПэя произошла именно ошибка (не отмена), тогда sbolBankInvoiceId становится использованным, и зарегистрировать заказ с таким же orderNumber не получится. Одним из решений может быть перевод заказа в статус ожидания оплаты на бекенде. После чего можно оплатить заказ другим способом: например, через эквайринг. На iOS и Android по-разному реализована реакция на отмену оплаты, об этом — подробности ниже.

Создание плагина

Для создания плагина на Flutter используется команда (документация):

flutter create --org plugin.sdk --template=plugin --platforms=android,ios sber_pay

Где:

  • plugin.sdk — путь до исполняемого файла плагина на Android-стороне;

  • --template=plugin — указание, что создаётся именно плагин;

  • sber_pay — название плагина.

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

Внедрение Android-части

Минимальная версия Android SDK - 21.

Есть 2 пути интеграции SDK.

Структура папок Android
Структура папок Android

1) Интеграция через Maven репозиторий

В файле android/build.gradle, внутри android {...}, нужно найти dependencies и указать:

dependencies {
   implementation( 'ru.spaymentsplus.libraries:spaysdk:1.2.4' ) { transitive = true }
}

По договору вам передадут секретные данные для получения aar-бандла. Вообще, как сказали разработчики SDK, если вы планируете отправлять свое приложение в удаленный репозиторий, то достаточно сделать его приватным и давать доступ только команде. Если же вы работаете один, можно рассмотреть такой способ. 

Создаем файл sber_pay/example/android/sberpay.properties и указываем переменные:

sPayUrl=ссылка из договора для получения бандла
sPayUsername=логин из договора для получения бандла
sPayPassword=пароль из договора для получения бандла

В файле sber_pay/example/android/.gitignore указываем sberpay.properties как игнорируемый, чтобы он не попал в удаленный репозиторий.

Теперь переходим в build.gradle и получаем aar-бандлы по указанным в sberpay.properties данным:

def sberpayProperties = new File('sberpay.properties')
def properties = new Properties()
properties.load(sberpayProperties.newDataInputStream())

def link = properties.getProperty('sPayUrl')
def login = properties.getProperty('sPayUsername')
def pass = properties.getProperty('sPayPassword')

allprojects {
   repositories {
       google()
       mavenCentral()
       maven {
           name = "GitHubPackages"
           url = uri(link)
           credentials {
               username = login
               password = pass
           }
       }
   }
}

Таким образом удастся получить библиотеки и при этом не раскрыть секретные логин/пароль/ссылку. Насколько удалось выяснить, получение бандлов таким способом вызвано тем, что SberPay SDK использует специальную библиотеку для профилирования, у которой есть требования к безопасности. Из-за этого этот SDK (по крайней мере на Android стороне) не может быть публичным.

2) Интеграция вручную

Получив aar-бандлы, создаем в папке android папку libs и перемещаем туда библиотеки. Затем в build.gradle подключаются эти библиотеки вместе с транзитивными зависимостями. Внутри android {...} нужно найти dependencies и указать

dependencies {
   // .aar бандлы SberPay SDK
   implementation files('../libs/bms-sdk-fingerprint_VERSION_release.aar')
   implementation files('../libs/SDK-VERSION.aar')

   // Транзитивные библиотеки, необходимые для работы SberPay SDK
   implementation 'com.google.android.material:material:<version>'
   implementation 'io.github.sberid:SberIdSDK:<version>'
   implementation 'com.google.dagger:dagger:<version>'
   implementation 'androidx.lifecycle:lifecycle-runtime-ktx:<version>'
}

Подключаемые здесь aar-бандлы, если отправляются в контроль версий, должны храниться только в приватном репозитории.

Настройка проекта

После получения SberPay SDK нужно установить minSdkVersion 21 в sber_pay/android/build.gradle:

defaultConfig {
   minSdkVersion 21
}

Проверьте, что в sber_pay/example/android/app/build.gradle также указана 21 версия:

defaultConfig {
   …
   minSdkVersion 21
}

Еще в приложении стоит добавить разрешения для доступа к геолокации в файле sber_pay/example/android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_UPDATES" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Это увеличит шансы для совершения успешной оплаты.

Android-плагин и нативная кнопка

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

В документации к СберПэю предлагают создать специальный PlatformView, который затем разворачивается на стороне Flutter-приложения. Делать это необязательно, в Flutter-части этот процесс будет описан подробнее.

Пока что настроим файл плагина sber_pay/android/src/main/kotlin/plugin/sdk/sber_pay/SberPayPlugin.kt:

Настройка SberPayPlugin.kt
/**
* Плагин для оплаты с использованием SberPay. Для работы нужен установленный Сбербанк (либо Сбол).
*/
class SberPayPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {

   private lateinit var channel: MethodChannel
   private lateinit var binding: FlutterPluginBinding
   private lateinit var activity: Activity
   private lateinit var context: Context

   /** Кнопка для управления оплатой **/
   private lateinit var button: SPayButton

   override fun onAttachedToEngine(flutterPluginBinding: FlutterPluginBinding) {
       channel = MethodChannel(flutterPluginBinding.binaryMessenger, "sber_pay")
       binding = flutterPluginBinding
       context = flutterPluginBinding.applicationContext
       channel.setMethodCallHandler(this)
   }

   override fun onMethodCall(call: MethodCall, result: Result) {
       when (call.method) {
           // Инициализация
           "init" -> {
               initialize(call, result)
           }
           // Проверка готовности к оплате
           "isReadyForSPaySdk" -> {
               /**
                * Метод для проверки готовности к оплате.
                * Зависит от переданного аргумента [env] при инициализации через метод [initialize]
                * (см. комментарий к методу). Запрос может выполняться долго.
                *
                * @return Если у пользователя нет установленного сбера в режимах
                * SPayStage.SandboxRealBankApp, SPayStage.prod - вернет false.
                */
               result.success(button.isReadyForSPaySdk())
           }
           // Оплата
           "payWithBankInvoiceId" -> {
               showSberPaymentModal(call, result)
           }

           else -> {
               result.notImplemented()
           }
       }
   }

   /**
    * Метод для оплаты, в аргументы которого обязательно необходимо передать:
    * @property apiKey ключ, выдаваемый по договору, либо создаваемый в личном кабинете;
    * @property merchantLogin логин, выдаваемый по договору, либо создаваемый в личном кабинете;
    * @property appPackage пакет вашего приложения;
    * @property language использовано по умолчанию "RU";
    * @property bankInvoiceId параметр, который получаем после запроса для регистрации заказа в
    * шлюзе Сбера.
    */
   private fun showSberPaymentModal(call: MethodCall, result: Result) {
       val args = call.arguments as Map<*, *>
       var responseSent = false // Флаг для отслеживания отправки ответа
       var hasError = false // Флаг для отслеживания отправки ошибки

       try {
           val apiKey = args["apiKey"] as String
           val merchantLogin = args["merchantLogin"] as String
           val bankInvoiceId = args["bankInvoiceId"] as String
           val appPackage = context.packageName
           val language = "RU"

           if (!responseSent) {
               button.payWithBankInvoiceId(apiKey, merchantLogin, bankInvoiceId, appPackage, language) { response ->
                   when (response) {
                       // Оплата не завершена
                       is PaymentResult.Processing ->
                           result.success("processing")
                       // Оплата прошла успешно
                       is PaymentResult.Success ->
                           result.success("success")
                       // Оплата прошла с ошибкой
                       is PaymentResult.Error -> {
                           if (!hasError) {
                               hasError = true
                               if (response.merchantError is MerchantError.SdkClosedByUser) {
                                   result.success("cancel")
                                   return@payWithBankInvoiceId
                               }
                               result.error("-", "MerchantError", response.merchantError?.description
                                       ?: "Ошибка выполнения оплаты")
                               return@payWithBankInvoiceId
                           }
                       }
                   }
                   responseSent = true
               }
           }
       } catch (error: Exception) {
           result.error("-", error.localizedMessage, error.message)
       }
   }

   /**
    * Метод инициализации, выполняется перед стартом приложения.
    * [env], полученный из FLutter, Тесты со всеми типами [env] лучше всего проводить на реальном
    * устройстве. Он определяет тип запуска:
    *
    * @property SPayStage.SandboxRealBankApp устройство с установленным Сбером;
    * @property SPayStage.SandBoxWithoutBankApp устройство без Сбера;
    * @property SPayStage.prod устройство с установленным Сбером, работает с продовыми данными.
    */
   private fun initialize(call: MethodCall, result: Result) {
       val args = call.arguments as Map<*, *>
       val sPayStage = when (args["env"] as String) {
           "sandboxRealBankApp" -> SPayStage.SandboxRealBankApp
           "sandboxWithoutBankApp" -> SPayStage.SandBoxWithoutBankApp
           else -> {
               SPayStage.Prod
           }
       }
       // Оплата частями
       val enableBnpl = args["enableBnpl"] as Boolean? ?: false

       try {
           SPaySdkApp.getInstance().initialize(application = activity.application, stage = sPayStage, enableBnpl = enableBnpl)
           result.success(true)
       } catch (e: Exception) {
           result.error("-", e.localizedMessage, e.message)
       }
   }

   override fun onDetachedFromEngine(binding: FlutterPluginBinding) {
       channel.setMethodCallHandler(null)
   }

   override fun onAttachedToActivity(activityBinding: ActivityPluginBinding) {
       activity = activityBinding.activity
       button = SPayButton(activity, null)
   }

   override fun onReattachedToActivityForConfigChanges(activityBinding: ActivityPluginBinding) {
       activity = activityBinding.activity
       button = SPayButton(activity, null)
   }

   override fun onDetachedFromActivity() {}

   override fun onDetachedFromActivityForConfigChanges() {}
}

Внедрение iOS-части

Минимальная версия iOS - 12.0, XCode 13

Начнем с подключения SDK. Для добавления нативного SDK необходимо перенести папку SPaySdk.xcframework в папку iOS-плагина. 

Для работы с плагином в приложении, в которое он будет интегрирован, необходимо добавить следующие параметры в файл sber_pay/example/ios/Runner/info.plist:

 <key>DTXAutoStart</key>
    <string>false</string>
    <key>LSApplicationQueriesSchemes</key>
    <array>
       <string>sbolidexternallogin</string>
        <string>sberbankidexternallogin</string>
    </array>
   <key>NSAppTransportSecurity</key>
      <dict>
         <key>NSExceptionDomains</key>
         <dict>
            <key>gate1.spaymentsplus.ru</key>
            <dict>
               <key>NSExceptionAllowsInsecureHTTPLoads</key>
               <true/>
            </dict>
            <key>ift.gate2.spaymentsplus.ru</key>
            <dict>
               <key>NSExceptionAllowsInsecureHTTPLoads</key>
               <true/>
            </dict>
            <key>cms-res.online.sberbank.ru</key>
               <dict>
                   <key>NSExceptionAllowsInsecureHTTPLoads</key>
                   <true/>
               </dict>
         </dict>
      </dict>
    <key>NSBluetoothAlwaysUsageDescription</key>
    <string>Данные Bluetooth собираются и отправляются на сервер для безопасного проведения оплаты</string>
    <key>NSBluetoothPeripheralUsageDescription</key>
    <string>Данные Bluetooth собираются и отправляются на сервер для безопасного проведения оплаты</string>
    <key>NSLocationWhenInUseUsageDescription</key>
    <string>Данные локации необходимы для безопасного проведения оплаты</string>

В отличие от Android, нужно добавить параметр для настройки диплинка на наше приложение, с помощью которого будет осуществлен возврат в наше приложение после перехода в Сбербанк онлайн/СБОЛ.

В Android данный функционал реализуется передачей параметра package. Если приложение до этого уже было настроено на работу с диплинками, можно использовать имеющуюся схему. Далее в реализации плагина будет дополнительно указана проверка на host в диплинке. В нашем случае переход обратно в приложение из приложения Сбербанка/СБОЛа будет по диплинку sbersdk://spay:

  <key>CFBundleURLTypes</key>
     <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLName</key>
            <string>spay</string>
            <key>CFBundleURLSchemes</key>
            <array>
            <string>sbersdk</string>
            </array>
        </dict>
    </array>

Также можно добавить через вкладку Info→URL Types:

Настройка URL Types
Настройка URL Types

Далее необходимо добавить в Capabilities проекта Access wi-fi information. Для этого необходимо выбрать: Ваш таргет → Signing & Capabilities → +Capability → Access wi-fi information.

Настройка доступа к wi-fi
Настройка доступа к wi-fi

В части плагина, в sber_pay/ios/sber_pay.podspec необходимо добавить код:

s.preserve_paths = 'SPaySdk.xcframework/**/*'
s.xcconfig = { 'OTHER_LDFLAGS' => '-framework SPaySdk' }
s.vendored_frameworks = 'SPaySdk.xcframework'

В это же файле нужно установить iOS 12 версии:

s.platform = :ios, '12.0'

Соответственно на стороне приложения тоже должна быть iOS 12. Установить ее можно выбрав в таргете нужную версию в настройках Minimum deployments.

iOS плагин

Нативная реализация SDK на платформе iOS не привязана к кнопке, поэтому сразу приступим к реализации плагина. Настроим sber_pay/ios/Classes/SberPayPlugin.swift. Аналогично платформе Android можно не создавать нативную кнопку, а использовать только готовое API.

Настройка SberPayPlugin.swift
// Плагин для оплаты с использованием SberPay.
public class SberPayPlugin: NSObject, FlutterPlugin {

   public static func register(with registrar: FlutterPluginRegistrar) {
       let channel = FlutterMethodChannel(name: "sber_pay", binaryMessenger: registrar.messenger())
       let instance = SberPayPlugin()
       registrar.addMethodCallDelegate(instance, channel: channel)
       /// Создание [addApplicationDelegate] для перехода по диплинку обратно в приложение
       registrar.addApplicationDelegate(instance)
   }

   public func application(_ app: UIApplication,open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
       /// Если при открытии приложения с диплинком если он содержит хост "spay", то такой диплинк
       /// попадает в нативный плагин. Таким образом работает возврат в приложение и получение данных нативным
       /// SDK от приложения Сбербанк онлайн/СБОЛ
       if  url.host == "spay" {
           SPay.getAuthURL(url)
       }

       return true
   }

   public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
       switch call.method {
           /// Инициализация
       case "init":
           initialize(call, result:result)
           /// Проверка готовности к оплате
       case "isReadyForSPaySdk":
           /**
            Метод для проверки готовности к оплате.
            Зависит от переданного аргумента [env] при инициализации через метод [initialize]
            (см. комментарий к методу). Запрос может выполняться долго.

            - Returns Если у пользователя нет установленного сбера в режимах SEnvironment.sandboxRealBankApp,
            SEnvironment.prod - вернет false.
            */
           result(SPay.isReadyForSPay)
           // Оплата
       case "payWithBankInvoiceId":
           payWithBankInvoiceId(call, result: result)
       default:
           result(FlutterMethodNotImplemented)
       }
   }

   /**
    Метод для оплаты, в аргументы которого обязательно необходимо передать:
    - Parameter apiKey ключ, выдаваемый по договору, либо создаваемый в личном кабинете;
    - Parameter merchantLogin логин, выдаваемый по договору, либо создаваемый в личном кабинете;
    - Parameter bankInvoiceId параметр, который получаем после запроса для регистрации заказа в
    шлюзе Сбера.
    - Parameter redirectUri диплинк обратно в приложение после перехода в Сбербанк
    */
   private func payWithBankInvoiceId(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
       guard let args = call.arguments as? [String: Any],
             let apiKey = args["apiKey"] as? String,
             let merchantLogin = args["merchantLogin"] as? String,
             let bankInvoiceId = args["bankInvoiceId"] as? String,
             let redirectUri = args["redirectUri"] as? String
       else {
           result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil))
           return
       }

       if bankInvoiceId.count != 32 {
           result(FlutterError(code: "-", message: "MerchantError", details: "Длина bankInvoiceId должна быть 32 символа"))
           return
       }

       guard let topController = getTopViewController() else {
           result(FlutterError(code: "PluginError", message: "SberPay: Failed to implement controller", details: nil))
           return
       }

       let request = SBankInvoicePaymentRequest(
           merchantLogin: merchantLogin,
           bankInvoiceId: bankInvoiceId,
           language: "RU",
           redirectUri: redirectUri,
           apiKey: apiKey)

       SPay.payWithBankInvoiceId(with: topController, paymentRequest: request) { state, info  in
           switch state {
           case .success:
               result("success")
           case .waiting:
               result("processing")
           case .cancel:
               result("cancel")
           case .error:
               result(FlutterError(code: "-", message: "Ошибка оплаты", details: info))
           @unknown default:
               result(FlutterError(code: "-", message: "Неопределенная ошибка", details: info))
           }
       }
   }

   /**
    Метод инициализации, выполняется перед стартом приложения.
    [env], полученный из FLutter, Тесты со всеми типами [env] лучше всего проводить на реальном устройстве. Он
    определяет тип запуска:

    - Parameter SEnvironment.sandboxRealBankApp устройство с установленным Сбером;
    - Parameter SEnvironment.sandboxWithoutBankApp устройство без Сбера;
    - Parameter SEnvironment.prod устройство с установленным Сбером, работает с продовыми данными.
    - Parameter enableBnpl Функционал Оплата частями
    */
   private func initialize(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
       guard let args = call.arguments as? [String: Any],
             let env = args["env"] as? String
       else {
           result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil))
           return
       }

       let enableBnpl = args["enableBnpl"] as? Bool ?? false

       let sPayStage: SEnvironment
       switch env {
       case "sandboxRealBankApp":
           sPayStage = .sandboxRealBankApp
       case "sandboxWithoutBankApp":
           sPayStage = .sandboxWithoutBankApp
       default:
           sPayStage = .prod
       }

       SPay.setup(bnplPlan: enableBnpl, environment: sPayStage)
       result(true)
   }

   private func getTopViewController() -> UIViewController? {
       var topController = UIApplication.shared.keyWindow?.rootViewController

       while let presentedViewController = topController?.presentedViewController {
           topController = presentedViewController
       }

       return topController
   }
}

Внедрение платформ в Flutter-приложение

Наконец можно приступать к написанию кода в Flutter-части. Ранее упоминалось, что можно использовать нативную кнопку оплаты из SberPay SDK через PlatformView. Однако лучше всего просто сверстать ее в Flutter. В случае чего ее можно будет легко отредактировать.

Обратите внимание, здесь мы создаем кнопку именно на стороне плагина, делается это потому, что нам нужно соответствовать гайдланам Сбера по дизайну и исключить редактирование кнопки. Тем не менее, фактически от нее не зависит функционал оплаты, что является плюсом. Поэтому сначала в корне плагина создаем папку assets, в которой добавим лого СберПэя и шрифты (их можно получить из гайдбука Сбера).

Структура папки assets
Структура папки assets

После чего в pubspec.yaml объявим созданные ассеты:

flutter:
 assets:
   - assets/sberpay_logo.png

 fonts:
   - family: SberPaySans
     fonts:
       - asset: assets/fonts/spay_sans_text.ttf

Теперь создадим кнопку СберПэя в папке lib плагина:

/// Виджет нативной кнопки оплаты Сбербанка
class SberPayButton extends StatelessWidget {
 const SberPayButton({
   super.key,
   this.onPressed,
 });

 /// Обработчик нажатия на кнопку
 final VoidCallback? onPressed;

 @override
 Widget build(BuildContext context) {
   return InkWell(
     onTap: onPressed,
     child: Container(
       height: 48,
       decoration: BoxDecoration(
         color: const Color(0xFF21A038),
         borderRadius: BorderRadius.circular(12),
       ),
       child: Row(
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
           const Flexible(
             child: Text(
               'Оплатить',
               style: TextStyle(
                 color: Colors.white,
                 fontSize: 16,
                 fontFamily: 'SberPaySans',
                 package: 'sber_pay',
               ),
             ),
           ),
           const SizedBox(width: 8),
           Image.asset(
             'assets/sberpay_logo.png',
             package: 'sber_pay',
             width: 48,
             height: 22,
           ),
         ],
       ),
     ),
   );
 }
}

СберПэй позволяет использовать свой SDK в трех режимах:

  • prod — прод;

  • sandboxWithRealBank — песочница с банковским приложением;

  • sandboxWithoutBank — песочница.

Последние два режима применяются для тестирования процесса оплаты. Они нужны, чтобы предупредить возможные сложности с использованием СберПэя. Для каждого из режимов создадим enum, который будет определять режим запуска SberPay SDK:

/// Тип инициализации сервисов Сбербанка
enum SberPayEnv {
 /// Продуктовый режим.
 ///
 /// Для авторизации пользователя происходит редирект в приложение Сбербанка.
 prod,
 /// Режим песочницы.
 ///
 /// Позволяет протестировать оплату как в [prod], но с тестовыми данными.
 /// Для авторизации пользователя происходит редирект в приложение Сбербанка.
 ///
 /// На Android тестируется только на реальных устройствах. На iOS можно
 /// тестировать как на реальном устройстве, так и в симуляторе.
 sandboxRealBankApp,
 /// Режим песочницы без перехода в банк.
 ///
 /// При авторизации пользователя не осуществляется переход в приложение
 /// Сбербанка.
 ///
 /// На Android тестируется только на реальных устройствах. На iOS можно
 /// тестировать как на реальном устройстве, так и в симуляторе.
 sandboxWithoutBankApp
}

Добавим еще один специальный enum, который будет транслировать платформенные ответы в удобный вид:

/// Статусы оплаты
enum SberPayPaymentStatus {
 /// Успешный результат
 success,

 /// Необходимо проверить статус оплаты
 processing,

 /// Пользователь отменил оплату
 cancel,

 /// Неизвестный тип
 unknown;

 static fromString(String? value) {
   switch (value) {
     case "success":
       return SberPayPaymentStatus.success;
     case "processing":
       return SberPayPaymentStatus.processing;
     case "cancel":
       return SberPayPaymentStatus.cancel;
     default:
       return SberPayPaymentStatus.unknown;
   }
 }
}

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

sber_pay.dart
/// Плагин для отображения нативной кнопки SberPay SDK
///
/// Все исключения (Exceptions) приходящие из методов этого класса должны
/// обрабатываться уровнем выше.
class SberPayPlugin {
 static const methodChannel = MethodChannel('sber_pay');

 /// Инициализация SberPay SDK.
 ///
 /// Необходимо выполнить для начала работы с библиотекой.
 /// На платформе Android этот метод является асинхронным, однако у
 /// него нет API (коллбека) для выполнения кода после завершения
 /// инициализации.
 ///
 /// * [env] - среда запуска, которая определяется через [SberPayEnv].
 /// * [enableBnpl] - функционал оплаты частями
 static Future<bool> initSberPay({
   required String env,
   bool? enableBnpl,
 }) async {
   final result = await methodChannel.invokeMethod<bool>('init', {
     'env': env,
     'enableBnpl': enableBnpl,
   });
   return result ?? false;
 }

 /// Метод для проверки готовности к оплате.
 ///
 /// Зависит от переданного аргумента [env] при инициализации через метод
 /// [initSberPay] (см. комментарий к методу).
 ///
 /// Запрос может выполняться долго, поэтому здесь стоит искусственная
 /// задержка, чтобы дождаться инициализации SDK.
 ///
 /// Если у пользователя нет установленного сбера в режимах
 /// [SberPayEnv.sandboxRealBankApp], [SberPayEnv.prod] - вернет false.
 static Future<bool> isReadyForSPaySdk() async {
   final result = await methodChannel.invokeMethod<bool>('isReadyForSPaySdk');
   if (result == null || result == false) {
     await Future.delayed(
       const Duration(seconds: 2),
       () async {
         return await methodChannel.invokeMethod<bool>('isReadyForSPaySdk');
       },
     );
   }
   return result ?? false;
 }

 /// Метод оплаты через SberPay SDK.
 /// * [apiKey] - ключ, выдаваемый по договору, либо создаваемый в личном
 /// кабинете;
 /// * [merchantLogin] - логин, выдаваемый по договору, либо создаваемый в
 /// личном кабинете;
 /// * [bankInvoiceId] - параметр, который получаем после запроса для
 /// регистрации заказа в шлюзе Сбера.
 /// * [redirectUri] - диплинк для перехода обратно в приложение после открытия
 /// Сбербанка (только на iOS).
 ///
 /// Возвращает статус оплаты [SberPayPaymentStatus]
 static Future<SberPayPaymentStatus> payWithBankInvoiceId({
   required String apiKey,
   required String merchantLogin,
   required String bankInvoiceId,
   required String redirectUri,
 }) async {
   final result = await methodChannel.invokeMethod<String>(
     'payWithBankInvoiceId',
     {
       'apiKey': apiKey,
       'merchantLogin': merchantLogin,
       'bankInvoiceId': bankInvoiceId,
       'redirectUri': redirectUri,
     },
   );
   return SberPayPaymentStatus.fromString(result);
 }
}

В методе isReadyForSPaySdk стоит искусственная задержка, потому что метод initialized на стороне Android выполняется асинхронно, и нельзя дождаться его выполнения, так как в этом методе нет соответствующего API (например, коллбэка при его завершении). На iOS такой проблемы нет. Разработчики SDK обещали исправить это в будущих версиях.

Плагин полностью готов к работе. Теперь можно создать реализацию в sber_pay/example/lib/main.dart для тестирования SberPay SDK. Она может быть простенькая, а может быть как эта:

main.dart
/// Необходимо указать по данным из договора
const _apiKey = '';
const _merchantLogin = '';

/// Диплинк на переход в приложение
const _redirectUri = 'sbersdk://spay';

void main() => runApp(const SberPayExampleApp());

class SberPayExampleApp extends StatefulWidget {
 const SberPayExampleApp({super.key});

 @override
 State<SberPayExampleApp> createState() => _SberPayExampleAppState();
}

class _SberPayExampleAppState extends State<SberPayExampleApp> {
 late final TextEditingController _controller;
 late String _paymentStatus;
 late bool _isPluginLoading;
 late bool _isAppReadyForPay;
 late bool _isPluginInitialized;
 late SberPayEnv _selectedInitType;
 late Color _color;

 @override
 void initState() {
   super.initState();
   _controller = TextEditingController();
   _paymentStatus = '';
   _selectedInitType = SberPayEnv.sandboxWithoutBankApp;
   _isPluginLoading = false;
   _isAppReadyForPay = false;
   _isPluginInitialized = false;
   _color = Colors.grey;
   _readyForPay();
 }

 Future<void> _readyForPay() async {
   setState(() => _isPluginLoading = true);
   _isPluginInitialized = await SberPayPlugin.initSberPay(
     env: _selectedInitType.name,
   );
   if (mounted) setState(() {});

   _isAppReadyForPay = await SberPayPlugin.isReadyForSPaySdk();
   if (!_isAppReadyForPay) {
     _isAppReadyForPay = await SberPayPlugin.isReadyForSPaySdk();
   }
   if (mounted) setState(() => _isPluginLoading = false);
 }

 void _setEnv(SberPayEnv env) {
   _selectedInitType = env;
   _paymentStatus = '';
   _color = Colors.grey;
   _readyForPay();
 }

 @override
 void dispose() {
   _controller.dispose();
   super.dispose();
 }

 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     theme: ThemeData(
       colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF21A038)),
       useMaterial3: true,
     ),
     home: Scaffold(
       appBar: AppBar(
         title: const Text('SberPay plugin example'),
         backgroundColor: const Color(0xFF21A038),
         centerTitle: true,
       ),
       body: Builder(
         builder: (context) {
           return Padding(
             padding: const EdgeInsets.all(8.0),
             child: SingleChildScrollView(
               child: Column(
                 mainAxisSize: MainAxisSize.min,
                 children: [
                   Table(
                       border: TableBorder.all(),
                       defaultVerticalAlignment:
                           TableCellVerticalAlignment.middle,
                       children: [
                         _tableRowWrapper(
                           'Тип запуска',
                           Column(
                             children: [
                               ChoiceChip(
                                 label: const Text('Прод'),
                                 selected:
                                     _selectedInitType == SberPayEnv.prod,
                                 onSelected: (_) => _setEnv(SberPayEnv.prod),
                               ),
                               const SizedBox(
                                 width: 10,
                               ),
                               ChoiceChip(
                                 label: const Text('Песочница/Банк'),
                                 selected: _selectedInitType ==
                                     SberPayEnv.sandboxRealBankApp,
                                 onSelected: (_) => _setEnv(
                                   SberPayEnv.sandboxRealBankApp,
                                 ),
                               ),
                               const SizedBox(
                                 width: 10,
                               ),
                               ChoiceChip(
                                 label: const Text('Песочница'),
                                 selected: _selectedInitType ==
                                     SberPayEnv.sandboxWithoutBankApp,
                                 onSelected: (_) => _setEnv(
                                   SberPayEnv.sandboxWithoutBankApp,
                                 ),
                               ),
                             ],
                           ),
                         ),
                         _tableRowWrapper(
                           'Плагин проинициализирован',
                           Text(_isPluginLoading
                               ? 'Загрузка'
                               : _isPluginInitialized
                                   ? "ДА"
                                   : "НЕТ"),
                         ),
                         _tableRowWrapper(
                           'Оплата доступна',
                           Text(_isPluginLoading
                               ? 'Загрузка'
                               : _isAppReadyForPay
                                   ? "ДА"
                                   : "НЕТ"),
                         ),
                         _tableRowWrapper(
                           'Статус операции оплаты',
                           SizedBox(
                             height: 80,
                             child: Row(
                               children: [
                                 CircleAvatar(
                                   backgroundColor: _color,
                                   radius: 10.0,
                                 ),
                                 Flexible(
                                   child: Text(
                                     _paymentStatus.isEmpty
                                         ? "Оплата не производилась"
                                         : _paymentStatus,
                                     textAlign: TextAlign.center,
                                   ),
                                 )
                               ],
                             ),
                           ),
                         ),
                       ]),
                   const SizedBox(height: 16),
                   Padding(
                     padding: const EdgeInsets.symmetric(horizontal: 16),
                     child: TextField(
                       controller: _controller,
                       textInputAction: TextInputAction.done,
                       decoration: InputDecoration(
                         hintText: 'Введите bankInvoiceID',
                         suffixIcon: GestureDetector(
                           onTap: () {
                             _controller.clear();
                             _color = Colors.grey;
                             setState(() => _paymentStatus = '');
                           },
                           child: const Icon(Icons.close, size: 32),
                         ),
                       ),
                     ),
                   ),
                   const SizedBox(height: 16),
                   Padding(
                     padding: const EdgeInsets.symmetric(horizontal: 16),
                     child: _isPluginLoading
                         ? Container(
                             height: 48,
                             decoration: BoxDecoration(
                               color: const Color(0xFF21A038),
                               borderRadius: BorderRadius.circular(12),
                             ),
                             child: const Center(
                               child: CircularProgressIndicator(
                                 color: Colors.white,
                               ),
                             ),
                           )
                         : SberPayButton(
                             onPressed: () async {
                               if (_apiKey.isEmpty || _merchantLogin.isEmpty) {
                                 ScaffoldMessenger.of(context).showSnackBar(
                                   const SnackBar(
                                     content: Text(
                                       'Не заданы apiKey и/или merchantLogin',
                                     ),
                                     duration: Duration(seconds: 2),
                                   ),
                                 );
                                 return;
                               }
                               if (_controller.text.isEmpty) {
                                 ScaffoldMessenger.of(context).showSnackBar(
                                   const SnackBar(
                                     content: Text('Введите bankInvoiceID'),
                                     duration: Duration(seconds: 2),
                                   ),
                                 );
                               } else {
                                 try {
                                   final result = await SberPayPlugin
                                       .payWithBankInvoiceId(
                                     apiKey: _apiKey,
                                     merchantLogin: _merchantLogin,
                                     bankInvoiceId: _controller.text,
                                     redirectUri: _redirectUri,
                                   );
                                   switch (result) {
                                     case SberPayPaymentStatus.success:
                                       _color = Colors.green;
                                       _paymentStatus =
                                           'Оплата прошла успешно';
                                       break;
                                     case SberPayPaymentStatus.processing:
                                       _color = Colors.yellow;
                                       _paymentStatus =
                                           'Необходимо проверить статус оплаты';
                                       break;
                                     case SberPayPaymentStatus.cancel:
                                       _color = Colors.blue;
                                       _paymentStatus =
                                           'Пользователь отменил оплату';
                                       break;
                                     case SberPayPaymentStatus.unknown:
                                       _color = Colors.purple;
                                       _paymentStatus =
                                           'Неизвестное состояние';
                                   }
                                   setState(() {});
                                 } on PlatformException catch (e) {
                                   setState(
                                     () {
                                       _color = Colors.red;
                                       _paymentStatus = e.details ?? '';
                                     },
                                   );
                                 }
                               }
                             },
                           ),
                   ),
                 ],
               ),
             ),
           );
         },
       ),
     ),
   );
 }

 TableRow _tableRowWrapper(String title, Widget secondChild) {
   return TableRow(
     children: [
       TableCell(child: Text(title, textAlign: TextAlign.center)),
       Padding(
         padding: const EdgeInsets.all(4.0),
         child: Center(child: secondChild),
       ),
     ],
   );
 }
}

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

Для совершения оплаты в методе payWithBankInvoiceId указываются apiKey и merchantLogin, которые должны быть приватными. Конечно, их точно так же, как и credentials, для получения aar-бандла можно сразу указать здесь, и даже если отправлять в контроль версий, то только в приватный. Все же лучше получать эти данные из переменных среды, которые могут быть предварительно обфусцированы. Это повысит вероятность сохранения важных данных.

Также в этом методе, в аргументе redirectUri, указывается диплинк, который до этого мы регистрировали в iOS части приложения. Он понадобится для перехода из вашего приложения в приложение Сбербанка и обратно.

На Android и iOS по-разному реализована отмена оплаты (событие, когда пользователь свернул нативное модальное окно либо когда нажал «Отменить оплату»). Если в iOS приходит отдельное событие cancel, то в Android это приходит как ошибка.

После всех вышеизложенных трудов получается что-то такое:

Статья получилась очень насыщенной всякими нюансами. Когда смотришь на готовый результат, кажется, что ничего особенного в реализации нет. Однако все проекты создают люди, где-то могут быть ошибки, где-то могут быть особенности проектирования и многое другое. Поэтому получается то, что получается. И наверное, это нормально. Важно лишь поддерживать всегда стремление совершенствовать свои продукты и проекты, ведь из этого в целом и состоит разработка.

Финальный проект мы разместили в GitHub. Как обычно, делитесь своим мнением и наработками в данной области под этим постом.

Комментарии (0)