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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user