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,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,38 @@
|
||||
# sxchnge_kyc
|
||||
|
||||
Shared Flutter identity verification and backend-owned visible limits. Both web
|
||||
and future mobile clients consume the same models, providers and service contracts.
|
||||
|
||||
- `IdentityProvider` coordinates draft photos, validation errors, submission,
|
||||
refresh and semantic analytics. Inject `IdentityService` and `PhotoService`.
|
||||
- `LimitsProvider` fetches `LimitsService` models; it does not calculate caps from
|
||||
a client-selected status. Refresh after status updates. Existing values are
|
||||
marked stale after an error; out-of-order responses cannot replace newer data.
|
||||
- `DummyIdentityService` keeps only review metadata in memory. `DummyLimitsService`
|
||||
mimics backend policy with explicitly illustrative EUR fixtures. Neither is
|
||||
production authorization. `review` exists only on the dummy service.
|
||||
- `FilePhotoService` opens the platform picker and validates JPEG/PNG content,
|
||||
10 MiB size, minimum 600px per dimension, maximum 40 megapixels and decodability.
|
||||
It performs no face matching, OCR or identity approval. Camera capture is not
|
||||
implemented; users can take a photo separately and select it.
|
||||
- Photo models are transient; clients must dispose providers and evict sensitive
|
||||
image cache entries. No disk, browser storage, network or document telemetry is
|
||||
used by these stubs. No guarantee of cryptographic memory erasure is made.
|
||||
|
||||
Replace both identity and limits services when connecting a backend. A submit
|
||||
adapter must reuse the supplied idempotency key across retries, scope requests to
|
||||
the authenticated account, map safe errors and return server-confirmed status.
|
||||
Numeric values use minor currency units (currently a two-decimal currency fixture);
|
||||
extend the transport with currency precision before supporting other units.
|
||||
|
||||
The app's `/identity` route is an explicitly public demo, not an authenticated
|
||||
account endpoint. Production requires session/email guards, server authorization,
|
||||
private document storage, staff review and support integration. See
|
||||
[the UX/measurement contract](../../../docs/ux/kyc.md).
|
||||
|
||||
Validation: `flutter pub get`, `flutter analyze`, `flutter test`.
|
||||
|
||||
Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_selector)
|
||||
and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html).
|
||||
For native macOS consumers enable the user-selected read-only file entitlement;
|
||||
iOS/Android camera permissions are not required for this file-picker-only flow.
|
||||
@@ -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';
|
||||
@@ -0,0 +1,17 @@
|
||||
name: sxchnge_kyc
|
||||
description: Shared identity verification and visible limits for SXCHNGE clients.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
image: ^4.9.0
|
||||
file_selector: ^1.1.0
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
class Photos implements PhotoService {
|
||||
Photo? result = Photo(Uint8List.fromList([1, 2, 3]));
|
||||
KycError? error;
|
||||
Completer<Photo?>? pending;
|
||||
@override
|
||||
Future<Photo?> pick() async {
|
||||
if (error != null) throw KycException(error!);
|
||||
return pending == null ? result : pending!.future;
|
||||
}
|
||||
}
|
||||
|
||||
class LimitStub implements LimitsService {
|
||||
final requests = <Completer<Limits>>[];
|
||||
@override
|
||||
Future<Limits> fetch() {
|
||||
final c = Completer<Limits>();
|
||||
requests.add(c);
|
||||
return c.future;
|
||||
}
|
||||
}
|
||||
|
||||
class BrokenAnalytics implements AnalyticsService {
|
||||
@override
|
||||
Future<void> capture(AnalyticsEvent event) => throw StateError('offline');
|
||||
}
|
||||
|
||||
Future<void> draft(IdentityProvider p) async {
|
||||
await p.refresh();
|
||||
p.start();
|
||||
await p.pick(PhotoKind.passport);
|
||||
p.next();
|
||||
await p.pick(PhotoKind.selfie);
|
||||
p.next();
|
||||
p.confirm(true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late DummyIdentityService service;
|
||||
late Photos photos;
|
||||
late DummyAnalyticsService sink;
|
||||
late IdentityProvider p;
|
||||
setUp(() {
|
||||
service = DummyIdentityService(delay: Duration.zero);
|
||||
photos = Photos();
|
||||
sink = DummyAnalyticsService();
|
||||
p = IdentityProvider(service, photos, JourneyRecorder(sink));
|
||||
});
|
||||
tearDown(() => p.dispose());
|
||||
test(
|
||||
'unverified, pending, correction and denial retain basic service limits',
|
||||
() async {
|
||||
final limits = LimitsProvider(DummyLimitsService(service));
|
||||
addTearDown(limits.dispose);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
await draft(p);
|
||||
await p.submit();
|
||||
expect(p.record!.status, IdentityStatus.pending);
|
||||
expect(p.hasDraft, false);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
await p.demoReview(
|
||||
IdentityStatus.changesRequested,
|
||||
reason: ReviewReason.passportExpired,
|
||||
);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
await draft(p);
|
||||
await p.submit();
|
||||
await p.demoReview(
|
||||
IdentityStatus.denied,
|
||||
reason: ReviewReason.unableToVerify,
|
||||
);
|
||||
p.start();
|
||||
expect(p.editing, false);
|
||||
await limits.refresh();
|
||||
expect(limits.data!.tier, AccountTier.basic);
|
||||
},
|
||||
);
|
||||
test('approval refreshes backend limits through callback', () async {
|
||||
final limits = LimitsProvider(DummyLimitsService(service));
|
||||
addTearDown(limits.dispose);
|
||||
final subject = IdentityProvider(
|
||||
service,
|
||||
photos,
|
||||
JourneyRecorder(sink),
|
||||
onStatusChanged: limits.refresh,
|
||||
);
|
||||
addTearDown(subject.dispose);
|
||||
await draft(subject);
|
||||
final before = limits.data!.rows.first.currentMinor;
|
||||
await subject.submit();
|
||||
await subject.demoReview(IdentityStatus.approved);
|
||||
expect(limits.data!.tier, AccountTier.verified);
|
||||
expect(limits.data!.rows.first.currentMinor, greaterThan(before));
|
||||
});
|
||||
test('cancel and invalid replacement preserve photo', () async {
|
||||
await draft(p);
|
||||
p.back();
|
||||
final original = p.selfie;
|
||||
photos.result = null;
|
||||
await p.pick(PhotoKind.selfie);
|
||||
expect(p.selfie, same(original));
|
||||
expect(p.error, null);
|
||||
photos.error = KycError.tooLarge;
|
||||
await p.pick(PhotoKind.selfie);
|
||||
expect(p.selfie, same(original));
|
||||
expect(p.error, KycError.tooLarge);
|
||||
expect(p.step, 1);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sink.events.any((e) => e.signal == JourneySignal.cancelled), true);
|
||||
});
|
||||
test(
|
||||
'submit retry preserves photos and counts one terminal simulated outcome',
|
||||
() async {
|
||||
await draft(p);
|
||||
service.nextError = KycError.unavailable;
|
||||
await p.submit();
|
||||
expect(p.hasDraft, true);
|
||||
expect(p.canSubmit, true);
|
||||
await p.submit();
|
||||
expect(p.record!.status, IdentityStatus.pending);
|
||||
expect(p.hasDraft, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final events = sink.events.where(
|
||||
(e) => e.step == JourneyStep.kycSubmission,
|
||||
);
|
||||
expect(events.where((e) => e.terminal).length, 1);
|
||||
expect(events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(events.every((e) => e.simulated), true);
|
||||
expect(
|
||||
sink.events
|
||||
.expand((e) => e.properties.keys)
|
||||
.any(
|
||||
(k) => [
|
||||
'email',
|
||||
'filename',
|
||||
'bytes',
|
||||
'passport',
|
||||
'token',
|
||||
].contains(k),
|
||||
),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
test('duplicate submit suppressed and service idempotency replays', () async {
|
||||
await draft(p);
|
||||
await Future.wait([p.submit(), p.submit()]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
sink.events
|
||||
.where(
|
||||
(e) =>
|
||||
e.step == JourneyStep.kycSubmission &&
|
||||
e.signal == JourneySignal.requested,
|
||||
)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
final replay = await service.submit(
|
||||
passport: photos.result!,
|
||||
selfie: photos.result!,
|
||||
idempotencyKey: p.record!.submissionId!,
|
||||
);
|
||||
expect(replay.submissionId, p.record!.submissionId);
|
||||
});
|
||||
test('status error does not invent a tier or allow submission', () async {
|
||||
service.nextError = KycError.unavailable;
|
||||
await p.refresh();
|
||||
expect(p.record, null);
|
||||
expect(p.stale, true);
|
||||
p.start();
|
||||
expect(p.editing, false);
|
||||
await p.refresh();
|
||||
p.start();
|
||||
expect(p.editing, true);
|
||||
});
|
||||
test(
|
||||
'limits ignore late response and mark retained values stale on error',
|
||||
() async {
|
||||
final s = LimitStub();
|
||||
final limits = LimitsProvider(s);
|
||||
addTearDown(limits.dispose);
|
||||
final old = limits.refresh();
|
||||
final recent = limits.refresh();
|
||||
s.requests[1].complete(
|
||||
Limits(tier: AccountTier.verified, rows: [], simulated: true),
|
||||
);
|
||||
await recent;
|
||||
s.requests[0].complete(
|
||||
Limits(tier: AccountTier.basic, rows: [], simulated: true),
|
||||
);
|
||||
await old;
|
||||
expect(limits.data!.tier, AccountTier.verified);
|
||||
final failure = limits.refresh();
|
||||
s.requests[2].completeError(StateError('offline'));
|
||||
await failure;
|
||||
expect(limits.failed, true);
|
||||
expect(limits.data!.tier, AccountTier.verified);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'exit discards images; analytics errors cannot block submission',
|
||||
() async {
|
||||
await draft(p);
|
||||
p.exit();
|
||||
expect(p.hasDraft, false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(sink.events.any((e) => e.signal == JourneySignal.abandoned), true);
|
||||
final subject = IdentityProvider(
|
||||
service,
|
||||
photos,
|
||||
JourneyRecorder(BrokenAnalytics()),
|
||||
);
|
||||
addTearDown(subject.dispose);
|
||||
await draft(subject);
|
||||
await subject.submit();
|
||||
expect(subject.record!.status, IdentityStatus.pending);
|
||||
},
|
||||
);
|
||||
test('late picker does not repopulate disposed state', () async {
|
||||
final subject = IdentityProvider(service, photos, JourneyRecorder(sink));
|
||||
await subject.refresh();
|
||||
subject.start();
|
||||
photos.pending = Completer<Photo?>();
|
||||
final pending = subject.pick(PhotoKind.passport);
|
||||
subject.dispose();
|
||||
photos.pending!.complete(photos.result);
|
||||
await pending;
|
||||
expect(subject.hasDraft, false);
|
||||
});
|
||||
test('validates bytes, signature, corrupt encoding and resolution', () async {
|
||||
Future<void> rejects(Uint8List bytes, KycError code) async => expectLater(
|
||||
FilePhotoService.validate(bytes),
|
||||
throwsA(isA<KycException>().having((e) => e.code, 'code', code)),
|
||||
);
|
||||
await rejects(Uint8List(FilePhotoService.maxBytes + 1), KycError.tooLarge);
|
||||
await rejects(Uint8List.fromList([1, 2, 3]), KycError.format);
|
||||
await rejects(Uint8List.fromList([255, 216, 255, 0]), KycError.unreadable);
|
||||
final r = ui.PictureRecorder();
|
||||
ui.Canvas(r);
|
||||
final pic = r.endRecording();
|
||||
final image = await pic.toImage(20, 20);
|
||||
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
image.dispose();
|
||||
pic.dispose();
|
||||
await rejects(bytes, KycError.tooSmall);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user