260 lines
8.2 KiB
Dart
260 lines
8.2 KiB
Dart
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();
|
|
}
|