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