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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user