Add optional KYC flow and service-backed account limits

This commit is contained in:
Stephan D committed 2026-09-12 09:28:24 +02:00
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();
}
}