Connect customer quotations and payment execution to Sendico (#50)

This commit is contained in:
Stephan D committed 2026-10-16 09:57:28 +02:00
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();