Submit customer identity proofs and show reviewed limits

This commit is contained in:
Stephan D committed 2026-10-15 02:22:28 +02:00
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;