Submit customer identity proofs and show reviewed limits
This commit is contained in:
1 parent
7bf8b8087a
commit
1354551ae8
50 files changed
+1777
-67
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import '../models/identity.dart';
|
||||
import '../models/limits.dart';
|
||||
import '../transport/backend.dart';
|
||||
|
||||
abstract final class VerificationMapper {
|
||||
static Limits limits(PolicyDTO dto) => Limits(
|
||||
tier: AccountTier.values.byName(dto.tier),
|
||||
rows: const [],
|
||||
simulated: false,
|
||||
profileRef: dto.profileRef,
|
||||
entries: dto.entries
|
||||
.map(
|
||||
(v) => PolicyLimit(
|
||||
operation: v.operation,
|
||||
period: v.period,
|
||||
currency: v.currency,
|
||||
cap: v.cap,
|
||||
remaining: v.remaining,
|
||||
used: v.used,
|
||||
reserved: v.reserved,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
static Identity fromDTO(VerificationDTO dto) => Identity(
|
||||
upgradePending: dto.upgradePending,
|
||||
status: IdentityStatus.values.byName(dto.status),
|
||||
reason: dto.reason == null || dto.reason!.isEmpty
|
||||
? null
|
||||
: ReviewReason.values.byName(dto.reason!),
|
||||
submissionId: dto.id,
|
||||
profile: IdentityProfile(
|
||||
fullName: dto.profile.fullName,
|
||||
dateOfBirth: dto.profile.dateOfBirth,
|
||||
residenceCountry: dto.profile.residenceCountry,
|
||||
),
|
||||
);
|
||||
static ProfileDTO profile(IdentityProfile value) => ProfileDTO(
|
||||
value.fullName.trim(),
|
||||
value.dateOfBirth,
|
||||
value.residenceCountry,
|
||||
);
|
||||
static KycException failure(int status) => KycException(switch (status) {
|
||||
400 || 413 || 415 || 422 => KycError.format,
|
||||
401 => KycError.sessionExpired,
|
||||
403 => KycError.permission,
|
||||
409 => KycError.conflict,
|
||||
429 => KycError.rateLimited,
|
||||
_ => KycError.unavailable,
|
||||
});
|
||||
}
|
||||
@@ -36,16 +36,40 @@ class Identity {
|
||||
this.reason,
|
||||
this.submissionId,
|
||||
this.simulated = false,
|
||||
this.profile = const IdentityProfile(),
|
||||
this.upgradePending = false,
|
||||
});
|
||||
final IdentityStatus status;
|
||||
final ReviewReason? reason;
|
||||
final String? submissionId;
|
||||
final bool simulated;
|
||||
final IdentityProfile profile;
|
||||
final bool upgradePending;
|
||||
bool get canSubmit =>
|
||||
status == IdentityStatus.unverified ||
|
||||
status == IdentityStatus.changesRequested;
|
||||
}
|
||||
|
||||
/// Customer assertions awaiting human review, distinct from a verified identity.
|
||||
class IdentityProfile {
|
||||
const IdentityProfile({
|
||||
this.fullName = '',
|
||||
this.dateOfBirth = '',
|
||||
this.residenceCountry = '',
|
||||
});
|
||||
final String fullName, dateOfBirth, residenceCountry;
|
||||
bool get isComplete {
|
||||
final date = DateTime.tryParse(dateOfBirth);
|
||||
return fullName.trim().isNotEmpty &&
|
||||
fullName.length <= 200 &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(dateOfBirth) &&
|
||||
date != null &&
|
||||
date.toIso8601String().startsWith(dateOfBirth) &&
|
||||
date.isBefore(DateTime.now()) &&
|
||||
RegExp(r'^[A-Z]{2}$').hasMatch(residenceCountry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
|
||||
class Photo {
|
||||
Photo(Uint8List bytes)
|
||||
|
||||
@@ -33,8 +33,28 @@ class Limits {
|
||||
required this.tier,
|
||||
required List<VisibleLimit> rows,
|
||||
required this.simulated,
|
||||
}) : rows = List.unmodifiable(rows);
|
||||
this.profileRef,
|
||||
List<PolicyLimit> entries = const [],
|
||||
}) : rows = List.unmodifiable(rows),
|
||||
entries = List.unmodifiable(entries);
|
||||
final AccountTier tier;
|
||||
final List<VisibleLimit> rows;
|
||||
final bool simulated;
|
||||
final String? profileRef;
|
||||
final List<PolicyLimit> entries;
|
||||
}
|
||||
|
||||
/// Authoritative policy amounts in major units. Keep decimal strings intact,
|
||||
/// including currencies whose precision differs from fiat's common two digits.
|
||||
class PolicyLimit {
|
||||
const PolicyLimit({
|
||||
required this.operation,
|
||||
required this.period,
|
||||
required this.currency,
|
||||
required this.cap,
|
||||
required this.remaining,
|
||||
required this.used,
|
||||
required this.reserved,
|
||||
});
|
||||
final String operation, period, currency, cap, remaining, used, reserved;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
Identity? record;
|
||||
Photo? passport;
|
||||
Photo? selfie;
|
||||
IdentityProfile profile = const IdentityProfile();
|
||||
KycError? error;
|
||||
PhotoKind? photoError;
|
||||
bool busy = false;
|
||||
@@ -31,11 +32,13 @@ class IdentityProvider extends ChangeNotifier {
|
||||
int step = 0;
|
||||
bool _disposed = false;
|
||||
String? _key;
|
||||
bool get hasDraft => passport != null || selfie != null;
|
||||
bool _profileTouched = false;
|
||||
bool get hasDraft => passport != null || selfie != null || _profileTouched;
|
||||
bool get canSubmit =>
|
||||
!busy &&
|
||||
!stale &&
|
||||
confirmed &&
|
||||
profile.isComplete &&
|
||||
passport != null &&
|
||||
selfie != null &&
|
||||
(record?.canSubmit ?? false);
|
||||
@@ -57,6 +60,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
final result = await service.fetch();
|
||||
if (_disposed) return;
|
||||
record = result;
|
||||
if (!editing) profile = result.profile;
|
||||
stale = false;
|
||||
await onStatusChanged?.call();
|
||||
if (_disposed) return;
|
||||
@@ -144,6 +148,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
|
||||
void next() {
|
||||
if (busy ||
|
||||
(step == 0 && !profile.isComplete) ||
|
||||
(step == 0 && passport == null) ||
|
||||
(step == 1 && selfie == null))
|
||||
return;
|
||||
@@ -152,6 +157,15 @@ class IdentityProvider extends ChangeNotifier {
|
||||
_notify();
|
||||
}
|
||||
|
||||
void updateProfile(IdentityProfile value) {
|
||||
if (busy || !editing) return;
|
||||
profile = value;
|
||||
_profileTouched = true;
|
||||
confirmed = false;
|
||||
_key = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void confirm(bool value) {
|
||||
if (!busy) {
|
||||
confirmed = value;
|
||||
@@ -172,6 +186,7 @@ class IdentityProvider extends ChangeNotifier {
|
||||
final op = _begin(JourneyStep.kycSubmission);
|
||||
try {
|
||||
final result = await service.submit(
|
||||
profile: profile,
|
||||
passport: passport!,
|
||||
selfie: selfie!,
|
||||
idempotencyKey: _key!,
|
||||
@@ -217,6 +232,8 @@ class IdentityProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _clear() {
|
||||
_profileTouched = false;
|
||||
profile = const IdentityProfile();
|
||||
passport = null;
|
||||
selfie = null;
|
||||
confirmed = false;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import '../models/identity.dart';
|
||||
import '../services/contracts.dart';
|
||||
|
||||
/// Offers optional verification once per customer in this app session. Identity
|
||||
/// dependency failures must never prevent an already authenticated basic login.
|
||||
class IdentityOnboardingProvider {
|
||||
IdentityOnboardingProvider(this.service);
|
||||
final IdentityService service;
|
||||
final _offered = <String>{};
|
||||
Future<bool> offer(String customer) async {
|
||||
if (customer.isEmpty || service.isSimulated || !_offered.add(customer))
|
||||
return false;
|
||||
try {
|
||||
final identity = await service.fetch().timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
return identity.status == IdentityStatus.unverified ||
|
||||
identity.status == IdentityStatus.changesRequested;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../mappers/backend.dart';
|
||||
import '../models/identity.dart';
|
||||
import '../models/limits.dart';
|
||||
import '../transport/backend.dart';
|
||||
import 'contracts.dart';
|
||||
import 'http/client.dart';
|
||||
|
||||
/// Credentials are read for each operation, so a replaced session cannot leave
|
||||
/// documents attached to a previously authenticated customer.
|
||||
class BackendIdentityService implements IdentityService {
|
||||
BackendIdentityService({
|
||||
required this.baseUri,
|
||||
required this.accessToken,
|
||||
http.Client? client,
|
||||
}) : _client = client ?? createClient() {
|
||||
if (!baseUri.hasAuthority ||
|
||||
baseUri.hasQuery ||
|
||||
baseUri.hasFragment ||
|
||||
baseUri.userInfo.isNotEmpty ||
|
||||
(baseUri.scheme != 'https' &&
|
||||
!(baseUri.scheme == 'http' &&
|
||||
['localhost', '127.0.0.1'].contains(baseUri.host))))
|
||||
throw ArgumentError('Valid identity API URL required');
|
||||
}
|
||||
final Uri baseUri;
|
||||
final String? Function() accessToken;
|
||||
final http.Client _client;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
Uri get _uri => baseUri.replace(
|
||||
path: '${baseUri.path.replaceFirst(RegExp(r"/$"), "")}/accounts/identity',
|
||||
);
|
||||
|
||||
Future<String> _send(http.BaseRequest request) async {
|
||||
final token = accessToken();
|
||||
if (token == null) throw const KycException(KycError.sessionExpired);
|
||||
request.followRedirects = false;
|
||||
request.headers.addAll({
|
||||
'Authorization': 'Bearer $token',
|
||||
if (kIsWeb) 'X-CSRF-Protection': '1',
|
||||
});
|
||||
try {
|
||||
final response = await _client
|
||||
.send(request)
|
||||
.then(http.Response.fromStream)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
if (token != accessToken())
|
||||
throw const KycException(KycError.sessionExpired);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300)
|
||||
throw VerificationMapper.failure(response.statusCode);
|
||||
return response.body;
|
||||
} on KycException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const KycException(KycError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _mapped<T>(Future<String> response, T Function(String) map) async {
|
||||
try {
|
||||
return map(await response);
|
||||
} on KycException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const KycException(KycError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Identity> fetch() => _mapped(
|
||||
_send(http.Request('GET', _uri)),
|
||||
(body) => VerificationMapper.fromDTO(VerificationDTO.decode(body)),
|
||||
);
|
||||
Future<Limits> fetchLimits() => _mapped(
|
||||
_send(
|
||||
http.Request(
|
||||
'GET',
|
||||
_uri.replace(
|
||||
path: _uri.path.replaceFirst(RegExp(r'/identity$'), '/limits'),
|
||||
),
|
||||
),
|
||||
),
|
||||
(body) => VerificationMapper.limits(PolicyDTO.decode(body)),
|
||||
);
|
||||
@override
|
||||
Future<Identity> submit({
|
||||
required IdentityProfile profile,
|
||||
required Photo passport,
|
||||
required Photo selfie,
|
||||
required String idempotencyKey,
|
||||
}) async {
|
||||
if (!profile.isComplete) throw const KycException(KycError.incomplete);
|
||||
final request = http.MultipartRequest('POST', _uri)
|
||||
..fields.addAll({
|
||||
'profile': jsonEncode(VerificationMapper.profile(profile).toJson()),
|
||||
'idempotencyKey': idempotencyKey,
|
||||
})
|
||||
..files.addAll([
|
||||
http.MultipartFile.fromBytes(
|
||||
'document',
|
||||
passport.bytes,
|
||||
filename: 'document',
|
||||
),
|
||||
http.MultipartFile.fromBytes(
|
||||
'selfie',
|
||||
selfie.bytes,
|
||||
filename: 'selfie',
|
||||
),
|
||||
]);
|
||||
return _mapped(
|
||||
_send(request),
|
||||
(body) => VerificationMapper.fromDTO(VerificationDTO.decode(body)),
|
||||
);
|
||||
}
|
||||
|
||||
void close() => _client.close();
|
||||
}
|
||||
|
||||
class BackendLimitsService implements LimitsService {
|
||||
const BackendLimitsService(this.identity);
|
||||
final BackendIdentityService identity;
|
||||
@override
|
||||
Future<Limits> fetch() => identity.fetchLimits();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ abstract interface class IdentityService {
|
||||
/// 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 IdentityProfile profile,
|
||||
required Photo passport,
|
||||
required Photo selfie,
|
||||
required String idempotencyKey,
|
||||
|
||||
@@ -27,6 +27,7 @@ class DummyIdentityService implements IdentityService {
|
||||
|
||||
@override
|
||||
Future<Identity> submit({
|
||||
required IdentityProfile profile,
|
||||
required Photo passport,
|
||||
required Photo selfie,
|
||||
required String idempotencyKey,
|
||||
@@ -35,7 +36,7 @@ class DummyIdentityService implements IdentityService {
|
||||
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
|
||||
if (!IdentityMapper.fromDTO(_record).canSubmit)
|
||||
throw const KycException(KycError.conflict);
|
||||
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
|
||||
if (!profile.isComplete || passport.bytes.isEmpty || selfie.bytes.isEmpty)
|
||||
throw const KycException(KycError.incomplete);
|
||||
_lastKey = idempotencyKey;
|
||||
_record = IdentityDTO('pending', id: idempotencyKey);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:http/browser_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => BrowserClient()..withCredentials = true;
|
||||
@@ -0,0 +1 @@
|
||||
export 'native.dart' if (dart.library.js_interop) 'browser.dart';
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
http.Client createClient() => http.Client();
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ProfileDTO {
|
||||
const ProfileDTO(this.fullName, this.dateOfBirth, this.residenceCountry);
|
||||
factory ProfileDTO.fromJson(Map<String, dynamic> data) => ProfileDTO(
|
||||
data['fullName'] as String? ?? '',
|
||||
data['dateOfBirth'] as String? ?? '',
|
||||
data['residenceCountry'] as String? ?? '',
|
||||
);
|
||||
final String fullName, dateOfBirth, residenceCountry;
|
||||
Map<String, String> toJson() => {
|
||||
'fullName': fullName,
|
||||
'dateOfBirth': dateOfBirth,
|
||||
'residenceCountry': residenceCountry,
|
||||
};
|
||||
}
|
||||
|
||||
class VerificationDTO {
|
||||
const VerificationDTO(
|
||||
this.status,
|
||||
this.reason,
|
||||
this.id,
|
||||
this.profile, {
|
||||
this.upgradePending = false,
|
||||
});
|
||||
factory VerificationDTO.decode(String body) {
|
||||
final envelope = jsonDecode(body) as Map<String, dynamic>;
|
||||
if (envelope['status'] != 'success')
|
||||
throw const FormatException('Unaccepted verification');
|
||||
final data = envelope['data'] as Map<String, dynamic>;
|
||||
return VerificationDTO(
|
||||
data['status'] as String,
|
||||
data['reason'] as String?,
|
||||
data['submissionId'] as String?,
|
||||
ProfileDTO.fromJson(data['profile'] as Map<String, dynamic>),
|
||||
upgradePending: data['upgradePending'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
final String status;
|
||||
final String? reason, id;
|
||||
final ProfileDTO profile;
|
||||
final bool upgradePending;
|
||||
}
|
||||
|
||||
class PolicyDTO {
|
||||
const PolicyDTO(this.tier, this.profileRef, this.entries);
|
||||
factory PolicyDTO.decode(String body) {
|
||||
final envelope = jsonDecode(body) as Map<String, dynamic>;
|
||||
if (envelope['status'] != 'success')
|
||||
throw const FormatException('Unaccepted limits');
|
||||
final data = envelope['data'] as Map<String, dynamic>;
|
||||
return PolicyDTO(
|
||||
data['tier'] as String,
|
||||
data['profileRef'] as String,
|
||||
(data['entries'] as List)
|
||||
.map((v) => LimitDTO.fromJson(v as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
final String tier, profileRef;
|
||||
final List<LimitDTO> entries;
|
||||
}
|
||||
|
||||
class LimitDTO {
|
||||
LimitDTO.fromJson(Map<String, dynamic> d)
|
||||
: operation = d['operation'] as String,
|
||||
period = d['period'] as String,
|
||||
currency = d['currency'] as String,
|
||||
cap = d['cap'] as String,
|
||||
remaining = d['remaining'] as String,
|
||||
used = d['used'] as String,
|
||||
reserved = d['reserved'] as String;
|
||||
final String operation, period, currency, cap, remaining, used, reserved;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import '../models/identity.dart';
|
||||
import '../models/limits.dart';
|
||||
|
||||
// Transport contracts for the stub. Replace with generated backend DTOs later.
|
||||
// Transport fixtures for simulated services. Production owns its backend DTOs.
|
||||
class IdentityDTO {
|
||||
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
|
||||
final String status;
|
||||
|
||||
Reference in new issue
Block a user