Build exchange workspace and shared transaction services (#28)
This commit is contained in:
1 parent
52b7135eda
commit
c496da8a55
48 files changed
+4757
-120
No files matched your search
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
enum JourneyKind { login, signup, verification, kyc }
|
||||
enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout }
|
||||
|
||||
enum JourneyStep {
|
||||
login,
|
||||
@@ -14,6 +14,10 @@ enum JourneyStep {
|
||||
kycSelfie,
|
||||
kycSubmission,
|
||||
kycStatus,
|
||||
transferDraft,
|
||||
transferQuote,
|
||||
transferSubmission,
|
||||
settlement,
|
||||
}
|
||||
|
||||
enum JourneySignal {
|
||||
@@ -27,6 +31,9 @@ enum JourneySignal {
|
||||
}
|
||||
|
||||
enum JourneyFailure {
|
||||
insufficientFunds,
|
||||
limitExceeded,
|
||||
declined,
|
||||
document,
|
||||
invalidCredentials,
|
||||
emailInUse,
|
||||
@@ -79,7 +86,13 @@ class AnalyticsEvent {
|
||||
final bool simulated;
|
||||
final DateTime occurredAt;
|
||||
String get name {
|
||||
final area = kind == JourneyKind.kyc ? 'kyc' : 'auth';
|
||||
final area = switch (kind) {
|
||||
JourneyKind.kyc => 'kyc',
|
||||
JourneyKind.topUp => 'top_up',
|
||||
JourneyKind.exchange => 'exchange',
|
||||
JourneyKind.payout => 'payout',
|
||||
_ => 'auth',
|
||||
};
|
||||
var stage = step == null ? 'journey' : _snake(step!.name);
|
||||
if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', '');
|
||||
return '${area}_${stage}_${signal.name}';
|
||||
@@ -189,7 +202,7 @@ class JourneyRecorder {
|
||||
operation._finished = true;
|
||||
journey.pending--;
|
||||
journey.simulated |= simulated;
|
||||
final complete = failure == null && !cancelled && terminal;
|
||||
final complete = !cancelled && terminal;
|
||||
_emit(
|
||||
journey,
|
||||
cancelled
|
||||
@@ -257,8 +270,8 @@ class JourneyRecorder {
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
abandon(JourneyExit.disposed);
|
||||
void dispose({bool abandonActive = true}) {
|
||||
if (abandonActive) abandon(JourneyExit.disposed);
|
||||
_disposed = true;
|
||||
_idle?.cancel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Shared limit transport contract for service adapters.
|
||||
export 'src/transport/contracts.dart' show LimitsDTO, LimitDTO, LimitsMapper;
|
||||
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,48 @@
|
||||
# SXCHNGE wallet
|
||||
|
||||
Shared Flutter domain, injectable services and providers for accounts, quotes,
|
||||
operations, limits synchronization and account language. Web and mobile clients
|
||||
consume the same models; widgets do not call transport APIs.
|
||||
|
||||
`WalletService` returns mapped domain models. `DummyWalletService` is an in-memory
|
||||
backend stand-in; `WalletProvider` owns snapshots and pending-operation refresh,
|
||||
`TransferProvider` owns draft/review/submission/reconciliation, and
|
||||
`WorkspaceLimitsProvider` refetches the separate `LimitsService` projection when
|
||||
wallet state changes. The adapter reuses the KYC package's limit DTO and mapper.
|
||||
|
||||
## Demo behavior
|
||||
|
||||
- Opening balances: EUR 75, USD 0, GBP 0. No invented historical transactions.
|
||||
- Fixed demo ratios: EUR/USD 1.1; GBP uses weight 1300 vs EUR 1100 and USD 1000.
|
||||
- Top-up fee zero; exchange fee 0.5% rounded down, minimum one minor unit;
|
||||
payout fee 100 minor units. Quote expiry is 60 seconds.
|
||||
- Top-ups credit only after settlement. Outgoing total debit includes the fee;
|
||||
it leaves available balance on acceptance. Failed outgoing operations restore
|
||||
that exact total. Exchange destination credits only on completion.
|
||||
- Outgoing usage, including fees, is converted upward to EUR minor units.
|
||||
Basic caps: EUR 100/day and 500/month; verified: 2500/day and 10000/month.
|
||||
Top-ups do not consume outgoing capacity. Windows follow the stub's local clock.
|
||||
Policy, usage and remaining are returned by the service, not set in widgets.
|
||||
- Submission rechecks balance, limits and expiry, is idempotent by request key,
|
||||
and permits a quote to execute only once. Unknown outcomes reconcile before
|
||||
any resubmission. `nextError`, `loseNextResponse`, `failNextOperation`, injectable
|
||||
clock/delays support deterministic failure testing.
|
||||
- Everything resets on application reload. Recipient values are demo text only;
|
||||
no bank details, payment provider, storage or real money movement is connected.
|
||||
|
||||
## Integration boundary
|
||||
|
||||
Replace `WalletService` and `LimitsService` together with authenticated backend
|
||||
adapters. 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
|
||||
storage with the signed-in account endpoint. No authorization is supplied by
|
||||
public demo routes or an available balance.
|
||||
|
||||
Analytics use the shared typed recorder: draft, quote, submission and authoritative
|
||||
settlement. Retried submissions keep a journey; each accepted operation closes
|
||||
once on success/failure. Closing the app while pending does not count as draft
|
||||
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
||||
signals are simulated. Cross-session outcomes require backend event delivery;
|
||||
client-only analytics are not production accounting or conversion evidence.
|
||||
@@ -0,0 +1,113 @@
|
||||
enum TransferKind { topUp, exchange, payout }
|
||||
|
||||
enum OperationStatus { pending, completed, failed }
|
||||
|
||||
enum WalletError {
|
||||
unavailable,
|
||||
invalidAmount,
|
||||
insufficientFunds,
|
||||
limitExceeded,
|
||||
expiredQuote,
|
||||
invalidRecipient,
|
||||
unsupported,
|
||||
notFound,
|
||||
uncertain,
|
||||
}
|
||||
|
||||
class WalletException implements Exception {
|
||||
const WalletException(this.code);
|
||||
final WalletError code;
|
||||
}
|
||||
|
||||
class CurrencyAccount {
|
||||
const CurrencyAccount({
|
||||
required this.id,
|
||||
required this.currency,
|
||||
required this.precision,
|
||||
required this.available,
|
||||
});
|
||||
final String id, currency;
|
||||
final int precision, available;
|
||||
|
||||
/// Locale-aware decimal input, integer money. Grouping/exponents are rejected.
|
||||
int? parse(String input) {
|
||||
final value = input.trim().replaceAll(',', '.');
|
||||
if (!RegExp(r'^\d+(\.\d+)?$').hasMatch(value)) return null;
|
||||
final parts = value.split('.');
|
||||
if (parts.length > 1 && parts[1].length > precision) return null;
|
||||
final major = int.tryParse(parts[0]);
|
||||
if (major == null || major > 100000000) return null;
|
||||
final fraction = parts.length > 1
|
||||
? int.parse(parts[1].padRight(precision, '0'))
|
||||
: 0;
|
||||
return major * scale + fraction;
|
||||
}
|
||||
|
||||
int get scale {
|
||||
var v = 1;
|
||||
for (var i = 0; i < precision; i++) {
|
||||
v *= 10;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
class TransferRequest {
|
||||
const TransferRequest({
|
||||
required this.kind,
|
||||
required this.source,
|
||||
required this.amount,
|
||||
this.destination,
|
||||
this.recipient = '',
|
||||
});
|
||||
final TransferKind kind;
|
||||
final String source;
|
||||
final int amount;
|
||||
final String? destination;
|
||||
final String recipient;
|
||||
}
|
||||
|
||||
class Quote {
|
||||
const Quote({
|
||||
required this.id,
|
||||
required this.request,
|
||||
required this.fee,
|
||||
required this.debit,
|
||||
required this.credit,
|
||||
required this.expiresAt,
|
||||
required this.rateNumerator,
|
||||
required this.rateDenominator,
|
||||
});
|
||||
final String id;
|
||||
final TransferRequest request;
|
||||
final int fee, debit, credit, rateNumerator, rateDenominator;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
class WalletOperation {
|
||||
const WalletOperation({
|
||||
required this.id,
|
||||
required this.quote,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
});
|
||||
final String id;
|
||||
final Quote quote;
|
||||
final OperationStatus status;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
class WalletSnapshot {
|
||||
WalletSnapshot({
|
||||
required List<CurrencyAccount> accounts,
|
||||
required List<WalletOperation> operations,
|
||||
}) : accounts = List.unmodifiable(accounts),
|
||||
operations = List.unmodifiable(operations);
|
||||
final List<CurrencyAccount> accounts;
|
||||
final List<WalletOperation> operations;
|
||||
}
|
||||
|
||||
class AccountProfile {
|
||||
const AccountProfile(this.locale);
|
||||
final String locale;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'wallet.dart';
|
||||
|
||||
/// Refetch the backend projection when an operation changes the wallet.
|
||||
class WorkspaceLimitsProvider extends LimitsProvider {
|
||||
WorkspaceLimitsProvider(super.service, this.wallet) {
|
||||
_snapshot = wallet.data;
|
||||
wallet.addListener(_changed);
|
||||
}
|
||||
final WalletProvider wallet;
|
||||
WalletSnapshot? _snapshot;
|
||||
void _changed() {
|
||||
if (identical(_snapshot, wallet.data)) return;
|
||||
_snapshot = wallet.data;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
wallet.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'wallet.dart';
|
||||
|
||||
class TransferProvider extends ChangeNotifier {
|
||||
TransferProvider(this.wallet, this.kind, {String? source})
|
||||
: source = source ?? wallet.data?.accounts.firstOrNull?.id ?? 'eur',
|
||||
recorder = JourneyRecorder(wallet.analytics) {
|
||||
destination =
|
||||
wallet.data?.accounts
|
||||
.where((a) => a.id != this.source)
|
||||
.firstOrNull
|
||||
?.id ??
|
||||
'usd';
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferDraft,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
}
|
||||
final WalletProvider wallet;
|
||||
final TransferKind kind;
|
||||
final JourneyRecorder recorder;
|
||||
String source;
|
||||
String? destination;
|
||||
String amount = '', recipient = '';
|
||||
Quote? quote;
|
||||
WalletOperation? result;
|
||||
WalletError? error;
|
||||
bool noSubmission = false;
|
||||
bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
|
||||
int _revision = 0;
|
||||
String? _key;
|
||||
JourneyKind get journey => switch (kind) {
|
||||
TransferKind.topUp => JourneyKind.topUp,
|
||||
TransferKind.exchange => JourneyKind.exchange,
|
||||
TransferKind.payout => JourneyKind.payout,
|
||||
};
|
||||
bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty;
|
||||
bool get expired =>
|
||||
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
|
||||
void change({
|
||||
String? source,
|
||||
String? destination,
|
||||
String? amount,
|
||||
String? recipient,
|
||||
}) {
|
||||
if (busy || uncertain) return;
|
||||
this.source = source ?? this.source;
|
||||
this.destination = destination ?? this.destination;
|
||||
this.amount = amount ?? this.amount;
|
||||
this.recipient = recipient ?? this.recipient;
|
||||
quote = null;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
_key = null;
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void edit() {
|
||||
if (!busy && !uncertain) {
|
||||
quote = null;
|
||||
error = null;
|
||||
_key = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> review() async {
|
||||
if (busy || uncertain) return;
|
||||
final money = wallet.account(source)?.parse(amount);
|
||||
if (money == null || money <= 0) {
|
||||
error = WalletError.invalidAmount;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
final revision = ++_revision;
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferQuote,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final q = await wallet.service.quote(
|
||||
TransferRequest(
|
||||
kind: kind,
|
||||
source: source,
|
||||
amount: money,
|
||||
destination: kind == TransferKind.exchange ? destination : null,
|
||||
recipient: recipient.trim(),
|
||||
),
|
||||
);
|
||||
if (_disposed || revision != _revision) return;
|
||||
quote = q;
|
||||
_key = null;
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (busy || quote == null || uncertain) return;
|
||||
if (expired) {
|
||||
error = WalletError.expiredQuote;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
_key ??= List.generate(
|
||||
16,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferSubmission,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final r = await wallet.service.submit(quote!.id, _key!);
|
||||
if (_disposed) return;
|
||||
_accepted(r, op);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
// All transport/unexpected failures are ambiguous after execution started.
|
||||
uncertain = [
|
||||
WalletError.unavailable,
|
||||
WalletError.uncertain,
|
||||
].contains(error);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _accepted(WalletOperation value, JourneyOperation op) {
|
||||
result = value;
|
||||
uncertain = false;
|
||||
error = null;
|
||||
recipient = '';
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
wallet.track(value, recorder, journey);
|
||||
_handedOff = true;
|
||||
}
|
||||
|
||||
Future<void> reconcile() async {
|
||||
if (busy || !uncertain || _key == null) return;
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferSubmission,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final r = await wallet.service.findSubmission(_key!);
|
||||
if (_disposed) return;
|
||||
if (r != null) {
|
||||
_accepted(r, op);
|
||||
} else {
|
||||
uncertain = false;
|
||||
noSubmission = true;
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
}
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
WalletError _code(Object e) =>
|
||||
e is WalletException ? e.code : WalletError.unavailable;
|
||||
JourneyFailure _failure(WalletError e) => switch (e) {
|
||||
WalletError.insufficientFunds => JourneyFailure.insufficientFunds,
|
||||
WalletError.limitExceeded => JourneyFailure.limitExceeded,
|
||||
WalletError.expiredQuote => JourneyFailure.expired,
|
||||
_ => JourneyFailure.unavailable,
|
||||
};
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
recipient = '';
|
||||
if (!_handedOff) recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../services/contracts.dart';
|
||||
|
||||
class WalletProvider extends ChangeNotifier {
|
||||
WalletProvider(this.service, this.analytics);
|
||||
final WalletService service;
|
||||
final AnalyticsService analytics;
|
||||
WalletSnapshot? data;
|
||||
bool loading = false,
|
||||
failed = false,
|
||||
profileLoading = false,
|
||||
profileFailed = false,
|
||||
saving = false,
|
||||
saveFailed = false;
|
||||
String? preferredLocale;
|
||||
bool _disposed = false;
|
||||
int _revision = 0;
|
||||
Timer? _poll;
|
||||
final _settlements = <String, (JourneyRecorder, JourneyOperation)>{};
|
||||
CurrencyAccount? account(String? id) {
|
||||
for (final a in data?.accounts ?? <CurrencyAccount>[]) {
|
||||
if (a.id == id) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
WalletOperation? operation(String id) {
|
||||
for (final o in data?.operations ?? <WalletOperation>[]) {
|
||||
if (o.id == id) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get hasPending =>
|
||||
data?.operations.any((o) => o.status == OperationStatus.pending) ?? false;
|
||||
Future<void> refresh() async {
|
||||
final rev = ++_revision;
|
||||
loading = true;
|
||||
failed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.fetch();
|
||||
if (_disposed || rev != _revision) return;
|
||||
data = result;
|
||||
for (final o in result.operations) {
|
||||
if (o.status == OperationStatus.pending) continue;
|
||||
final pair = _settlements.remove(o.id);
|
||||
if (pair != null) {
|
||||
pair.$1.finish(
|
||||
pair.$2,
|
||||
simulated: service.isSimulated,
|
||||
failure: o.status == OperationStatus.failed
|
||||
? JourneyFailure.declined
|
||||
: null,
|
||||
terminal: true,
|
||||
);
|
||||
pair.$1.dispose();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (_disposed || rev != _revision) return;
|
||||
failed = true;
|
||||
}
|
||||
if (_disposed || rev != _revision) return;
|
||||
loading = false;
|
||||
_poll?.cancel();
|
||||
if (hasPending) {
|
||||
_poll = Timer(const Duration(seconds: 3), () {
|
||||
if (!_disposed) refresh();
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void track(
|
||||
WalletOperation operation,
|
||||
JourneyRecorder recorder,
|
||||
JourneyKind kind,
|
||||
) {
|
||||
final step = recorder.begin(
|
||||
kind,
|
||||
JourneyStep.settlement,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
_settlements[operation.id] = (recorder, step);
|
||||
final snapshot = data;
|
||||
if (snapshot != null) {
|
||||
data = WalletSnapshot(
|
||||
accounts: snapshot.accounts,
|
||||
operations: [
|
||||
operation,
|
||||
...snapshot.operations.where((o) => o.id != operation.id),
|
||||
],
|
||||
);
|
||||
}
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
Future<void> loadProfile() async {
|
||||
if (profileLoading) return;
|
||||
profileLoading = true;
|
||||
profileFailed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.profile();
|
||||
if (_disposed) return;
|
||||
preferredLocale = result.locale;
|
||||
} catch (_) {
|
||||
if (_disposed) return;
|
||||
profileFailed = true;
|
||||
}
|
||||
profileLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> saveLocale(String locale) async {
|
||||
if (saving) return false;
|
||||
saving = true;
|
||||
saveFailed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.saveLocale(locale);
|
||||
if (_disposed) return false;
|
||||
preferredLocale = result.locale;
|
||||
} catch (_) {
|
||||
if (_disposed) return false;
|
||||
saveFailed = true;
|
||||
}
|
||||
saving = false;
|
||||
notifyListeners();
|
||||
return !saveFailed;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_poll?.cancel();
|
||||
for (final p in _settlements.values) {
|
||||
p.$1.dispose(abandonActive: false);
|
||||
}
|
||||
_settlements.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../models/wallet.dart';
|
||||
|
||||
abstract interface class WalletService {
|
||||
bool get isSimulated;
|
||||
Future<WalletSnapshot> fetch();
|
||||
Future<Quote> quote(TransferRequest request);
|
||||
|
||||
/// Same key must reconcile uncertain submissions without duplicate execution.
|
||||
Future<WalletOperation> submit(String quoteId, String idempotencyKey);
|
||||
Future<WalletOperation?> findSubmission(String idempotencyKey);
|
||||
Future<AccountProfile> profile();
|
||||
Future<AccountProfile> saveLocale(String locale);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sxchnge_kyc/limits_transport.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../transport/wallet.dart';
|
||||
import 'contracts.dart';
|
||||
|
||||
/// Coherent, account-scoped backend stand-in. No network or persistence.
|
||||
class DummyWalletService implements WalletService {
|
||||
DummyWalletService(
|
||||
this.identity, {
|
||||
this.delay = const Duration(milliseconds: 350),
|
||||
this.settlement = const Duration(seconds: 2),
|
||||
DateTime Function()? now,
|
||||
String initialLocale = 'en',
|
||||
}) : now = now ?? DateTime.now,
|
||||
_locale = initialLocale;
|
||||
final IdentityService identity;
|
||||
final Duration delay, settlement;
|
||||
final DateTime Function() now;
|
||||
final _accounts = [
|
||||
AccountDTO('eur', 'EUR', 2, 7500),
|
||||
AccountDTO('usd', 'USD', 2, 0),
|
||||
AccountDTO('gbp', 'GBP', 2, 0),
|
||||
];
|
||||
final Map<String, QuoteDTO> _quotes = {};
|
||||
final _usedQuotes = <String>{};
|
||||
final Map<String, OperationDTO> _submissions = {};
|
||||
String _locale;
|
||||
int _serial = 0;
|
||||
WalletError? nextError;
|
||||
bool failNextOperation = false;
|
||||
bool loseNextResponse = false;
|
||||
@override
|
||||
bool get isSimulated => true;
|
||||
Future<void> _wait() async {
|
||||
await Future<void>.delayed(delay);
|
||||
final error = nextError;
|
||||
nextError = null;
|
||||
if (error != null) throw WalletException(error);
|
||||
}
|
||||
|
||||
AccountDTO _account(String id) => _accounts.firstWhere(
|
||||
(a) => a.id == id,
|
||||
orElse: () => throw const WalletException(WalletError.notFound),
|
||||
);
|
||||
// Fixed demo ratios to EUR; never marketed as live rates.
|
||||
int _weight(String id) => switch (id) {
|
||||
'eur' => 1100,
|
||||
'usd' => 1000,
|
||||
'gbp' => 1300,
|
||||
_ => throw const WalletException(WalletError.unsupported),
|
||||
};
|
||||
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
|
||||
void _settle() {
|
||||
for (final o in _submissions.values) {
|
||||
if (o.status != 'pending' || now().isBefore(o.ready)) continue;
|
||||
o.status = o.fail ? 'failed' : 'completed';
|
||||
final q = o.quote;
|
||||
if (o.fail) {
|
||||
if (q.request.kind != 'topUp')
|
||||
_account(q.request.source).available += q.debit;
|
||||
} else if (q.request.kind == 'topUp') {
|
||||
_account(q.request.source).available += q.credit;
|
||||
} else if (q.request.kind == 'exchange') {
|
||||
_account(q.request.destination!).available += q.credit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _usage({required bool monthly, required bool pending}) {
|
||||
final current = now();
|
||||
return _submissions.values
|
||||
.where(
|
||||
(o) =>
|
||||
o.quote.request.kind != 'topUp' &&
|
||||
o.status == (pending ? 'pending' : 'completed') &&
|
||||
o.created.year == current.year &&
|
||||
o.created.month == current.month &&
|
||||
(monthly || o.created.day == current.day),
|
||||
)
|
||||
.fold(
|
||||
0,
|
||||
(total, o) => total + _eur(o.quote.debit, o.quote.request.source),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _verified() async =>
|
||||
(await identity.fetch()).status == IdentityStatus.approved;
|
||||
void _check(QuoteDTO q, bool verified) {
|
||||
if (q.request.kind == 'topUp') return;
|
||||
if (_account(q.request.source).available < q.debit)
|
||||
throw const WalletException(WalletError.insufficientFunds);
|
||||
final charge = _eur(q.debit, q.request.source);
|
||||
if (charge +
|
||||
_usage(monthly: false, pending: false) +
|
||||
_usage(monthly: false, pending: true) >
|
||||
(verified ? 250000 : 10000) ||
|
||||
charge +
|
||||
_usage(monthly: true, pending: false) +
|
||||
_usage(monthly: true, pending: true) >
|
||||
(verified ? 1000000 : 50000))
|
||||
throw const WalletException(WalletError.limitExceeded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletSnapshot> fetch() async {
|
||||
await _wait();
|
||||
_settle();
|
||||
return WalletSnapshot(
|
||||
accounts: _accounts.map(WalletMapper.account).toList(),
|
||||
operations: _submissions.values
|
||||
.toList()
|
||||
.reversed
|
||||
.map(WalletMapper.operation)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Quote> quote(TransferRequest r) async {
|
||||
await _wait();
|
||||
_settle();
|
||||
_account(r.source);
|
||||
if (r.amount <= 0 || r.amount > 100000000)
|
||||
throw const WalletException(WalletError.invalidAmount);
|
||||
if (r.kind == TransferKind.exchange &&
|
||||
(r.source == r.destination || r.destination == null))
|
||||
throw const WalletException(WalletError.unsupported);
|
||||
if (r.kind == TransferKind.payout &&
|
||||
(r.recipient.trim().length < 6 || r.recipient.length > 140))
|
||||
throw const WalletException(WalletError.invalidRecipient);
|
||||
if (r.kind == TransferKind.exchange) _account(r.destination!);
|
||||
final fee = switch (r.kind) {
|
||||
TransferKind.topUp => 0,
|
||||
TransferKind.exchange => max(1, r.amount ~/ 200),
|
||||
TransferKind.payout => 100,
|
||||
};
|
||||
final n = r.kind == TransferKind.exchange ? _weight(r.source) : 1;
|
||||
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
|
||||
final q = QuoteDTO(
|
||||
'quote-${++_serial}',
|
||||
WalletMapper.request(r),
|
||||
fee,
|
||||
r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
|
||||
r.amount * n ~/ d,
|
||||
now().add(const Duration(seconds: 60)),
|
||||
n,
|
||||
d,
|
||||
);
|
||||
if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount);
|
||||
final verified = await _verified();
|
||||
_settle();
|
||||
_check(q, verified);
|
||||
_quotes[q.id] = q;
|
||||
return WalletMapper.quote(q);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation> submit(String quoteId, String idempotencyKey) async {
|
||||
await _wait();
|
||||
if (_submissions.containsKey(idempotencyKey))
|
||||
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||
final q = _quotes[quoteId];
|
||||
if (q == null) throw const WalletException(WalletError.expiredQuote);
|
||||
final verified = await _verified();
|
||||
_settle();
|
||||
if (_submissions.containsKey(idempotencyKey))
|
||||
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||
if (!now().isBefore(q.expires))
|
||||
throw const WalletException(WalletError.expiredQuote);
|
||||
if (_usedQuotes.contains(q.id))
|
||||
throw const WalletException(WalletError.expiredQuote);
|
||||
_check(q, verified);
|
||||
_usedQuotes.add(q.id);
|
||||
if (q.request.kind != 'topUp')
|
||||
_account(q.request.source).available -= q.debit;
|
||||
final o = OperationDTO(
|
||||
'op-${++_serial}',
|
||||
q,
|
||||
now(),
|
||||
now().add(settlement),
|
||||
fail: failNextOperation,
|
||||
);
|
||||
failNextOperation = false;
|
||||
_submissions[idempotencyKey] = o;
|
||||
if (loseNextResponse) {
|
||||
loseNextResponse = false;
|
||||
throw const WalletException(WalletError.uncertain);
|
||||
}
|
||||
return WalletMapper.operation(o);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation?> findSubmission(String key) async {
|
||||
await _wait();
|
||||
_settle();
|
||||
final o = _submissions[key];
|
||||
return o == null ? null : WalletMapper.operation(o);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AccountProfile> profile() async {
|
||||
await _wait();
|
||||
return WalletMapper.profile(_locale);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AccountProfile> saveLocale(String locale) async {
|
||||
await _wait();
|
||||
if (!['en', 'ru'].contains(locale))
|
||||
throw const WalletException(WalletError.unsupported);
|
||||
_locale = locale;
|
||||
return WalletMapper.profile(_locale);
|
||||
}
|
||||
|
||||
// Kept separate from wallet fetch because LimitsService has a different result.
|
||||
Future<Limits> fetchLimits() async {
|
||||
await _wait();
|
||||
final verified = await _verified();
|
||||
_settle();
|
||||
final rows = <LimitDTO>[];
|
||||
for (final monthly in [false, true]) {
|
||||
final basic = monthly ? 50000 : 10000;
|
||||
final upgraded = monthly ? 1000000 : 250000;
|
||||
final current = verified ? upgraded : basic;
|
||||
final used = _usage(monthly: monthly, pending: false);
|
||||
final pending = _usage(monthly: monthly, pending: true);
|
||||
rows.add(
|
||||
LimitDTO(
|
||||
monthly ? 'month' : 'day',
|
||||
'EUR',
|
||||
basic,
|
||||
upgraded,
|
||||
current,
|
||||
max(0, current - used - pending),
|
||||
used,
|
||||
pending,
|
||||
),
|
||||
);
|
||||
}
|
||||
return LimitsMapper.fromDTO(
|
||||
LimitsDTO(
|
||||
tier: verified ? 'verified' : 'basic',
|
||||
rows: rows,
|
||||
simulated: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WalletLimitsService implements LimitsService {
|
||||
const WalletLimitsService(this.wallet);
|
||||
final DummyWalletService wallet;
|
||||
@override
|
||||
Future<Limits> fetch() => wallet.fetchLimits();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import '../models/wallet.dart';
|
||||
|
||||
class AccountDTO {
|
||||
AccountDTO(this.id, this.currency, this.precision, this.available);
|
||||
final String id, currency;
|
||||
final int precision;
|
||||
int available;
|
||||
}
|
||||
|
||||
class RequestDTO {
|
||||
const RequestDTO(
|
||||
this.kind,
|
||||
this.source,
|
||||
this.destination,
|
||||
this.amount,
|
||||
this.recipient,
|
||||
);
|
||||
final String kind, source, recipient;
|
||||
final String? destination;
|
||||
final int amount;
|
||||
}
|
||||
|
||||
class QuoteDTO {
|
||||
const QuoteDTO(
|
||||
this.id,
|
||||
this.request,
|
||||
this.fee,
|
||||
this.debit,
|
||||
this.credit,
|
||||
this.expires,
|
||||
this.numerator,
|
||||
this.denominator,
|
||||
);
|
||||
final String id;
|
||||
final RequestDTO request;
|
||||
final int fee, debit, credit, numerator, denominator;
|
||||
final DateTime expires;
|
||||
}
|
||||
|
||||
class OperationDTO {
|
||||
OperationDTO(
|
||||
this.id,
|
||||
this.quote,
|
||||
this.created,
|
||||
this.ready, {
|
||||
this.status = 'pending',
|
||||
this.fail = false,
|
||||
});
|
||||
final String id;
|
||||
final QuoteDTO quote;
|
||||
final DateTime created, ready;
|
||||
String status;
|
||||
final bool fail;
|
||||
}
|
||||
|
||||
abstract final class WalletMapper {
|
||||
static CurrencyAccount account(AccountDTO d) => CurrencyAccount(
|
||||
id: d.id,
|
||||
currency: d.currency,
|
||||
precision: d.precision,
|
||||
available: d.available,
|
||||
);
|
||||
static RequestDTO request(TransferRequest r) =>
|
||||
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
|
||||
static TransferRequest fromRequest(RequestDTO d) => TransferRequest(
|
||||
kind: TransferKind.values.byName(d.kind),
|
||||
source: d.source,
|
||||
destination: d.destination,
|
||||
amount: d.amount,
|
||||
recipient: d.recipient,
|
||||
);
|
||||
static Quote quote(QuoteDTO d) => Quote(
|
||||
id: d.id,
|
||||
request: fromRequest(d.request),
|
||||
fee: d.fee,
|
||||
debit: d.debit,
|
||||
credit: d.credit,
|
||||
expiresAt: d.expires,
|
||||
rateNumerator: d.numerator,
|
||||
rateDenominator: d.denominator,
|
||||
);
|
||||
static WalletOperation operation(OperationDTO d) => WalletOperation(
|
||||
id: d.id,
|
||||
quote: quote(d.quote),
|
||||
status: OperationStatus.values.byName(d.status),
|
||||
createdAt: d.created,
|
||||
);
|
||||
static AccountProfile profile(String locale) => AccountProfile(locale);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export 'src/models/wallet.dart';
|
||||
export 'src/providers/wallet.dart';
|
||||
export 'src/providers/transfer.dart';
|
||||
export 'src/services/contracts.dart';
|
||||
export 'src/services/dummy.dart';
|
||||
export 'src/providers/limits.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
name: sxchnge_wallet
|
||||
description: Shared currency accounts, transaction flows and account preferences.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
sxchnge_kyc:
|
||||
path: ../sxchnge_kyc
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late DummyWalletService s;
|
||||
late WalletProvider wallet;
|
||||
late DummyAnalyticsService analytics;
|
||||
setUp(() {
|
||||
now = DateTime(2026, 9, 12, 10);
|
||||
s = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
delay: Duration.zero,
|
||||
now: () => now,
|
||||
);
|
||||
analytics = DummyAnalyticsService();
|
||||
wallet = WalletProvider(s, analytics);
|
||||
});
|
||||
tearDown(() => wallet.dispose());
|
||||
Future<WalletOperation> transfer(
|
||||
TransferKind kind,
|
||||
int amount, {
|
||||
String source = 'eur',
|
||||
String? to,
|
||||
String recipient = 'Sample recipient',
|
||||
String key = 'one',
|
||||
}) async {
|
||||
final q = await s.quote(
|
||||
TransferRequest(
|
||||
kind: kind,
|
||||
source: source,
|
||||
destination: to,
|
||||
amount: amount,
|
||||
recipient: recipient,
|
||||
),
|
||||
);
|
||||
return s.submit(q.id, key);
|
||||
}
|
||||
|
||||
test(
|
||||
'funding credits only on completion; history and available balance agree',
|
||||
() async {
|
||||
final o = await transfer(TransferKind.topUp, 2500);
|
||||
expect(o.status, OperationStatus.pending);
|
||||
expect((await s.fetch()).accounts.first.available, 7500);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
final state = await s.fetch();
|
||||
expect(state.accounts.first.available, 10000);
|
||||
expect(state.operations.single.status, OperationStatus.completed);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'exchange debits total once, then credits target and updates usage',
|
||||
() async {
|
||||
final q = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.exchange,
|
||||
source: 'eur',
|
||||
destination: 'usd',
|
||||
amount: 2000,
|
||||
),
|
||||
);
|
||||
expect(q.debit, 2010);
|
||||
expect(q.credit, 2200);
|
||||
await Future.wait([s.submit(q.id, 'same'), s.submit(q.id, 'same')]);
|
||||
var data = await s.fetch();
|
||||
expect(data.accounts.first.available, 5490);
|
||||
expect(data.accounts[1].available, 0);
|
||||
expect(data.operations.length, 1);
|
||||
var limits = await s.fetchLimits();
|
||||
expect(limits.rows.first.reservedMinor, 2010);
|
||||
expect(limits.rows.first.usedMinor, 0);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
data = await s.fetch();
|
||||
expect(data.accounts[1].available, 2200);
|
||||
limits = await s.fetchLimits();
|
||||
expect(limits.rows.first.usedMinor, 2010);
|
||||
expect(limits.rows.first.reservedMinor, 0);
|
||||
expect(limits.rows.first.remainingMinor, 7990);
|
||||
await expectLater(
|
||||
s.submit(q.id, 'different'),
|
||||
throwsA(isA<WalletException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
test('failed payout restores amount and fee, releases limit usage', () async {
|
||||
s.failNextOperation = true;
|
||||
await transfer(TransferKind.payout, 2000);
|
||||
expect((await s.fetch()).accounts.first.available, 5400);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
final data = await s.fetch();
|
||||
expect(data.accounts.first.available, 7500);
|
||||
expect(data.operations.single.status, OperationStatus.failed);
|
||||
expect((await s.fetchLimits()).rows.first.remainingMinor, 10000);
|
||||
});
|
||||
test('expired quotes and stale balance cannot execute', () async {
|
||||
final q = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 7000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
now = now.add(const Duration(seconds: 61));
|
||||
await expectLater(
|
||||
s.submit(q.id, 'expired'),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.expiredQuote,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect((await s.fetch()).accounts.first.available, 7500);
|
||||
final a = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 6000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
final b = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 6000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
await s.submit(a.id, 'first');
|
||||
await expectLater(
|
||||
s.submit(b.id, 'second'),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.insufficientFunds,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test('limits block a funded account independently of balance', () async {
|
||||
await transfer(TransferKind.topUp, 20000);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
await s.fetch();
|
||||
await expectLater(
|
||||
s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 11000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.limitExceeded,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test('decimal parser accepts locale decimal, rejects grouping and excess precision', () {
|
||||
const a = CurrencyAccount(
|
||||
id: 'eur',
|
||||
currency: 'EUR',
|
||||
precision: 2,
|
||||
available: 0,
|
||||
);
|
||||
expect(a.parse('12,34'), 1234);
|
||||
expect(a.parse('12.3'), 1230);
|
||||
expect(a.parse('1,000.00'), null);
|
||||
expect(a.parse('1.234'), null);
|
||||
expect(a.parse('-2'), null);
|
||||
expect(a.parse('1e5'), null);
|
||||
});
|
||||
test(
|
||||
'preferences are service-owned; failed save preserves stored locale',
|
||||
() async {
|
||||
expect((await s.profile()).locale, 'en');
|
||||
await wallet.saveLocale('ru');
|
||||
expect((await s.profile()).locale, 'ru');
|
||||
s.nextError = WalletError.unavailable;
|
||||
expect(await wallet.saveLocale('en'), false);
|
||||
expect((await s.profile()).locale, 'ru');
|
||||
expect(wallet.saveFailed, true);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'provider reconciles lost response and records settlement once',
|
||||
() async {
|
||||
// Use real-clock quote lifetime for the provider UI expiry check.
|
||||
final service = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
delay: Duration.zero,
|
||||
settlement: Duration.zero,
|
||||
);
|
||||
final p = WalletProvider(service, analytics);
|
||||
addTearDown(p.dispose);
|
||||
await p.refresh();
|
||||
final draft = TransferProvider(p, TransferKind.exchange);
|
||||
addTearDown(draft.dispose);
|
||||
draft.change(amount: '20');
|
||||
await draft.review();
|
||||
service.loseNextResponse = true;
|
||||
await draft.submit();
|
||||
expect(draft.uncertain, true);
|
||||
expect(draft.result, null);
|
||||
await draft.reconcile();
|
||||
expect(draft.uncertain, false);
|
||||
expect(draft.result, isNotNull);
|
||||
await p.refresh();
|
||||
await p.refresh();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(p.data!.operations.length, 1);
|
||||
expect(
|
||||
analytics.events
|
||||
.where((e) => e.step == JourneyStep.settlement && e.terminal)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
analytics.events.every(
|
||||
(e) =>
|
||||
!e.properties.containsKey('amount') &&
|
||||
!e.properties.containsKey('recipient'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'pending operations refresh balances and limits without a detail screen',
|
||||
() async {
|
||||
await wallet.refresh();
|
||||
final limits = WorkspaceLimitsProvider(WalletLimitsService(s), wallet);
|
||||
addTearDown(limits.dispose);
|
||||
await limits.refresh();
|
||||
s.failNextOperation = true;
|
||||
await transfer(TransferKind.payout, 2000);
|
||||
await wallet.refresh();
|
||||
expect(wallet.account('eur')!.available, 5400);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
final done = Completer<void>();
|
||||
void changed() {
|
||||
if (wallet.operation(wallet.data!.operations.single.id)?.status ==
|
||||
OperationStatus.failed &&
|
||||
!done.isCompleted)
|
||||
done.complete();
|
||||
}
|
||||
|
||||
wallet.addListener(changed);
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
wallet.removeListener(changed);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(wallet.account('eur')!.available, 7500);
|
||||
expect(limits.data!.rows.first.remainingMinor, 10000);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'closing a pending operation does not report draft abandonment',
|
||||
() async {
|
||||
final r = JourneyRecorder(analytics);
|
||||
final op = r.begin(
|
||||
JourneyKind.exchange,
|
||||
JourneyStep.settlement,
|
||||
simulated: true,
|
||||
);
|
||||
expect(op, isNotNull);
|
||||
r.dispose(abandonActive: false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
analytics.events.any((e) => e.signal == JourneySignal.abandoned),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user