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сfieldphoneили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 выбирается так:
messagesсерверной конфигурации (поlocaleи арендатору) — правятся без релиза, так приходят и кыргызские тексты;messagesизFinipay.init— свои формулировки приложения;- встроенный русский словарь.
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.
Безопасность: что обязано сделать приложение
-
finipay.signOut()при выходе и смене пользователя,partnerUserвinit. -
Ключ партнёра — только на сервере. В приложении — одноразовый код.
-
Резервное копирование выключено —
allowBackup="false"или правила исключения (раздел «Настройка платформ»). -
Отчёты о сбоях вычищены. В отчёт — тип ошибки,
code,statusCodeиtraceId; не отправляйте тела запросов,serverMessageиdetails. Sentry:sendDefaultPii = false, вычистка вbeforeSendи отдельно крошек — HTTP-крошки содержат полный адрес. Crashlytics: ничего производного от токенов вsetCustomKeyиlog. -
Релиз с обфускацией, символы в архиве:
flutter build appbundle --release \ --obfuscate --split-debug-info=build/symbols/$VERSIONОбфускация не прячет строки — секретов в сборке быть не должно.
-
Ключи перевода — из
--dart-defineили вашей системы конфигурации, не из кода; больше нигде не переиспользуются. Экрана для ввода ключа нет. -
Возвраты со страниц оплаты — только 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 на подменённой сети и хранилища в памяти.