Всем привет! Это Мурат Насиров и Артем Баркалов, мы Flutter-разработчики в Friflex. Разрабатываем высоконагруженные мобильные приложения для бизнеса и специализируемся на Flutter. В этой статье мы собрали большую часть кейсов, с которыми вы можете столкнуться при интеграции кнопки оплаты SberPay в приложении на Flutter. Это поможет вам понять механику работы СберПэй и шаги, которые необходимо сделать для передачи событий из натива во Flutter.
Содержание
3.1.1 Интеграция через Maven репозиторий
3.1.2 Интеграция вручную
3.1.3 Настройка проекта
3.1.4 Android-плагин и нативная кнопка
3.2.1 iOS плагин
Подготовка
Использование SberPay SDK в продакшене требует наличия установленного приложения «Сбербанк Онлайн» (СБОЛ), иначе в процессе оплаты произойдет ошибка. Тестирование можно провести либо на симуляторе iOS, либо на реальном устройстве iOS или Android. На эмуляторе Android тестирование не выйдет — при попытке оплаты возникает ошибка. Разработчики SDK используют аппаратные возможности смартфонов: геолокацию, bluetooth, Wi-Fi для снижения вероятности совершения мошеннических операций (условный антифрод).
Первым делом разработчик узнает от Сбербанка (по договору о подключении SberPay SDK в приложение), логин/пароль (credentials) и ссылку, которые указываются в файле build.gradle
проекта (приложения) в части Android для получения библиотеки. Либо отдельно запрашивается aar-бандл. Для подключения SDK на iOS выдадут отдельную ссылку на репозиторий, хотя он публичный.
Вместе с данными для получения SDK выдают тестовые данные для регистрации заказа в шлюзе Сбера, а также специальные apiKey
и merchantLogin
, которые используются для инициализации SberPay SDK в вашем приложении. Тестовые данные не подходят для проверки списания реальных денежных средств с реальной карты, они исключительно для тестирования. Для работы 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
должны быть в нижнем регистре. Если по каким-то причинам во время оплаты через модальное окно СберПэя произошла именно ошибка (не отмена), тогда 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.
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" />
Это увеличит шансы для совершения успешной оплаты.
Для работы с сервисом SberPay в релизе, если у вас включена обфускация необходимо добавить proguard. В android/app/build.gradle
по умолчанию shrinkResources
и minifyEnabled
не указаны:
android {
...
buildTypes {
release {
// shrinkResources и minifyEnabled не указаны, по-умолчанию true, однако лучше указать явно
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
Теперь в android/app/proguard-rules.pro
добавляем:
-keep class spay.sdk.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
Если вам не нужна обфускация, тогда shrinkResources
и minifyEnabled
в android/app/build.gradle
должны быть false
:
android {
...
buildTypes {
release {
shrinkResources false
minifyEnabled false
}
}
}
Соответственно, создавать android/app/proguard-rules.pro
в таком случае не нужно.
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:
Далее необходимо добавить в Capabilities проекта Access wi-fi information. Для этого необходимо выбрать: Ваш таргет → Signing & Capabilities → +Capability → Access wi-fi information.
В части плагина, в 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
, в которой добавим лого СберПэя и шрифты (их можно получить из гайдбука Сбера).
После чего в 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. Как обычно, делитесь своим мнением и наработками в данной области под этим постом.
P.S. Мы ведем дружелюбный канал про Flutter в Telegram. Присоединяйтесь!