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,54 @@
import 'dart:typed_data';
enum IdentityStatus { unverified, pending, approved, changesRequested, denied }
enum ReviewReason {
passportUnreadable,
selfieUnreadable,
passportExpired,
unableToVerify,
}
enum PhotoKind { passport, selfie }
enum KycError {
unavailable,
internal,
permission,
format,
tooLarge,
unreadable,
tooSmall,
incomplete,
sessionExpired,
rateLimited,
conflict,
}
class KycException implements Exception {
const KycException(this.code);
final KycError code;
}
class Identity {
const Identity({
required this.status,
this.reason,
this.submissionId,
this.simulated = false,
});
final IdentityStatus status;
final ReviewReason? reason;
final String? submissionId;
final bool simulated;
bool get canSubmit =>
status == IdentityStatus.unverified ||
status == IdentityStatus.changesRequested;
}
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
class Photo {
Photo(Uint8List bytes)
: bytes = Uint8List.fromList(bytes).asUnmodifiableView();
final Uint8List bytes;
}
@@ -0,0 +1,31 @@
enum AccountTier { basic, verified }
enum LimitPeriod { day, month }
class VisibleLimit {
const VisibleLimit({
required this.period,
required this.currency,
required this.basicMinor,
required this.verifiedMinor,
required this.currentMinor,
required this.remainingMinor,
});
final LimitPeriod period;
final String currency;
final int basicMinor;
final int verifiedMinor;
final int currentMinor;
final int remainingMinor;
}
class Limits {
Limits({
required this.tier,
required List<VisibleLimit> rows,
required this.simulated,
}) : rows = List.unmodifiable(rows);
final AccountTier tier;
final List<VisibleLimit> rows;
final bool simulated;
}
@@ -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();
}
}
@@ -0,0 +1,24 @@
import '../models/identity.dart';
import '../models/limits.dart';
abstract interface class IdentityService {
bool get isSimulated;
Future<Identity> fetch();
/// Backend adapters must scope the key to the authenticated account and reject
/// stale/duplicate review transitions. Retrying the same key is idempotent.
Future<Identity> submit({
required Photo passport,
required Photo selfie,
required String idempotencyKey,
});
}
abstract interface class LimitsService {
/// Fetch server-owned visible limits. No client-selected KYC status or tier.
Future<Limits> fetch();
}
abstract interface class PhotoService {
Future<Photo?> pick();
}
@@ -0,0 +1,93 @@
import '../models/identity.dart';
import '../models/limits.dart';
import '../transport/contracts.dart';
import 'contracts.dart';
/// Process-local backend stand-in. It never stores or sends document bytes.
class DummyIdentityService implements IdentityService {
DummyIdentityService({this.delay = const Duration(milliseconds: 450)});
final Duration delay;
IdentityDTO _record = const IdentityDTO('unverified');
String? _lastKey;
KycError? nextError;
@override
bool get isSimulated => true;
Future<void> _wait() async {
await Future<void>.delayed(delay);
final error = nextError;
nextError = null;
if (error != null) throw KycException(error);
}
@override
Future<Identity> fetch() async {
await _wait();
return IdentityMapper.fromDTO(_record);
}
@override
Future<Identity> submit({
required Photo passport,
required Photo selfie,
required String idempotencyKey,
}) async {
await _wait();
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
if (!IdentityMapper.fromDTO(_record).canSubmit)
throw const KycException(KycError.conflict);
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
throw const KycException(KycError.incomplete);
_lastKey = idempotencyKey;
_record = IdentityDTO('pending', id: idempotencyKey);
return IdentityMapper.fromDTO(_record);
}
/// Explicit demo-only human decision stand-in; unavailable in real adapters.
void review(IdentityStatus status, {ReviewReason? reason}) {
if (_record.status != 'pending')
throw const KycException(KycError.conflict);
if (![
IdentityStatus.approved,
IdentityStatus.changesRequested,
IdentityStatus.denied,
].contains(status))
throw const KycException(KycError.conflict);
_record = IdentityDTO(status.name, id: _record.id, reason: reason?.name);
}
}
/// Mimics backend policy evaluation. Widgets/providers never compute active caps.
/// Values are illustrative fixtures, not the product's real limits policy.
class DummyLimitsService implements LimitsService {
DummyLimitsService(this.identity);
final IdentityService identity;
@override
Future<Limits> fetch() async {
final record = await identity.fetch();
final approved = record.status == IdentityStatus.approved;
return LimitsMapper.fromDTO(
LimitsDTO(
tier: approved ? 'verified' : 'basic',
simulated: true,
rows: [
LimitDTO(
'day',
'EUR',
10000,
250000,
approved ? 250000 : 10000,
approved ? 250000 : 10000,
),
LimitDTO(
'month',
'EUR',
50000,
1000000,
approved ? 1000000 : 50000,
approved ? 1000000 : 50000,
),
],
),
);
}
}
@@ -0,0 +1,81 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_selector/file_selector.dart';
import 'package:image/image.dart' as metadata;
import '../models/identity.dart';
import 'contracts.dart';
class FilePhotoService implements PhotoService {
const FilePhotoService();
static const maxBytes = 10 * 1024 * 1024;
@override
Future<Photo?> pick() async {
try {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'JPEG / PNG',
extensions: ['jpg', 'jpeg', 'png'],
mimeTypes: ['image/jpeg', 'image/png'],
uniformTypeIdentifiers: ['public.jpeg', 'public.png'],
),
],
);
if (file == null) return null;
if (await file.length() > maxBytes)
throw const KycException(KycError.tooLarge);
final bytes = await file.readAsBytes();
return await validate(bytes);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.permission);
}
}
static Future<Photo> validate(Uint8List bytes) async {
try {
if (bytes.length > maxBytes) throw const KycException(KycError.tooLarge);
final png =
bytes.length > 8 &&
bytes[0] == 137 &&
bytes[1] == 80 &&
bytes[2] == 78 &&
bytes[3] == 71;
final jpg =
bytes.length > 3 &&
bytes[0] == 255 &&
bytes[1] == 216 &&
bytes[2] == 255;
if (!png && !jpg) throw const KycException(KycError.format);
final metadata.Decoder decoder = png
? metadata.PngDecoder()
: metadata.JpegDecoder();
final info = decoder.startDecode(bytes);
if (info == null) throw const KycException(KycError.unreadable);
if (info.width < 600 || info.height < 600)
throw const KycException(KycError.tooSmall);
if (info.width * info.height > 40000000)
throw const KycException(KycError.tooLarge);
final codec = await ui.instantiateImageCodec(
bytes,
targetWidth: 1000,
allowUpscaling: false,
);
try {
final frame = await codec.getNextFrame();
frame.image.dispose();
} finally {
codec.dispose();
}
return Photo(bytes);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.unreadable);
}
}
}
@@ -0,0 +1,64 @@
import '../models/identity.dart';
import '../models/limits.dart';
// Transport contracts for the stub. Replace with generated backend DTOs later.
class IdentityDTO {
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
final String status;
final String? reason;
final String? id;
final bool simulated;
}
class IdentityMapper {
static Identity fromDTO(IdentityDTO dto) => Identity(
status: IdentityStatus.values.byName(dto.status),
reason: dto.reason == null ? null : ReviewReason.values.byName(dto.reason!),
submissionId: dto.id,
simulated: dto.simulated,
);
}
class LimitsDTO {
const LimitsDTO({
required this.tier,
required this.rows,
required this.simulated,
});
final String tier;
final List<LimitDTO> rows;
final bool simulated;
}
class LimitDTO {
const LimitDTO(
this.period,
this.currency,
this.basic,
this.verified,
this.current,
this.remaining,
);
final String period;
final String currency;
final int basic, verified, current, remaining;
}
class LimitsMapper {
static Limits fromDTO(LimitsDTO dto) => Limits(
tier: AccountTier.values.byName(dto.tier),
simulated: dto.simulated,
rows: dto.rows
.map(
(r) => VisibleLimit(
period: LimitPeriod.values.byName(r.period),
currency: r.currency,
basicMinor: r.basic,
verifiedMinor: r.verified,
currentMinor: r.current,
remainingMinor: r.remaining,
),
)
.toList(),
);
}
@@ -0,0 +1,7 @@
export 'src/models/identity.dart';
export 'src/models/limits.dart';
export 'src/providers/identity.dart';
export 'src/providers/limits.dart';
export 'src/services/contracts.dart';
export 'src/services/dummy.dart';
export 'src/services/photos.dart';