Build exchange workspace and shared transaction services (#28)

This commit is contained in:
Stephan D committed 2026-09-12 12:01:36 +02:00
1 parent 52b7135eda
commit c496da8a55
48 files changed
+4757 -120

No files matched your search

@@ -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,
);
},
);
}