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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export 'src/models/identity.dart';
|
||||
export 'src/models/limits.dart';
|
||||
export 'src/providers/identity.dart';
|
||||
export 'src/providers/onboarding.dart';
|
||||
export 'src/providers/limits.dart';
|
||||
export 'src/services/contracts.dart';
|
||||
export 'src/services/backend.dart';
|
||||
export 'src/services/dummy.dart';
|
||||
export 'src/services/photos.dart';
|
||||
@@ -6,6 +6,7 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
http: ^1.6.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
image: ^4.9.0
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
String response({String status = 'pending'}) => jsonEncode({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'status': status,
|
||||
'submissionId': 'submission',
|
||||
'profile': {
|
||||
'fullName': 'Legal Name',
|
||||
'dateOfBirth': '1990-04-23',
|
||||
'residenceCountry': 'PL',
|
||||
},
|
||||
},
|
||||
});
|
||||
void main() {
|
||||
test('an approved review with an unfinished upgrade stays pending and cannot resubmit', () async {
|
||||
final payload = jsonDecode(response()) as Map<String, dynamic>;
|
||||
(payload['data'] as Map<String, dynamic>)['upgradePending'] = true;
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient((_) async => http.Response(jsonEncode(payload), 200)),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.fetch();
|
||||
expect(result.status, IdentityStatus.pending);
|
||||
expect(result.upgradePending, true);
|
||||
expect(result.canSubmit, false);
|
||||
});
|
||||
test(
|
||||
'uploads profile plus both files, credentials scoped to current session',
|
||||
() async {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test/api/v1'),
|
||||
accessToken: () => 'session-token',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/accounts/identity');
|
||||
expect(request.headers['Authorization'], 'Bearer session-token');
|
||||
expect(request.followRedirects, false);
|
||||
expect(
|
||||
request.headers['content-type'],
|
||||
contains('multipart/form-data'),
|
||||
);
|
||||
expect(request.body, contains('name="document"'));
|
||||
expect(request.body, contains('name="selfie"'));
|
||||
expect(request.body, contains('"fullName":"Legal Name"'));
|
||||
expect(request.body, contains('stable-key'));
|
||||
expect(request.body, isNot(contains('customer_ref')));
|
||||
return http.Response(response(), 200);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await service.submit(
|
||||
profile: const IdentityProfile(
|
||||
fullName: 'Legal Name',
|
||||
dateOfBirth: '1990-04-23',
|
||||
residenceCountry: 'PL',
|
||||
),
|
||||
passport: Photo(Uint8List.fromList([1])),
|
||||
selfie: Photo(Uint8List.fromList([2])),
|
||||
idempotencyKey: 'stable-key',
|
||||
);
|
||||
expect(result.status, IdentityStatus.pending);
|
||||
expect(result.simulated, false);
|
||||
},
|
||||
);
|
||||
test('session switch rejects a late response', () async {
|
||||
var token = 'first';
|
||||
final pending = Completer<http.Response>();
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => token,
|
||||
client: MockClient((_) => pending.future),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = service.fetch();
|
||||
token = 'second';
|
||||
pending.complete(http.Response(response(), 200));
|
||||
await expectLater(
|
||||
result,
|
||||
throwsA(
|
||||
isA<KycException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
KycError.sessionExpired,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test(
|
||||
'production limits preserve exact decimals and never use demo rows',
|
||||
() async {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/accounts/limits');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'tier': 'verified',
|
||||
'profileRef': 'verified-policy',
|
||||
'entries': [
|
||||
{
|
||||
'operation': 'Transfer',
|
||||
'period': 'day',
|
||||
'currency': 'BTC',
|
||||
'cap': '0.12345678',
|
||||
'remaining': '0.10000001',
|
||||
'used': '0.02',
|
||||
'reserved': '0.00345677',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = await BackendLimitsService(service).fetch();
|
||||
expect(result.rows, isEmpty);
|
||||
expect(result.simulated, false);
|
||||
expect(result.entries.single.cap, '0.12345678');
|
||||
expect(result.tier, AccountTier.verified);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'unavailable, conflict and expired sessions retain typed recovery',
|
||||
() async {
|
||||
for (final pair in [
|
||||
(503, KycError.unavailable),
|
||||
(409, KycError.conflict),
|
||||
(401, KycError.sessionExpired),
|
||||
]) {
|
||||
final service = BackendIdentityService(
|
||||
baseUri: Uri.parse('https://api.example.test'),
|
||||
accessToken: () => 'token',
|
||||
client: MockClient(
|
||||
(_) async => http.Response('private upstream details', pair.$1),
|
||||
),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
await expectLater(
|
||||
service.fetch(),
|
||||
throwsA(isA<KycException>().having((e) => e.code, 'code', pair.$2)),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,13 @@ class BrokenAnalytics implements AnalyticsService {
|
||||
Future<void> draft(IdentityProvider p) async {
|
||||
await p.refresh();
|
||||
p.start();
|
||||
p.updateProfile(
|
||||
const IdentityProfile(
|
||||
fullName: "Example Person",
|
||||
dateOfBirth: "1990-04-23",
|
||||
residenceCountry: "PL",
|
||||
),
|
||||
);
|
||||
await p.pick(PhotoKind.passport);
|
||||
p.next();
|
||||
await p.pick(PhotoKind.selfie);
|
||||
@@ -170,6 +177,11 @@ void main() {
|
||||
1,
|
||||
);
|
||||
final replay = await service.submit(
|
||||
profile: const IdentityProfile(
|
||||
fullName: "Example Person",
|
||||
dateOfBirth: "1990-04-23",
|
||||
residenceCountry: "PL",
|
||||
),
|
||||
passport: photos.result!,
|
||||
selfie: photos.result!,
|
||||
idempotencyKey: p.record!.submissionId!,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
class RealIdentity extends DummyIdentityService {
|
||||
IdentityStatus status = IdentityStatus.unverified;
|
||||
bool unavailable = false;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
@override
|
||||
Future<Identity> fetch() async {
|
||||
if (unavailable) throw const KycException(KycError.unavailable);
|
||||
return Identity(status: status);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('offers optional verification once to a basic customer and allows later corrections', () async {
|
||||
final service = RealIdentity(),
|
||||
provider = IdentityOnboardingProvider(RealIdentity());
|
||||
expect(await provider.offer('customer'), true);
|
||||
expect(await provider.offer('customer'), false);
|
||||
final subject = IdentityOnboardingProvider(service);
|
||||
service.status = IdentityStatus.pending;
|
||||
expect(await subject.offer('pending'), false);
|
||||
service.status = IdentityStatus.approved;
|
||||
expect(await subject.offer('approved'), false);
|
||||
service.status = IdentityStatus.changesRequested;
|
||||
expect(await subject.offer('correction'), true);
|
||||
});
|
||||
test(
|
||||
'a failed optional identity dependency never blocks basic login',
|
||||
() async {
|
||||
final service = RealIdentity()..unavailable = true;
|
||||
expect(
|
||||
await IdentityOnboardingProvider(service).offer('customer'),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user