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,52 @@
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../models/wallet.dart';
import '../transport/backend.dart';
import '../transport/wallet.dart';
abstract final class BackendMapper {
static Map<String, Object?> request(TransferRequest r) => {
'kind': r.kind.name,
'source': r.source,
'destination': r.destination ?? '',
'amount': r.amount,
'recipient': r.recipient,
};
static WalletSnapshot snapshot(BackendDTO d) => WalletSnapshot(
accounts: d.accounts.map(WalletMapper.account).toList(),
operations: d.operations.map(WalletMapper.operation).toList(),
);
static Quote quote(BackendDTO d) => WalletMapper.quote(d.quoteValue);
static WalletOperation? operation(BackendDTO d) => d.operationValue == null
? null
: WalletMapper.operation(d.operationValue!);
static AccountProfile profile(BackendDTO d) =>
WalletMapper.profile(ProfileDTO(d.locale));
static FactorChallenge challenge(
BackendDTO d,
FactorPurpose purpose,
String operation,
) {
final c = d.challenge;
return FactorChallenge(
id: c.id,
purpose: purpose,
operation: operation,
target: c.target,
expiresAt: c.expires,
resendAt: c.resend,
);
}
static WalletError failure(BackendDTO d) =>
WalletError.values.where((v) => v.name == d.error).firstOrNull ??
WalletError.unavailable;
static FactorError factorFailure(BackendDTO d) => switch (d.error) {
'factorInvalid' => FactorError.invalid,
'factorExpired' || 'expiredQuote' => FactorError.expired,
'factorLocked' => FactorError.locked,
'factorCooldown' => FactorError.cooldown,
'factorRequired' => FactorError.required,
_ => FactorError.unavailable,
};
}
@@ -25,9 +25,14 @@ class CurrencyAccount {
required this.currency,
required this.precision,
required this.available,
this.payoutMethod = '',
this.payoutLabel = '',
this.canFund = true,
});
final String id, currency;
final int precision, available;
final String payoutMethod, payoutLabel;
final bool canFund;
/// Locale-aware decimal input, integer money. Grouping/exponents are rejected.
int? parse(String input) {
@@ -19,6 +19,8 @@ class TransferProvider extends ChangeNotifier {
.firstOrNull
?.id ??
'usd';
wallet.addListener(_walletChanged);
_syncAccounts();
final op = recorder.begin(
journey,
JourneyStep.transferDraft,
@@ -26,6 +28,30 @@ class TransferProvider extends ChangeNotifier {
);
recorder.finish(op, simulated: wallet.service.isSimulated);
}
void _syncAccounts() {
final accounts = wallet.data?.accounts;
if (accounts == null ||
accounts.isEmpty ||
busy ||
uncertain ||
quote != null)
return;
if (!accounts.any((a) => a.id == source)) source = accounts.first.id;
if (!accounts.any((a) => a.id == destination) || destination == source) {
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
}
if (!wallet.service.isSimulated && kind == TransferKind.payout) {
recipient = wallet.account(source)?.payoutMethod ?? '';
}
}
void _walletChanged() {
if (_disposed) return;
final previous = source;
_syncAccounts();
if (source != previous) notifyListeners();
}
final WalletProvider wallet;
final TransferKind kind;
final JourneyRecorder recorder;
@@ -45,7 +71,8 @@ class TransferProvider extends ChangeNotifier {
TransferKind.exchange => JourneyKind.exchange,
TransferKind.payout => JourneyKind.payout,
};
bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty;
bool get hasDraft =>
amount.isNotEmpty || (wallet.service.isSimulated && recipient.isNotEmpty);
bool get expired =>
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
void change({
@@ -59,6 +86,7 @@ class TransferProvider extends ChangeNotifier {
this.destination = destination ?? this.destination;
this.amount = amount ?? this.amount;
this.recipient = recipient ?? this.recipient;
if (source != null) _syncAccounts();
quote = null;
noSubmission = false;
error = null;
@@ -171,6 +199,7 @@ class TransferProvider extends ChangeNotifier {
error = null;
factorError = null;
notifyListeners();
_key ??= approval?.token;
_key ??= List.generate(
16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
@@ -266,6 +295,7 @@ class TransferProvider extends ChangeNotifier {
@override
void dispose() {
_disposed = true;
wallet.removeListener(_walletChanged);
recipient = '';
if (!_handedOff) recorder.dispose();
super.dispose();
@@ -140,6 +140,21 @@ class WalletProvider extends ChangeNotifier {
return !saveFailed;
}
/// Drop account-scoped state when the authenticated customer changes.
void reset() {
_revision++;
_poll?.cancel();
for (final pair in _settlements.values) {
pair.$1.dispose(abandonActive: false);
}
_settlements.clear();
data = null;
preferredLocale = null;
loading = failed = profileLoading = profileFailed = saving = saveFailed =
false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
@@ -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();
@@ -0,0 +1,78 @@
import 'dart:convert';
import 'wallet.dart';
/// Local BFF transport only; provider and widgets never parse response maps.
class BackendDTO {
BackendDTO.decode(String body) {
final envelope = jsonDecode(body) as Map<String, dynamic>;
accepted = envelope['status'] == 'success';
data = envelope['data'];
}
late final bool accepted;
late final dynamic data;
String get error =>
(data as Map<String, dynamic>?)?['error'] as String? ?? 'unavailable';
String get locale => (data as Map<String, dynamic>)['locale'] as String;
String get key => (data as Map<String, dynamic>)['idempotencyKey'] as String;
List<AccountDTO> get accounts =>
((data as Map<String, dynamic>)['accounts'] as List).map((v) {
final d = v as Map<String, dynamic>;
return AccountDTO(
d['id'] as String,
d['currency'] as String,
d['precision'] as int,
d['available'] as int,
payoutMethod: d['payoutMethod'] as String,
payoutLabel: d['payoutLabel'] as String,
canFund: d['canFund'] as bool,
);
}).toList();
List<OperationDTO> get operations =>
((data as Map<String, dynamic>)['operations'] as List)
.map((v) => operation(v as Map<String, dynamic>))
.toList();
QuoteDTO get quoteValue => quote(data as Map<String, dynamic>);
OperationDTO? get operationValue =>
data == null ? null : operation(data as Map<String, dynamic>);
static QuoteDTO quote(Map<String, dynamic> d) {
final r = d['request'] as Map<String, dynamic>;
return QuoteDTO(
d['id'] as String,
RequestDTO(
r['kind'] as String,
r['source'] as String,
(r['destination'] as String?)?.isEmpty == true
? null
: r['destination'] as String?,
r['amount'] as int,
r['recipient'] as String? ?? '',
),
d['fee'] as int,
d['debit'] as int,
d['credit'] as int,
DateTime.parse(d['expiresAt'] as String),
d['rateNumerator'] as int,
d['rateDenominator'] as int,
);
}
static OperationDTO operation(Map<String, dynamic> d) => OperationDTO(
d['id'] as String,
quote(d['quote'] as Map<String, dynamic>),
DateTime.parse(d['createdAt'] as String),
DateTime.parse(d['createdAt'] as String),
status: d['status'] as String,
);
ChallengeDTO get challenge => ChallengeDTO(data as Map<String, dynamic>);
}
class ChallengeDTO {
ChallengeDTO(Map<String, dynamic> d)
: id = d['challengeId'] as String,
target = d['target'] as String,
expires = DateTime.parse(d['expiresAt'] as String),
resend = DateTime.parse(d['resendAt'] as String);
final String id, target;
final DateTime expires, resend;
}
@@ -1,7 +1,17 @@
import '../models/wallet.dart';
class AccountDTO {
AccountDTO(this.id, this.currency, this.precision, this.available);
AccountDTO(
this.id,
this.currency,
this.precision,
this.available, {
this.payoutMethod = '',
this.payoutLabel = '',
this.canFund = true,
});
final String payoutMethod, payoutLabel;
final bool canFund;
final String id, currency;
final int precision;
int available;
@@ -64,6 +74,9 @@ abstract final class WalletMapper {
currency: d.currency,
precision: d.precision,
available: d.available,
payoutMethod: d.payoutMethod,
payoutLabel: d.payoutLabel,
canFund: d.canFund,
);
static RequestDTO request(TransferRequest r) =>
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
@@ -4,3 +4,5 @@ export 'src/providers/transfer.dart';
export 'src/services/contracts.dart';
export 'src/services/dummy.dart';
export 'src/providers/limits.dart';
export 'src/services/backend.dart';
export 'src/services/factors.dart';