Add optional KYC flow and service-backed account limits
This commit is contained in:
1 parent
61ccfc1844
commit
3397f04ab7
38 files changed
+3443
-36
No files matched your search
@@ -0,0 +1,235 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/identity.dart';
|
||||
import '../services/contracts.dart';
|
||||
import '../services/dummy.dart';
|
||||
|
||||
class IdentityProvider extends ChangeNotifier {
|
||||
IdentityProvider(
|
||||
this.service,
|
||||
this.photos,
|
||||
this.journeys, {
|
||||
this.onStatusChanged,
|
||||
});
|
||||
final Future<void> Function()? onStatusChanged;
|
||||
final IdentityService service;
|
||||
final PhotoService photos;
|
||||
final JourneyRecorder journeys;
|
||||
Identity? record;
|
||||
Photo? passport;
|
||||
Photo? selfie;
|
||||
KycError? error;
|
||||
PhotoKind? photoError;
|
||||
bool busy = false;
|
||||
bool stale = false;
|
||||
bool editing = false;
|
||||
bool confirmed = false;
|
||||
int step = 0;
|
||||
bool _disposed = false;
|
||||
String? _key;
|
||||
bool get hasDraft => passport != null || selfie != null;
|
||||
bool get canSubmit =>
|
||||
!busy &&
|
||||
!stale &&
|
||||
confirmed &&
|
||||
passport != null &&
|
||||
selfie != null &&
|
||||
(record?.canSubmit ?? false);
|
||||
bool get isDemo => service.isSimulated;
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
JourneyOperation _begin(JourneyStep step) =>
|
||||
journeys.begin(JourneyKind.kyc, step, simulated: isDemo);
|
||||
Future<void> refresh() async {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
photoError = null;
|
||||
_notify();
|
||||
final op = _begin(JourneyStep.kycStatus);
|
||||
try {
|
||||
final result = await service.fetch();
|
||||
if (_disposed) return;
|
||||
record = result;
|
||||
stale = false;
|
||||
await onStatusChanged?.call();
|
||||
if (_disposed) return;
|
||||
if (!result.canSubmit) {
|
||||
_clear();
|
||||
editing = false;
|
||||
}
|
||||
journeys.finish(op, simulated: isDemo, terminal: true);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
stale = true;
|
||||
error = _error(e);
|
||||
journeys.finish(op, failure: JourneyFailure.unavailable, terminal: false);
|
||||
}
|
||||
busy = false;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (busy || stale || !(record?.canSubmit ?? false)) return;
|
||||
editing = true;
|
||||
error = null;
|
||||
if (!hasDraft) {
|
||||
step = 0;
|
||||
confirmed = false;
|
||||
}
|
||||
final op = _begin(JourneyStep.kycPreparation);
|
||||
journeys.finish(op, simulated: isDemo);
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> pick(PhotoKind kind) async {
|
||||
if (busy || !editing) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
photoError = null;
|
||||
_notify();
|
||||
final op = _begin(
|
||||
kind == PhotoKind.passport
|
||||
? JourneyStep.kycPassport
|
||||
: JourneyStep.kycSelfie,
|
||||
);
|
||||
try {
|
||||
final photo = await photos.pick();
|
||||
if (_disposed) return;
|
||||
if (photo != null) {
|
||||
if (kind == PhotoKind.passport) {
|
||||
passport = photo;
|
||||
} else {
|
||||
selfie = photo;
|
||||
}
|
||||
confirmed = false;
|
||||
_key = null;
|
||||
}
|
||||
journeys.finish(op, simulated: isDemo, cancelled: photo == null);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _error(e);
|
||||
photoError = kind;
|
||||
journeys.finish(op, failure: JourneyFailure.document);
|
||||
}
|
||||
busy = false;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void remove(PhotoKind kind) {
|
||||
if (busy) return;
|
||||
if (kind == PhotoKind.passport) {
|
||||
passport = null;
|
||||
} else {
|
||||
selfie = null;
|
||||
}
|
||||
confirmed = false;
|
||||
_key = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void back() {
|
||||
if (!busy && step > 0) {
|
||||
step--;
|
||||
error = null;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
void next() {
|
||||
if (busy ||
|
||||
(step == 0 && passport == null) ||
|
||||
(step == 1 && selfie == null))
|
||||
return;
|
||||
if (step < 2) step++;
|
||||
error = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void confirm(bool value) {
|
||||
if (!busy) {
|
||||
confirmed = value;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (!canSubmit) return;
|
||||
busy = true;
|
||||
error = null;
|
||||
photoError = null;
|
||||
_notify();
|
||||
_key ??= List.generate(
|
||||
16,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
final op = _begin(JourneyStep.kycSubmission);
|
||||
try {
|
||||
final result = await service.submit(
|
||||
passport: passport!,
|
||||
selfie: selfie!,
|
||||
idempotencyKey: _key!,
|
||||
);
|
||||
if (_disposed) return;
|
||||
if (result.canSubmit) throw const KycException(KycError.conflict);
|
||||
record = result;
|
||||
editing = false;
|
||||
_clear();
|
||||
await onStatusChanged?.call();
|
||||
if (_disposed) return;
|
||||
journeys.finish(op, simulated: isDemo, terminal: true);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _error(e);
|
||||
if (error == KycError.conflict || error == KycError.sessionExpired)
|
||||
stale = true;
|
||||
journeys.finish(op, failure: JourneyFailure.unavailable);
|
||||
}
|
||||
busy = false;
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> demoReview(IdentityStatus status, {ReviewReason? reason}) async {
|
||||
if (busy || service is! DummyIdentityService) return;
|
||||
try {
|
||||
(service as DummyIdentityService).review(status, reason: reason);
|
||||
} on KycException catch (e) {
|
||||
error = e.code;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
void exit() {
|
||||
if (busy) return;
|
||||
journeys.abandon(JourneyExit.navigation);
|
||||
_clear();
|
||||
editing = false;
|
||||
error = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
passport = null;
|
||||
selfie = null;
|
||||
confirmed = false;
|
||||
_key = null;
|
||||
step = 0;
|
||||
}
|
||||
|
||||
KycError _error(Object e) => e is KycException ? e.code : KycError.internal;
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_clear();
|
||||
journeys.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/limits.dart';
|
||||
import '../services/contracts.dart';
|
||||
|
||||
class LimitsProvider extends ChangeNotifier {
|
||||
LimitsProvider(this.service);
|
||||
final LimitsService service;
|
||||
Limits? data;
|
||||
bool loading = false;
|
||||
bool failed = false;
|
||||
int _revision = 0;
|
||||
bool _disposed = false;
|
||||
Future<void> refresh() async {
|
||||
final revision = ++_revision;
|
||||
loading = true;
|
||||
failed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.fetch();
|
||||
if (_disposed || revision != _revision) return;
|
||||
data = result;
|
||||
} catch (_) {
|
||||
if (_disposed || revision != _revision) return;
|
||||
failed = true;
|
||||
}
|
||||
if (_disposed || revision != _revision) return;
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user