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
@@ -32,8 +32,9 @@ wallet state changes. The adapter reuses the KYC package's limit DTO and mapper.
|
||||
|
||||
## Integration boundary
|
||||
|
||||
Replace `WalletService` and `LimitsService` together with authenticated backend
|
||||
adapters. The backend must provide authorization, authoritative quotes/balances,
|
||||
Backend mode now uses `BackendWalletService` and `WalletFactorService` with the
|
||||
authenticated BFF; `BackendLimitsService` supplies the reviewed platform projection.
|
||||
See [payment provisioning and rollout](../../../docs/payments.md). The backend must provide authorization, authoritative quotes/balances,
|
||||
atomic limits enforcement, durable idempotency and operation reconciliation.
|
||||
Add supported payment methods and validated recipients from that contract, rather
|
||||
than interpreting the demo input as production bank validation. Replace profile
|
||||
|
||||
@@ -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';
|
||||
@@ -6,6 +6,7 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
http: ^1.6.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
sxchnge_auth:
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
Map<String, Object?> quoteData() => {
|
||||
'id': 'local-quote',
|
||||
'request': {
|
||||
'kind': 'payout',
|
||||
'source': 'ledger',
|
||||
'destination': '',
|
||||
'recipient': 'Saved bank ending 1234',
|
||||
'amount': 2001,
|
||||
},
|
||||
'fee': 100,
|
||||
'debit': 2101,
|
||||
'credit': 2001,
|
||||
'expiresAt': DateTime.now()
|
||||
.add(const Duration(minutes: 5))
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
'rateNumerator': 1,
|
||||
'rateDenominator': 1,
|
||||
};
|
||||
Map<String, Object?> operationData(String status) => {
|
||||
'id': 'local-quote',
|
||||
'quote': quoteData(),
|
||||
'status': status,
|
||||
'createdAt': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
http.Response success(Object? data) =>
|
||||
http.Response(jsonEncode({'status': 'success', 'data': data}), 200);
|
||||
void main() {
|
||||
test('backend wallet maps accounts, authoritative quote, execution and reconciliation', () async {
|
||||
final requests = <http.Request>[];
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example/api/v1'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((r) async {
|
||||
requests.add(r);
|
||||
expect(r.headers['Authorization'], 'Bearer session');
|
||||
expect(r.followRedirects, false);
|
||||
switch (r.url.path) {
|
||||
case '/api/v1/accounts/wallet':
|
||||
return success({
|
||||
'accounts': [
|
||||
{
|
||||
'id': 'ledger',
|
||||
'currency': 'EUR',
|
||||
'precision': 2,
|
||||
'available': 7500,
|
||||
'payoutMethod': 'method',
|
||||
'payoutLabel': 'Saved bank ending 1234',
|
||||
'canFund': false,
|
||||
},
|
||||
],
|
||||
'operations': <Object>[],
|
||||
});
|
||||
case '/api/v1/accounts/wallet/quote':
|
||||
expect(jsonDecode(r.body), {
|
||||
'kind': 'payout',
|
||||
'source': 'ledger',
|
||||
'destination': '',
|
||||
'amount': 2001,
|
||||
'recipient': 'method',
|
||||
});
|
||||
return success(quoteData());
|
||||
case '/api/v1/accounts/wallet/submit':
|
||||
expect(jsonDecode(r.body), {
|
||||
'quoteId': 'local-quote',
|
||||
'idempotencyKey': 'authorized-key',
|
||||
});
|
||||
return success(operationData('pending'));
|
||||
case '/api/v1/accounts/wallet/submission':
|
||||
return success(operationData('completed'));
|
||||
default:
|
||||
return success({'locale': 'ru'});
|
||||
}
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
expect(service.isSimulated, false);
|
||||
final data = await service.fetch();
|
||||
expect(data.accounts.single.available, 7500);
|
||||
expect(data.accounts.single.payoutLabel, 'Saved bank ending 1234');
|
||||
final q = await service.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'ledger',
|
||||
recipient: 'method',
|
||||
amount: 2001,
|
||||
),
|
||||
);
|
||||
expect(q.debit, 2101);
|
||||
expect(q.request.recipient, 'Saved bank ending 1234');
|
||||
expect(
|
||||
(await service.submit(q.id, 'authorized-key')).status,
|
||||
OperationStatus.pending,
|
||||
);
|
||||
expect(
|
||||
(await service.findSubmission('authorized-key'))!.status,
|
||||
OperationStatus.completed,
|
||||
);
|
||||
expect((await service.profile()).locale, 'ru');
|
||||
expect(requests.length, 5);
|
||||
});
|
||||
test('session replacement rejects late responses and never returns stale balances', () async {
|
||||
String? token = 'one';
|
||||
final response = Completer<http.Response>();
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => token,
|
||||
client: MockClient((_) => response.future),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final result = service.fetch();
|
||||
token = 'two';
|
||||
response.complete(success({'accounts': [], 'operations': []}));
|
||||
await expectLater(result, throwsA(isA<WalletException>()));
|
||||
token = null;
|
||||
await expectLater(service.fetch(), throwsA(isA<WalletException>()));
|
||||
});
|
||||
test(
|
||||
'malformed or unsuccessful responses cannot report payment success',
|
||||
() async {
|
||||
for (final response in [
|
||||
http.Response('broken', 200),
|
||||
http.Response('{"status":"error","data":{"error":"uncertain"}}', 503),
|
||||
success(null),
|
||||
]) {
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((_) async => response),
|
||||
);
|
||||
await expectLater(
|
||||
service.submit('quote', 'key'),
|
||||
throwsA(isA<WalletException>()),
|
||||
);
|
||||
service.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
test(
|
||||
'payment factor keeps authorization and resend keys stable across retry',
|
||||
() async {
|
||||
final bodies = <Map<String, dynamic>>[];
|
||||
var fail = true;
|
||||
final wallet = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((r) async {
|
||||
final body = jsonDecode(r.body) as Map<String, dynamic>;
|
||||
bodies.add(body);
|
||||
if (r.url.path.endsWith('/verify')) {
|
||||
if (fail) {
|
||||
fail = false;
|
||||
return http.Response(
|
||||
'{"status":"error","data":{"error":"factorInvalid"}}',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return success({'idempotencyKey': body['idempotencyKey']});
|
||||
}
|
||||
return success({
|
||||
'challengeId': 'challenge',
|
||||
'target': 'a***@example.com',
|
||||
'expiresAt': DateTime.now()
|
||||
.add(const Duration(minutes: 1))
|
||||
.toUtc()
|
||||
.toIso8601String(),
|
||||
'resendAt': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
}),
|
||||
);
|
||||
addTearDown(wallet.close);
|
||||
final factor = WalletFactorService(wallet, DummyTwoFactorService());
|
||||
final challenge = (await factor.begin(FactorPurpose.payout, 'quote'))!;
|
||||
await expectLater(
|
||||
factor.verify(challenge.id, 'bad'),
|
||||
throwsA(
|
||||
isA<FactorException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
FactorError.invalid,
|
||||
),
|
||||
),
|
||||
);
|
||||
final grant = await factor.verify(challenge.id, '123456');
|
||||
expect(grant.token, bodies[1]['idempotencyKey']);
|
||||
expect(bodies[1]['idempotencyKey'], bodies[2]['idempotencyKey']);
|
||||
await factor.resend(challenge.id);
|
||||
expect(bodies.last['idempotencyKey'], isNotEmpty);
|
||||
},
|
||||
);
|
||||
test('provider waits for real account IDs and submits with the authorized intent key', () async {
|
||||
final service = BackendWalletService(
|
||||
baseUri: Uri.parse('https://bff.example'),
|
||||
accessToken: () => 'session',
|
||||
client: MockClient((r) async {
|
||||
if (r.url.path.endsWith('/quote')) return success(quoteData());
|
||||
if (r.url.path.endsWith('/submit')) {
|
||||
expect(jsonDecode(r.body)['idempotencyKey'], 'approved-intent-key');
|
||||
return success(operationData('completed'));
|
||||
}
|
||||
return success({
|
||||
'accounts': [
|
||||
{
|
||||
'id': 'ledger',
|
||||
'currency': 'EUR',
|
||||
'precision': 2,
|
||||
'available': 7500,
|
||||
'payoutMethod': 'method',
|
||||
'payoutLabel': 'Saved bank ending 1234',
|
||||
'canFund': false,
|
||||
},
|
||||
],
|
||||
'operations': <Object>[],
|
||||
});
|
||||
}),
|
||||
);
|
||||
addTearDown(service.close);
|
||||
final wallet = WalletProvider(service, DummyAnalyticsService());
|
||||
addTearDown(wallet.dispose);
|
||||
final draft = TransferProvider(wallet, TransferKind.payout);
|
||||
addTearDown(draft.dispose);
|
||||
await wallet.refresh();
|
||||
expect(draft.source, 'ledger');
|
||||
expect(draft.recipient, 'method');
|
||||
expect(draft.hasDraft, false);
|
||||
draft.change(amount: '20.01');
|
||||
await draft.review();
|
||||
await draft.submit(approval: const FactorGrant('approved-intent-key'));
|
||||
expect(draft.result, isNotNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user