Finipay SDK

Flutter-пакет кошелька Finipay для приложений партнёров:

  • профиль и баланс пользователя;
  • история операций и статусы;
  • переводы внутри Finipay;
  • вывод в MBank и MegaPay;
  • пополнение из MBank и MegaPay;
  • QR: свой постоянный код, код на сумму, оплата чужого кода.

Экранов в пакете нет, кроме страницы оплаты (HostedPageScreen): интерфейс и управление состоянием остаются за приложением. Вход — без экранов: ваш бэкенд выдаёт одноразовый код, SDK сам меняет его на сессию.

Минимум
Flutter 3.41 (Dart 3.11)
Android minSdk 24
iOS 15.0

Установка

flutter pub add finipay

или в pubspec.yaml:

dependencies:
  finipay: ^0.0.1

Разрешений камеры пакет не приносит, класс Activity менять не нужно.

Что нужно от Finipay

Что Где используется
Арендатор (REDPAY, FINIPAY или ваш код) tenant в Finipay.init
Ключ и вектор шифрования перевода — 32 и 16 байт UTF-8 transferKey, transferIv
Ключ партнёра pk_… и документация для бэкенда только ваш сервер: выдаёт одноразовый код входа

Как устроен вход:

ваше приложение ──► ваш бэкенд ──(ключ pk_…)──► Finipay: одноразовый код
       ▲                                              │
       └────────── код ◄──────────────────────────────┘
SDK: partnerCode() → код → сессия кошелька

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

Настройка платформ

Android

<!-- android/app/src/main/AndroidManifest.xml -->
<application
    android:allowBackup="false"
    ... >

allowBackup="false" — иначе данные приложения уедут в облачный бэкап, и на новом устройстве получится нерабочее состояние вместо чистого входа. Если бэкап нужен для остального, исключите файлы SDK: токены лежат в защищённом хранилище, а идентификатор устройства и незакрытые операции — в FlutterSharedPreferences.xml, вместе с вашими настройками shared_preferences:

<application android:dataExtractionRules="@xml/backup_rules" ... >
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<data-extraction-rules>
    <cloud-backup>
        <exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
        <exclude domain="sharedpref" path="FlutterSharedPreferences.xml"/>
    </cloud-backup>
    <device-transfer>
        <exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
        <exclude domain="sharedpref" path="FlutterSharedPreferences.xml"/>
    </device-transfer>
</data-extraction-rules>

minSdk — не ниже 24. Незашифрованный трафик держите запрещённым (cleartextTrafficPermitted="false" в network_security_config).

iOS

Ничего добавлять не нужно — нужно не ослаблять App Transport Security: NSAllowsArbitraryLoads отключает защиту для всего приложения, включая страницу оплаты. Токены лежат в Keychain «только это устройство» и в бэкапы не попадают: на новом устройстве пользователь войдёт заново.

network_security_config и ATS на трафик SDK не влияют — dart:io не пользуется системным хранилищем доверия. Они действуют на встроенный браузер и нативные плагины.

Камера

В SDK её нет, чтобы разрешение не попало в ваш манифест без вашего решения. Подключили свой сканер — объявите android.permission.CAMERA и NSCameraUsageDescription вместе с ним.

Инициализация

Ключи не кладутся в код и в репозиторий: заведите файл сборки и закройте его .gitignore.

// dart_defines.json — в .gitignore
{
  "FINIPAY_ENV": "stand",
  "FINIPAY_TRANSFER_KEY": "…",
  "FINIPAY_TRANSFER_IV": "…"
}
flutter run --dart-define-from-file=dart_defines.json
import 'package:finipay/finipay.dart';

const _env = String.fromEnvironment('FINIPAY_ENV', defaultValue: 'stand');

Future<void> main() async {
  await Finipay.init(
    environment: _env == 'production'
        ? FinipayEnvironment.production
        : FinipayEnvironment.stand,
    tenant: 'REDPAY',
    transferKey: const String.fromEnvironment('FINIPAY_TRANSFER_KEY'),
    transferIv: const String.fromEnvironment('FINIPAY_TRANSFER_IV'),
    // Одноразовый код входа от ВАШЕГО бэкенда. SDK зовёт колбэк сам.
    partnerCode: () async {
      final response = await myApi.post('/wallet/finipay-code');
      return response.data['code'] as String;
    },
    // Кто вошёл у вас: сменился — SDK сбросит сессию кошелька сам.
    partnerUser: () => myAuth.currentUser?.id,
  );
  runApp(const MyApp());
}

Дальше SDK берут где угодно: Finipay.instance. Во всех примерах ниже finipay — это Finipay.instance.

Параметр Обязателен Умолчание Что это
environment да — FinipayEnvironment.stand, .production или .custom(Uri)
tenant да — арендатор; пустой — ArgumentError
transferKey, transferIv да, можно null — ключ AES-256 и вектор параметров перевода, ровно 32 и 16 байт UTF-8
partnerCode нет null колбэк за одноразовым кодом входа у вашего бэкенда
partnerUser нет null кто сейчас вошёл у вас; сменился — сессия кошелька сбрасывается
messages нет пусто свои тексты для пользователя по ключам словаря SDK
branding нет null название, цвета, контакты для ваших экранов — finipay.branding
locale нет 'ru' язык серверной конфигурации и текстов
appVersion нет версия сборки версия, которую сервер сверяет с минимальной поддерживаемой
logger нет молчит журнал предупреждений SDK; токены, коды и телефоны вычищаются
certificatePins нет нет закреплённые отпечатки ключей своих хостов
statusChannel нет false канал статусов поверх опроса; включайте по согласованию с Finipay
deviceInfo нет пусто модель и система для списка сессий — уходят при входе через finipay.auth, не при обмене кода партнёра
moneyCodec, endpoints нет контракт кошелька менять не нужно
  • Ошибка настройки — ArgumentError сразу, до работы с платформой. Значение ключа в текст ошибки не попадает.
  • init идемпотентен: повторный вызов с теми же контуром и арендатором вернёт тот же экземпляр, остальные параметры при этом не применяются. С другими контуром или арендатором — StateError: сначала await Finipay.instance.dispose().
  • До init обращение к Finipay.instance бросает StateError; проверить — Finipay.isInitialized.

Контуры

Контур Деньги
FinipayEnvironment.stand — тестовый условные
FinipayEnvironment.production — боевой настоящие с первого вызова
FinipayEnvironment.custom(uri) по адресу, который выдаст Finipay

Адреса зашиты в пакет, чтобы опечатка не увела боевую сборку на стенд. custom — только если контур переехал раньше, чем вышла версия пакета с новым адресом. Страница оплаты открывается только на finipay.kg и его поддоменах; у custom — на хосте адреса или на FinipayEnvironment.custom(uri, hostedPageHosts: {…}).

Ключи шифрования

Кошелёк принимает тело перевода, телефон в проверке получателя и номер заказа в запросе статуса только зашифрованными, на стенде тоже. SDK шифрует сам.

  • Длина в байтах UTF-8: ключ 32, вектор 16. Кириллическая буква — два байта.
  • Пустая строка — забытый --dart-define, init бросит сразу.
  • null в оба — осознанный отказ от переводов и вывода: QR, история, профиль и пополнение работают, а проверка получателя, перевод, вывод и status бросают StateError до выхода в сеть; watch перевода и вывода без ключей не запускайте. Спросить заранее — finipay.transfers.isEncryptionConfigured.
  • Экрана для ввода ключа быть не должно.

Ключ одинаков у всех установок — это обфускация параметра, а не защита перевода; перевод защищён токеном пользователя и проверками сервера. Сошёлся ли ключ, проверяется без денег: transfers.checkRecipient(phone) вернул получателя — да, EncryptionError — нет.

Вход и сессия

partnerCode

Функция, которая возвращает одноразовый код входа от вашего бэкенда. SDK зовёт её сам:

  • перед первым запросом, которому нужна сессия;
  • по finipay.signIn() — чтобы показать ошибку входа до первого экрана;
  • после того как сервер закрыл сессию: приходят SignedOut(isForced: true) и следом SignedIn, запрос повторяется один раз.

Контракт колбэка:

  • каждый вызов — свежий код: он одноразовый и живёт около минуты;
  • ваш пользователь не вошёл — бросайте исключение (Exception): запрос завершится UnauthorizedError, и ближайшие 3 секунды SDK отвечает той же ошибкой, не вызывая колбэк снова;
  • не зовите Finipay изнутри колбэка — StateError;
  • 15 секунд на ответ, остальные запросы SDK ждут входа;
  • код входа — не ключ партнёра. Ключ pk_… сервер отвергнет 401.

Передан ли partnerCode, видно по finipay.canSignInAutomatically. Без него вход ведёт приложение: finipay.auth.exchangePartnerCode(code). Обмен не повторяется ни по таймауту, ни по 5xx — код считается потраченным.

partnerUser и смена пользователя

При выходе и смене пользователя у вас — finipay.signOut(). Сессия кошелька живёт в защищённом хранилище и сама не сменится: без выхода новый пользователь откроет кошелёк предыдущего.

Future<void> onLogout() async {
  await Finipay.instance.signOut();
  await myAuth.logout();
}

partnerUser — страховка на случай забытого вызова: стабильный идентификатор вашего пользователя (не телефон), null — никто не вошёл. На устройстве хранится только его хеш.

Что вернул partnerUser Что делает SDK
того же пользователя ничего
другого сбрасывает сессию: SignedOut(isForced: true), новый код, SignedIn
null сессию не отдаёт и не стирает; запрос завершится UnauthorizedError

Колбэк зовётся на каждый запрос — он должен быть быстрым. Сессию на сервере отзывает только signOut().

События сессии

finipay.authEvents.listen((event) {
  switch (event) {
    case SignedIn():
      break; // сессия поднята
    case SignedOut(:final isForced):
      // isForced — сервер закрыл сессию или сменился пользователь; иначе
      // вызван signOut() или сессии нет и войти нечем. Сбросьте экраны
      // с данными кошелька.
      break;
    case RefreshFailed():
      break; // временная неприятность, сессия жива
  }
});

Токен доступа обновляется сам: на 401 SDK обновляет его (новый токен обновления заменяет старый) и повторяет запрос один раз. Сессию закрывают отказ сервера в обновлении, смена пользователя (partnerUser) и signOut(); таймауты и 5xx её не убивают. Есть ли сессия сейчас — await finipay.isSignedIn.

Правила денежных вызовов

Денежные вызовы — transfers.createTransfer и withdrawals.withdraw.

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

final reference = 'transfer:${DateTime.now().microsecondsSinceEpoch}';

Не телефон и не получатель: begin с той же ссылкой вернёт уже открытое намерение с прежним заказом, а новый orderNumber проигнорирует.

Ситуация Что делать
оборвалась связь, экран ещё открыт тот же intent: повторите вызов или опросите статус. Новую проверку получателя не делать — новый заказ создал бы второй перевод
приложение перезапустили посреди платежа намерение придёт из finipay.pendingOperations() — см. «После перезапуска»
отказ, человек пополнил счёт и нажал снова новая проверка получателя и begin(reference, orderNumber: …, attempt: 2)
человек сменил кошелёк или номер после отправки тоже новая попытка и следующий attempt

Исход — значение PaymentOutcome, а не исключение.

Исход Что случилось Что показать
PaymentAuthorized деньги ушли успех
PaymentDeclined отказ message — текст для человека; причину фрода он не раскрывает
PaymentPending принято, идёт проведение «отправляем» и watch(operationId)
PaymentIndeterminate исход неизвестен: обрыв, таймаут, 5xx не «не прошло» — человек заплатит второй раз. Опросить статус или повторить тем же намерением
PaymentCancelled человек отказался сам вернуться на форму

По обрыву, таймауту и 5xx денежные вызовы SDK не повторяет — повтор решает человек. Единственный автоповтор — после 401 с обновлённым токеном: такой запрос сервер не принимал, и уходит он с тем же ключом идемпотентности.

Намерение закрывается само на PaymentAuthorized, PaymentDeclined и PaymentCancelled; после PaymentPending и PaymentIndeterminate узнали итог — закройте его: finipay.completeOperation(intent).

Кроме исхода, денежный вызов может бросить исключение — сервер операцию не принял: ValidationError, UserNotFoundError, RateLimitError, EncryptionError, UnauthorizedError, UpdateRequiredError, ServerError с кодом 4xx. StateError — ошибка программиста или настройки.

Профиль и баланс

final balance = await finipay.users.balance();  // Money — дешевле профиля
final profile = await finipay.users.profile();  // имя, accountNumber, kycStatus

profile.kycStatus == KycStatus.green — можно проводить деньги. users.sessions() — устройства с открытой сессией, users.changeLanguage(InterfaceLanguage.kg) — язык текстов и push на сервере.

Перевод внутри Finipay

// Один раз на экран перевода.
final reference = 'transfer:${DateTime.now().microsecondsSinceEpoch}';

// На нажатии «Перевести».
final recipient = await finipay.transfers.checkRecipient(phone);
final order = recipient.orderFor(PaymentProvider.finipay);
if (order == null) return showNoWallet(); // у номера нет кошелька Finipay

final intent = await finipay.transfers.begin(
  reference,
  orderNumber: order.orderNumber,
  attempt: attempt, // 1, после отказа — следующая
);
final outcome = await finipay.transfers.createTransfer(
  intent: intent,
  request: TransferPayRequest.forIntent(
    intent: intent,
    recipient: recipient.phone,
    amount: Money.parse('150.00'),
    comment: comment,
  ),
);

switch (outcome) {
  case PaymentAuthorized():
    showSuccess();
  case PaymentDeclined(:final message):
    showError(message);
  case PaymentPending(:final operationId):
    finipay.transfers.watch(operationId).listen(showStatus);
  case PaymentIndeterminate():
    showChecking(); // НЕ «не прошло»: запрос мог выполниться
  case PaymentCancelled():
    break;
}
  • Проверка получателя — перед каждым переводом: вместе с получателем сервер выдаёт одноразовый номер заказа.
  • Тело — только через TransferPayRequest.forIntent. Чужой номер заказа в createTransfer — StateError до выхода в сеть.
  • Имя получателя — order.receiverName.
  • orderFor вернул null — у номера нет кошелька у этого провайдера. Это ответ, а не сбой связи.
  • UserNotFoundError (код 306) — получателя нет. EncryptionError (код 305) — не «нет получателя», а не тот ключ шифрования.
  • Своих запросов к этим эндпоинтам не собирайте: SDK шифрует параметры и экранирует Base64.

Сводка доходов и расходов за месяц — finipay.transfers.incomeAndExpense().

Вывод в MBank и MegaPay

Деньги покидают систему через сервис выплат банка. Провайдер и номер заказа склеены в Withdrawal, чтобы заказ одного кошелька не ушёл с провайдером другого.

На стенде не подтверждайте вывод без согласования с Finipay: проверяйте его до экрана подтверждения.

// Один раз на экран вывода.
final reference = 'withdraw:${DateTime.now().microsecondsSinceEpoch}';

// PaymentProvider.mbank или .megapay; null — у номера нет кошелька там.
final target = await finipay.withdrawals.check(phone, provider: provider);
if (target == null) return showNoWallet(provider);

confirm(name: target.name, phone: target.phone, amount: amount);

final intent = await finipay.withdrawals.begin(
  reference,
  target: target,
  attempt: attempt,
);
final outcome = await finipay.withdrawals.withdraw(
  intent: intent,
  target: target,
  amount: amount,
);

switch (outcome) {
  case PaymentPending(:final operationId):
    // Норма: банк подтверждает не сразу, сумма удержана.
    finipay.withdrawals.watch(operationId).listen(showStatus);
  case PaymentIndeterminate():
    // Банк мог уже выплатить: заново не отправлять, только статус.
    showChecking();
  case PaymentAuthorized():
    showSent();
  case PaymentDeclined(:final message):
    showDeclined(message);
  case PaymentCancelled():
    break;
}
  • Деньги удерживаются, а не списываются, до ответа банка; PaymentPending — норма. Закрыли экран — вывод не отменится.
  • «Проверить ещё раз» после PaymentIndeterminate — это withdrawals.status(orderNumber), а не второй вывод.
  • Имя сообщает не всякий банк. MBank отдаёт маскированное ФИО, у MegaPay name == null и isNameConfirmed == false — показывайте номер крупно.
  • withdraw с намерением от другого заказа — StateError до сети.
  • Проверка отдаёт заказы на всех провайдеров, которых знает сервер, — показывайте только те, что поддерживает ваше приложение.

Пополнение из MBank и MegaPay

Кошелёк сам просит банк списать сумму с номера плательщика. Два шага у человека: номер и сумма, потом код.

// 1. Номер плательщика и сумма. Банк шлёт плательщику код.
var topUp = await finipay.topUps.start(
  TopUpSource.megapay, // или TopUpSource.mbank
  phone: phone,
  amount: Money.parse('100.00'),
);
if (topUp.status == TopUpStatus.rejected) {
  return showError(topUp.message);
}

// 2. Код: MBank — из push в приложение MBank, MegaPay — PIN.
topUp = await finipay.topUps.confirm(topUp.orderNumber, code);
if (topUp.isCodeIncorrect) {
  return askCodeAgain(); // только MBank: пополнение живо
}

// 3. До исхода.
await for (final t in finipay.topUps.watch(topUp.orderNumber)) {
  if (t.isCredited) showCredited(t.amount);
  if (t.status == TopUpStatus.rejected) showError(t.message);
}
Статус Что показать
codeSent поле ввода кода; с isCodeIncorrect — «неверный код»
processing «банк подтверждает». Не отказ: деньги могли уже списаться
credited зачислено
rejected message — текст для показа; reason — код для ветвления
unknown как ожидание
  • Код MBank приходит push-уведомлением в приложение MBank, а не SMS — скажите это человеку до ввода.
  • Номер и сумма проверяются до вызова: номер не 996XXXXXXXXX или сумма вне 1–1 000 000 сом — ValidationError с field phone или amount. Для поля ввода — TopUp.normalizePhone(raw) и TopUp.acceptsAmount(amount).
  • Больше пяти пополнений за 15 минут — RateLimitError.
  • Отказ банка — статус rejected, а не исключение. Код не ввели за 15 минут — rejected с EXPIRED, денег никто не списывал.
  • Неверный PIN MegaPay окончателен — начните новое пополнение.
  • Автоповторов нет: повтор start шлёт плательщику второй код.
  • confirm упал с TimeoutError, NetworkError или ServerError — исход неизвестен: покажите «уточняем» и watch до исхода, код заново не отправляйте. ValidationError на confirm — код отбит до банка, введите снова.

watch сбои связи переживает сам: пауза растёт до 30 секунд. Разовый запрос — topUps.status(orderNumber).

QR

Код рисует сервер: приходит готовый PNG по национальному стандарту.

Свой постоянный код

final image = await finipay.qr.permanent();
Image.memory(image.png); // готовые байты PNG

Повторный вызов вернёт тот же код, срока жизни нет.

Код на сумму

if (!QrImage.acceptsAmount(amount)) return showAmountError();

final image = await finipay.qr.forAmount(amount);
image.expiresAt; // через 15 минут после выпуска

// Поток закрывается сам, когда исход известен.
await for (final tx in finipay.qr.watch(image.transactionId)) {
  if (tx.isPaid) showPaid(tx.amount);
  if (tx.isFailed) showFailed();
}
  • Активных кодов на сумму — не больше пяти: иначе RateLimitError. forAmount не повторяется сам — каждый вызов выпускает новый код.
  • Сумма — в пределах QrImage.minAmount…QrImage.maxAmount, иначе ValidationError.
  • Обратный отсчёт считайте от image.expiresAt.
  • История кодов — qr.transactions(from:, to:, kind:, limit:, offset:).

Страница оплаты своего кода

У кода на сумму может прийти image.payFormUrl — страница, где ту же сумму платят из MBank, MegaPay или по ELQR. Нет поля — нет кнопки. Оплата закрывает тот же transactionId, поэтому слежение не меняется.

final session = HostedPageSession(
  request: finipay.hostedPage(
    url: image.payFormUrl!,
    timeout: QrImage.dynamicLifetime,
    title: 'Оплата',
  ),
);
final navigator = Navigator.of(context);
final route = MaterialPageRoute<void>(
  builder: (_) => HostedPageScreen(session: session),
);
unawaited(navigator.push(route));

final result = await session.result;
if (route.isActive) navigator.removeRoute(route);
if (result is HostedPageBroken) showError(result.cause.message);
  • finipay.hostedPage бросает HostedPageSecurityException, если адрес не https или хост не из своего контура.
  • Страница обратно не редиректит. HostedPageCancelled и HostedPageTimedOut — не отказ. Правду знает qr.watch: держите его, пока страница открыта, и на isPaid закройте её — session.dispose().
  • Без BuildContext — InAppWebViewLauncher(navigatorKey: …).open(request).
  • Не внедряйте JS в страницу, не ставьте мост в приложение, не перехватывайте её запросы и не пропускайте ошибки TLS.

Оплата чужого кода

Камера — ваша. Пакет разбирает строку (EmvQr.parse, контрольная сумма сверяется) и держит защёлку, чтобы экран оплаты открылся один раз:

final gate = QrScanGate(
  onPayload: (code) => openPayment(code), // один раз до reset()
  onUnknownCode: (e) => showHint('Это не код для оплаты'),
);

// Например, mobile_scanner:
MobileScanner(
  onDetect: (capture) =>
      gate.submitAll(capture.barcodes.map((b) => b.rawValue)),
);
// Вернулись к сканеру после отмены — gate.reset().

Чужой код оплачивается обычным переводом:

final phone = code.recipient;
if (phone == null) return showNotPayable();

final recipient = await finipay.transfers.checkRecipient(phone);
final order = recipient.orderFor(PaymentProvider.finipay);
if (order == null) return showNotPayable();

// Сумма из кода на сумму — обязательство: менять её нельзя.
final amount = code.amount ?? await askAmount();
// Дальше — begin и createTransfer, как в переводе.

Код другого банка, где реквизит — не телефон пользователя Finipay, из кошелька не оплатить: checkRecipient ответит «не найден».

История

final page = await finipay.history.history(
  page: 0,
  pageSize: 20,
  from: from,
  to: to,
  type: OperationType.transfer, // или .payment; без него — всё
);
page.items;   // Operation: amount, createdAt, state, direction, title
page.hasMore; // следующая страница — page.nextPage

await finipay.history.search('Айбек');   // по номеру или имени
await finipay.history.favoriteContacts(); // телефоны частых переводов

Переводы, вывод и пополнения приходят одной выдачей.

Статусы операций

finipay.transfers.watch(orderNumber).listen(show);
finipay.withdrawals.watch(orderNumber).listen(show);
finipay.history.watch(operationId).listen(show);

Поток закрывается на финальном статусе (status.isFinal), повторы одного статуса отбрасываются. Разовый запрос — transfers.status, withdrawals.status, history.status. QR и пополнение следят своими qr.watch и topUps.watch.

В фоне опрос останавливайте — отменой подписки: pause() только копит события, запросы идут дальше. Вернулись на передний план — подпишитесь заново, поток начнёт со свежего статуса:

final foreground = AppForeground()..start();
StreamSubscription<QrTransaction>? subscription;

void follow() {
  subscription?.cancel();
  subscription = foreground.value
      ? finipay.qr.watch(transactionId).listen(show)
      : null;
}

follow();
foreground.addListener(follow);
// На закрытии экрана: subscription?.cancel(); foreground.dispose();

Деньги

Только Money поверх целых минорных единиц, double в денежных полях нет.

const Money.minor(25000);  // 250.00 сом
Money.major(250);          // то же
Money.parse('250.00');     // то же; лишний разряд — MoneyFormatException

money.toDecimalString();                    // '250.00'
MoneyFormatter(locale: 'ru').format(money); // для экрана

Сложение сумм в разных валютах бросает CurrencyMismatchException.

После перезапуска

Платёж мог пройти, пока приложение убивали. При старте покажите правду:

for (final intent in await finipay.pendingOperations()) {
  final orderNumber = intent.orderNumber;
  if (orderNumber == null) {
    await finipay.completeOperation(intent); // запрос не уходил
    continue;
  }
  final status = await finipay.transfers.status(orderNumber);
  if (status.isFinal) {
    await finipay.completeOperation(intent);
    showResult(status);
  } else {
    showPending(intent); // и transfers.watch(orderNumber)
  }
}

for (final t in await finipay.topUps.unfinished()) {
  showPendingTopUp(t); // «пополнение ещё обрабатывается»
  finipay.topUps.watch(t.orderNumber).listen(showTopUp);
}
  • pendingOperations() — незакрытые переводы и вывод, старше суток вычищаются сами. Статус вывода — тем же transfers.status.
  • Не закрыли намерение — оно вернётся при следующем старте.
  • Для topUps.unfinished() поле кода не показывайте: предъявлен ли код до перезапуска, неизвестно. Без кода пополнение истечёт само.

Обязательное обновление

Сервер может запретить денежные операции устаревшей сборке. Тогда finipay.appConfig.updateBlock() возвращает причину, а перевод и вывод бросают UpdateRequiredError до выхода в сеть.

void checkUpdate() {
  final block = finipay.appConfig.updateBlock();
  if (block != null) {
    showUpdateScreen(block.message, storeUrl: block.storeUrl);
  }
}

checkUpdate();
finipay.appConfig.updates.listen((_) => checkUpdate());

Флаги функций — finipay.appConfig.current?.isEnabled('имя').

Ошибки

Вызов Как приходит неудача
не денежный: вход, профиль, баланс, история, QR, пополнение исключение FinipayError
денежный: createTransfer, withdraw значение PaymentOutcome
пополнение — отказ банка статус TopUpStatus.rejected с причиной

message — всегда понятный текст из словаря SDK, его можно показывать как есть. Ответ сервера лежит в serverMessage — для журнала, не для пользователя.

Тип Когда Что делать
ValidationError неверный ввод; field — какое поле показать message у поля
InsufficientFundsError не хватает денег предложить пополнение
UserNotFoundError получателя нет «получатель не найден»
RateLimitError слишком часто: коды, QR, пополнения подождать retryAfter, если пришёл
EncryptionError сервер не расшифровал параметр перевода ошибка сборки: не тот ключ или вектор
UnauthorizedError сессии нет и поднять её не удалось вход; с partnerCode SDK входит сам
AccountNotActivatedError аккаунт не подтверждён (HTTP 423) экран подтверждения
OperationInProgressError операция уже выполняется ждать, новое намерение не создавать
UpdateRequiredError сборка старше поддерживаемой экран обновления
NetworkError нет связи повторить
TimeoutError сервер не ответил вовремя повторить; на денежном вызове — PaymentIndeterminate
CancelledError запрос отменил сам вызывающий код ничего
ServerError 5xx и любой незнакомый код показать message

У ошибок и исходов, пришедших с сервера, есть traceId — присылайте его с жалобой. statusCode — HTTP-статус, details — диагностика для журнала. Незнакомый код сервера приходит ServerError и приложение не роняет.

Числовые коды кошелька:

Код Где Тип field
104 пять активных QR; больше 5 пополнений за 15 минут RateLimitError
108 сумма QR или пополнения вне границ ValidationError amount
128 одноразовый код не подошёл ValidationError code
140 номер плательщика не 996XXXXXXXXX ValidationError phone
305 параметр перевода не расшифровался EncryptionError
306 получателя нет UserNotFoundError
422 не хватает средств InsufficientFundsError
434 перевод меньше 1 сома ValidationError amount

Как ответы на перевод и вывод становятся исходами:

Ответ Исход
200 и CHARGED PaymentAuthorized
200 и AWAIT PaymentPending
200 и REJECTED PaymentDeclined
409 — по этому заказу перевод уже есть PaymentPending: первая попытка дошла
422, InsufficientFundsError PaymentDeclined(insufficientFunds)
NetworkError, TimeoutError, 5xx PaymentIndeterminate

PaymentDeclined.reason — DeclineReason. Сейчас приходят insufficientFunds и unknown, остальные значения — на будущее; для fraudSuspected и blocked причину не раскрывают (isPresentable == false). Показывайте message.

Причины отказа пополнения (TopUp.reason):

reason Банк Что значит
OTP_INCORRECT MBank неверный код; статус остаётся codeSent, можно ввести ещё раз
PINCODE_INCORRECT MegaPay неверный PIN; начать новое пополнение
INSUFFICIENT_FUNDS оба у плательщика не хватает денег
PAYER_NOT_FOUND, USER_NOT_FOUND MBank, MegaPay номера нет у банка
ACCOUNT_NUMBER_NOT_FOUND оба у плательщика нет счёта
USER_IS_BLOCKED MegaPay плательщик заблокирован
USER_NOT_IDENTIFIED, NEED_IDENTIFY MegaPay плательщик не прошёл идентификацию
INCORRECT_NUMBER_FORMAT MegaPay банк не принял номер
MIN_SUM, LIMIT_MAX_SUM MegaPay сумма вне допустимой у банка
LIMIT_TRANSACTIONS_MAX_SUM, TRANSACTION_LIMIT_EXCEEDED оба превышен лимит
OTP_NOT_CREATED, MAX_SMS MBank банк не смог выслать код
TRANSACTION_CANCELED, CONFIRMATION_TIMEOUT, OTP_NOT_CONFIRMED MBank подтверждение не состоялось
SERVICE_IS_BLOCKED оба способ выключен
PAYMENT_FAILED оба банк отклонил списание
SERVICE_ERROR, ERROR, SERVICE_UNAVAILABLE оба сбой у банка до ввода кода; денег не списывали
EXPIRED — код не ввели за 15 минут

Тексты для пользователя

message у ошибок, у PaymentDeclined и у TopUp выбирается так:

  1. messages серверной конфигурации (по locale и арендатору) — правятся без релиза, так приходят и кыргызские тексты;
  2. messages из Finipay.init — свои формулировки приложения;
  3. встроенный русский словарь.
await Finipay.init(
  // ...
  messages: const {'error.network': 'Нет интернета. Проверьте Wi-Fi.'},
);

Для своих экранов — finipay.texts: of(key), forDecline(reason), forTopUpRejection(reason). Незнакомый ключ даёт текст error.unknown, незнакомая причина отказа пополнения — topup.rejected.

Ключ Встроенный текст
error.network Нет связи. Проверьте интернет и повторите.
error.timeout Сервер не ответил вовремя. Повторите попытку.
error.unauthorized Сессия истекла. Войдите заново.
error.signInRequired Войдите в приложение, чтобы продолжить.
error.sessionUserChanged Сменился пользователь. Войдите заново.
error.cancelled Операция отменена.
error.server Сервис временно недоступен. Повторите позже.
error.unknown Что-то пошло не так. Повторите позже.
error.updateRequired Обновите приложение, чтобы продолжить.
error.rateLimited Слишком много попыток. Подождите и повторите.
error.accountNotActivated Аккаунт не подтверждён. Введите код из сообщения.
error.encryption Перевод не отправлен. Обновите приложение.
error.insufficientFunds Недостаточно средств. Пополните кошелёк.
error.userNotFound Получатель не найден. Проверьте номер.
error.operationInProgress Операция уже выполняется. Дождитесь результата.
error.validation Проверьте введённые данные.
error.invalidAmount Сумма вне допустимых границ.
error.amountBelowMinimum Сумма перевода — не меньше 1 сома.
error.invalidOtp Неверный код. Проверьте и введите ещё раз.
error.invalidPayerPhone Номер плательщика — в формате 996XXXXXXXXX.
error.topUpAmount Сумма пополнения — от 1 до 1 000 000 сом.
error.selfTransfer Нельзя перевести самому себе.
error.qrLimit Слишком много запросов подряд. Попробуйте через 15 минут.
error.hostedPage Не удалось открыть страницу оплаты. Повторите попытку.
payment.declined Операция отклонена.
payment.declined.INSUFFICIENT_FUNDS Недостаточно средств. Пополните кошелёк.
payment.declined.CARD_EXPIRED Срок действия карты истёк.
payment.declined.LIMIT_EXCEEDED Превышен лимит операций.
payment.declined.INVALID_RECIPIENT Получатель не может принять перевод.
payment.declined.RECIPIENT_BLOCKED Счёт получателя заблокирован.
payment.declined.AUTHENTICATION_REQUIRED Операцию нужно подтвердить. Повторите попытку.
payment.declined.DUPLICATE_TRANSACTION Такая операция уже выполнена.
topup.rejected Пополнение не состоялось.
topup.rejected.OTP_INCORRECT Неверный код MBank. Введите ещё раз.
topup.rejected.PINCODE_INCORRECT Неверный PIN MegaPay. Начните пополнение заново.
topup.rejected.INSUFFICIENT_FUNDS У плательщика недостаточно средств.
topup.rejected.PAYER_NOT_FOUND Номер не найден в банке.
topup.rejected.USER_NOT_FOUND Номер не найден в банке.
topup.rejected.ACCOUNT_NUMBER_NOT_FOUND У плательщика нет счёта в банке.
topup.rejected.USER_IS_BLOCKED Плательщик заблокирован банком.
topup.rejected.USER_NOT_IDENTIFIED Плательщик не прошёл идентификацию в банке.
topup.rejected.NEED_IDENTIFY Плательщик не прошёл идентификацию в банке.
topup.rejected.INCORRECT_NUMBER_FORMAT Банк не принял номер. Проверьте его.
topup.rejected.MIN_SUM Сумма меньше допустимой для банка.
topup.rejected.LIMIT_MAX_SUM Сумма больше допустимой для банка.
topup.rejected.LIMIT_TRANSACTIONS_MAX_SUM Превышен лимит операций.
topup.rejected.TRANSACTION_LIMIT_EXCEEDED Превышен лимит операций.
topup.rejected.OTP_NOT_CREATED Банк не смог отправить код. Попробуйте позже.
topup.rejected.MAX_SMS Слишком много кодов. Попробуйте позже.
topup.rejected.TRANSACTION_CANCELED Пополнение отменено.
topup.rejected.CONFIRMATION_TIMEOUT Время на подтверждение истекло.
topup.rejected.OTP_NOT_CONFIRMED Код не подтверждён. Начните заново.
topup.rejected.SERVICE_IS_BLOCKED Пополнение этим способом сейчас недоступно.
topup.rejected.PAYMENT_FAILED Банк отклонил списание.
topup.rejected.SERVICE_ERROR Сбой у банка. Деньги не списаны, попробуйте позже.
topup.rejected.ERROR Сбой у банка. Деньги не списаны, попробуйте позже.
topup.rejected.SERVICE_UNAVAILABLE Банк не ответил. Деньги не списаны, попробуйте позже.
topup.rejected.EXPIRED Код не введён вовремя. Начните заново.

Журнал и пиннинг

В logger SDK пишет предупреждения — например, init без ключей шифрования; токены, коды, пароли и телефоны вычищаются до того, как текст попадёт в ваш журнал. Для поддержки сохраняйте traceId из ошибок и исходов.

Отпечатки ключей задаются только в Finipay.init, с сервера они не приходят:

certificatePins: [
  CertificatePins(
    host: '…', // хост и отпечатки выдаёт Finipay
    spkiSha256: ['основной…', 'запасной…'],
    expiresAt: DateTime.utc(2027, 3, 1),
  ),
],
  • Минимум два отпечатка — текущий и запасной; с одним набор не действует.
  • expiresAt обязателен: после этой даты набор отключается сам.
  • Закрепляется открытый ключ своих хостов. Отпечатки и ротацию согласуйте с Finipay; пустой список — обычная проверка цепочки.

Защита экрана и признаки устройства

// Экран, который нельзя снимать: FLAG_SECURE на Android. Защита снимается,
// когда экран закрыт.
ScreenCaptureProtection(child: paymentScreen);

// Root, эмулятор, отладка — сигнал, а не запрет: решает сервер.
final integrity = await DeviceIntegrityProbe.check();
if (integrity.isSuspicious) reportToBackend(integrity.toJson());

На iOS снимок экрана запретить нельзя: защита затемняет экран при уходе в фон и реагирует на запись. integrity.checked == false — проверить не смогли, а не «чисто».

Тесты приложения

Finipay.init ходит в платформенные каналы, которых в виджет-тестах нет. Для тестов — createTestFinipay:

import 'package:finipay/finipay.dart';
import 'package:finipay/finipay_testing.dart';

final adapter = StubHttpAdapter.byPath({
  '/users/balance': const StubResponse(
    body: {'code': 200, 'message': 'OK', 'data': '12450.00'},
  ),
});
final finipay = await createTestFinipay(
  adapter: adapter,
  signedInAs: 'u-1', // сессия уже открыта
);

expect(await finipay.users.balance(), Money.parse('12450.00'));
expect(adapter.countOf('/users/balance'), 1);
await finipay.dispose();
  • adapter — подменённая сеть: StubHttpAdapter.always, .byPath или своя функция; запросы пишутся в adapter.requests, обрыв связи — StubResponse.networkError().
  • transfers: false — как init без ключей шифрования.
  • secureStore, localStore — InMemoryKeyValueStore: один экземпляр в два вызова проверяет холодный старт.
  • Остальное — как у init: tenant, appVersion, branding, partnerCode, partnerUser, messages, moneyCodec.

createTestFinipay не трогает Finipay.instance и в настоящую сеть не ходит.

Стенд

На стенде SMS не отправляются: код подтверждения берётся служебным вызовом со служебным токеном, который выдаёт Finipay.

import 'package:finipay/finipay_dev.dart';

final otp = await DevOtpClient(
  baseUrl: FinipayEnvironment.stand.baseUrl,
  serviceToken: serviceToken,
).fetch(phone);

finipay_dev.dart в релизную сборку не подключайте: в релизе DevOtpClient бросает StateError. Тестовые номера банков для пополнения — у вашего контакта в Finipay.

Безопасность: что обязано сделать приложение

  1. finipay.signOut() при выходе и смене пользователя, partnerUser в init.

  2. Ключ партнёра — только на сервере. В приложении — одноразовый код.

  3. Резервное копирование выключено — allowBackup="false" или правила исключения (раздел «Настройка платформ»).

  4. Отчёты о сбоях вычищены. В отчёт — тип ошибки, code, statusCode и traceId; не отправляйте тела запросов, serverMessage и details. Sentry: sendDefaultPii = false, вычистка в beforeSend и отдельно крошек — HTTP-крошки содержат полный адрес. Crashlytics: ничего производного от токенов в setCustomKey и log.

  5. Релиз с обфускацией, символы в архиве:

    flutter build appbundle --release \
      --obfuscate --split-debug-info=build/symbols/$VERSION
    

    Обфускация не прячет строки — секретов в сборке быть не должно.

  6. Ключи перевода — из --dart-define или вашей системы конфигурации, не из кода; больше нигде не переиспользуются. Экрана для ввода ключа нет.

  7. Возвраты со страниц оплаты — только App Links и Universal Links, не свои схемы вида myapp://.

Перед публикацией:

  • signOut() при выходе и смене пользователя, partnerUser в init;
  • ключа партнёра в приложении нет;
  • allowBackup="false" или правила исключения;
  • вычистка Sentry и Crashlytics, включая крошки;
  • сборка с --obfuscate --split-debug-info, символы в архиве;
  • ключи перевода не в коде;
  • finipay_dev.dart в релиз не подключён;
  • NSAllowsArbitraryLoads не включён, cleartext запрещён;
  • пиннинг — только свои хосты, два отпечатка и expiresAt, либо выключен.

Об уязвимостях — security@finipay.kg, не публичной задачей.

Частые проблемы

Симптом Что сделать
ArgumentError про transferKey пустая строка — забытый --dart-define; длина — 32 и 16 байт UTF-8
StateError: Finipay.init() уже вызван с другим контуром… await Finipay.instance.dispose(), затем новый init
StateError: Шифрование переводов не настроено в init переданы null; проверяйте transfers.isEncryptionConfigured до формы
каждый вызов — UnauthorizedError partnerCode передан, не бросает, отвечает быстрее 15 секунд, возвращает код, а не pk_…, код свежий; partnerUser не null. После неудачи 3 секунды SDK отвечает той же ошибкой
StateError из finipay.signIn() signIn() работает только с partnerCode
новый пользователь видит кошелёк предыдущего не вызван signOut() при смене пользователя
пользователь вышел после переустановки так задумано: токены не попадают в бэкапы; с partnerCode вход сам
EncryptionError (305) на проверке получателя не тот ключ или вектор, не та длина в байтах
списалось дважды ссылка намерения — на операцию, а не на телефон; повтор — тем же намерением; PaymentIndeterminate не показан как отказ; pendingOperations() при старте
PaymentPending при ответе 200 так и должно быть: итог — по watch
повтор после отказа сразу «отправляем» (PaymentPending) begin с той же ссылкой вернул старое намерение со старым заказом: новая проверка получателя и attempt: ++attempt
«код MBank не приходит» он приходит push-уведомлением в приложение MBank
кнопки «Оплатить из банка» нет payFormUrl приходит не всегда — это не ошибка
экран оплаты открывается несколько раз пропускайте строки камеры через QrScanGate
всё перестало работать после смены сертификата пиннинг: нужен второй отпечаток или новая сборка; проверьте expiresAt
network_security_config.xml не действует на SDK и не должен: dart:io его не читает

В обращении укажите traceId, версию пакета, контур, платформу и шаги до ошибки.

Версии

Пока версия ниже 1.0.0, каждый выпуск подключается явно: ^0.0.1 пускает только 0.0.1. Что изменилось и как перейти — в журнале изменений (вкладка Changelog).

Лицензия — в файле LICENSE: использование только в приложениях, согласованных с Finipay.

Libraries

finipay
Finipay SDK: переводы, вывод, пополнение, QR, история, профиль и баланс.
finipay_dev
Служебный клиент тестового контура: коды подтверждения со стенда.
finipay_testing
Для тестов приложения: SDK на подменённой сети и хранилища в памяти.