Connect customer quotations and payment execution to Sendico (#50)
This commit is contained in:
1 parent
5d67c25c8e
commit
7a6510d1a8
45 files changed
+2696
-45
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../mappers/backend.dart';
|
||||
import '../models/wallet.dart';
|
||||
import '../transport/backend.dart';
|
||||
import 'contracts.dart';
|
||||
import 'http/client.dart';
|
||||
|
||||
class BackendWalletService implements WalletService {
|
||||
BackendWalletService({
|
||||
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 wallet API URL required');
|
||||
}
|
||||
final Uri baseUri;
|
||||
final String? Function() accessToken;
|
||||
final http.Client _client;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
Future<BackendDTO> send(
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
bool factor = false,
|
||||
}) async {
|
||||
final token = accessToken();
|
||||
if (token == null) throw const WalletException(WalletError.unavailable);
|
||||
final uri = baseUri.replace(
|
||||
path:
|
||||
'${baseUri.path.replaceFirst(RegExp(r"/$"), "")}/accounts/wallet$path',
|
||||
);
|
||||
final request = http.Request(body == null ? 'GET' : 'POST', uri)
|
||||
..followRedirects = false;
|
||||
request.headers.addAll({
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
if (kIsWeb) 'X-CSRF-Protection': '1',
|
||||
});
|
||||
if (body != null) request.body = jsonEncode(body);
|
||||
try {
|
||||
final response = await _client
|
||||
.send(request)
|
||||
.then(http.Response.fromStream)
|
||||
.timeout(const Duration(seconds: 40));
|
||||
if (token != accessToken())
|
||||
throw const WalletException(WalletError.unavailable);
|
||||
final dto = BackendDTO.decode(response.body);
|
||||
if (response.statusCode < 200 ||
|
||||
response.statusCode >= 300 ||
|
||||
!dto.accepted) {
|
||||
if (factor || dto.error.startsWith('factor'))
|
||||
throw FactorException(BackendMapper.factorFailure(dto));
|
||||
throw WalletException(BackendMapper.failure(dto));
|
||||
}
|
||||
return dto;
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} on WalletException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const WalletException(WalletError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _mapped<T>(
|
||||
Future<BackendDTO> dto,
|
||||
T Function(BackendDTO) map,
|
||||
) async {
|
||||
try {
|
||||
return map(await dto);
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} on WalletException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const WalletException(WalletError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
|
||||
@override
|
||||
Future<Quote> quote(TransferRequest request) => _mapped(
|
||||
send('/quote', body: BackendMapper.request(request)),
|
||||
BackendMapper.quote,
|
||||
);
|
||||
@override
|
||||
Future<WalletOperation> submit(
|
||||
String quoteId,
|
||||
String idempotencyKey, {
|
||||
String? approval,
|
||||
}) async {
|
||||
final value = await _mapped(
|
||||
send(
|
||||
'/submit',
|
||||
body: {'quoteId': quoteId, 'idempotencyKey': idempotencyKey},
|
||||
),
|
||||
BackendMapper.operation,
|
||||
);
|
||||
if (value == null) throw const WalletException(WalletError.uncertain);
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation?> findSubmission(String idempotencyKey) => _mapped(
|
||||
send('/submission', body: {'idempotencyKey': idempotencyKey}),
|
||||
BackendMapper.operation,
|
||||
);
|
||||
@override
|
||||
Future<AccountProfile> profile() =>
|
||||
_mapped(send('/profile'), BackendMapper.profile);
|
||||
@override
|
||||
Future<AccountProfile> saveLocale(String locale) async =>
|
||||
throw const WalletException(WalletError.unsupported);
|
||||
void close() => _client.close();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import '../mappers/backend.dart';
|
||||
import '../transport/backend.dart';
|
||||
import 'backend.dart';
|
||||
|
||||
/// Composes authentication factors with quote-bound payment authorization.
|
||||
/// Money codes authorize a durable BFF intent; raw verification proofs remain
|
||||
/// server-side and cannot be reused as login grants.
|
||||
class WalletFactorService implements TwoFactorService {
|
||||
WalletFactorService(this.wallet, this.auth);
|
||||
final BackendWalletService wallet;
|
||||
final TwoFactorService auth;
|
||||
final _challenges = <String, FactorChallenge>{};
|
||||
final _keys = <String, String>{};
|
||||
final _resends = <String, String>{};
|
||||
String _key() => List.generate(
|
||||
24,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
String? _token;
|
||||
@override
|
||||
bool get isSimulated => false;
|
||||
void _session({bool required = true}) {
|
||||
final token = wallet.accessToken();
|
||||
if (token != _token) {
|
||||
_challenges.clear();
|
||||
_keys.clear();
|
||||
_resends.clear();
|
||||
_token = token;
|
||||
}
|
||||
if (required && token == null)
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
}
|
||||
|
||||
Future<T> _mapped<T>(
|
||||
Future<BackendDTO> dto,
|
||||
T Function(BackendDTO) map,
|
||||
) async {
|
||||
final token = wallet.accessToken();
|
||||
try {
|
||||
final value = await dto;
|
||||
if (token != wallet.accessToken())
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
return map(value);
|
||||
} on FactorException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const FactorException(FactorError.unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorChallenge?> begin(
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
) async {
|
||||
if (![
|
||||
FactorPurpose.topUp,
|
||||
FactorPurpose.exchange,
|
||||
FactorPurpose.payout,
|
||||
].contains(purpose))
|
||||
return auth.begin(purpose, operation);
|
||||
_session();
|
||||
final c = await _mapped(
|
||||
wallet.send('/challenge', body: {'quoteId': operation}, factor: true),
|
||||
(d) => BackendMapper.challenge(d, purpose, operation),
|
||||
);
|
||||
_session();
|
||||
_challenges[c.id] = c;
|
||||
_keys.putIfAbsent(
|
||||
operation,
|
||||
() => List.generate(
|
||||
24,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join(),
|
||||
);
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorChallenge> resend(String challengeId) async {
|
||||
_session(required: false);
|
||||
final c = _challenges[challengeId];
|
||||
if (c == null) return auth.resend(challengeId);
|
||||
final result = await _mapped(
|
||||
wallet.send(
|
||||
'/resend',
|
||||
body: {
|
||||
'quoteId': c.operation,
|
||||
'challengeId': c.id,
|
||||
'idempotencyKey': _resends.putIfAbsent(c.id, _key),
|
||||
},
|
||||
factor: true,
|
||||
),
|
||||
(d) => BackendMapper.challenge(d, c.purpose, c.operation),
|
||||
);
|
||||
_session();
|
||||
_resends.remove(c.id);
|
||||
_challenges[result.id] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorGrant> verify(String challengeId, String code) async {
|
||||
_session(required: false);
|
||||
final c = _challenges[challengeId];
|
||||
if (c == null) return auth.verify(challengeId, code);
|
||||
final key = _keys[c.operation]!;
|
||||
return _mapped(
|
||||
wallet.send(
|
||||
'/verify',
|
||||
body: {
|
||||
'quoteId': c.operation,
|
||||
'challengeId': c.id,
|
||||
'code': code,
|
||||
'idempotencyKey': key,
|
||||
},
|
||||
factor: true,
|
||||
),
|
||||
(d) {
|
||||
if (d.key != key) throw const FactorException(FactorError.unavailable);
|
||||
return FactorGrant(key);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FactorSettings> fetch() => auth.fetch();
|
||||
@override
|
||||
Future<FactorSettings> setEnabled(bool enabled, String grant) =>
|
||||
auth.setEnabled(enabled, grant);
|
||||
@override
|
||||
Future<void> authorize(
|
||||
FactorPurpose purpose,
|
||||
String operation,
|
||||
String? grant,
|
||||
) async {
|
||||
if ([
|
||||
FactorPurpose.topUp,
|
||||
FactorPurpose.exchange,
|
||||
FactorPurpose.payout,
|
||||
].contains(purpose))
|
||||
throw const FactorException(FactorError.required);
|
||||
await auth.authorize(purpose, operation, grant);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user