import 'dart:math'; import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_kyc/limits_transport.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import '../destinations/dummy.dart'; import '../destinations/models.dart'; import '../destinations/transport.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.factors, DummyDestinationService? destinations, this.delay = const Duration(milliseconds: 350), this.settlement = const Duration(seconds: 2), DateTime Function()? now, String initialLocale = 'en', }) : destinations = destinations ?? DummyDestinationService(now: now), now = now ?? DateTime.now, _locale = initialLocale; final IdentityService identity; final DummyDestinationService destinations; final _destinationLeases = {}; final TwoFactorService? factors; 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 _quotes = {}; final _usedQuotes = {}; final Map _submissions = {}; String _locale; int _serial = 0; WalletError? nextError; bool failNextOperation = false; bool loseNextResponse = false; @override bool get isSimulated => true; Future _wait() async { await Future.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() { _quotes.removeWhere((_, quote) => !now().isBefore(quote.expires)); _destinationLeases.removeWhere( (_, lease) => !now().isBefore(lease.expiresAt), ); 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 _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 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(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); DestinationLease? lease; if (r.kind == TransferKind.payout) { if (r.payoutDestination == null) throw const WalletException(WalletError.invalidRecipient); try { lease = await destinations.prepare( r.payoutDestination!, PayoutContext(r.source, amount: r.amount), ); } on DestinationException { throw const WalletException(WalletError.destinationChanged); } } 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, includePayoutInput: false), fee, r.kind == TransferKind.topUp ? r.amount : r.amount + fee, r.amount * n ~/ d, now().add(const Duration(seconds: 60)), n, d, payoutTerms: lease == null ? null : PayoutTermsDTO(r.amount, 2, switch (lease.snapshot) { CryptoDestination d => d.assetLabel, RussianBankDestination() => 'RUB', CardDestination() => _account(r.source).currency, }, settlement.inSeconds), payoutSnapshot: lease == null ? null : DestinationMapper.details(lease.snapshot), ); if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount); final verified = await _verified(); _settle(); _check(q, verified); _quotes[q.id] = q; if (lease != null) _destinationLeases[q.id] = lease; return WalletMapper.quote(q); } @override Future submit( String quoteId, String idempotencyKey, { String? approval, }) 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(); try { await factors?.authorize( FactorPurpose.values.byName(q.request.kind), q.id, approval, ); } catch (_) { final accepted = _submissions[idempotencyKey]; if (accepted != null) return WalletMapper.operation(accepted); rethrow; } _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); final lease = _destinationLeases[q.id]; if (q.request.kind == 'payout' && lease == null) throw const WalletException(WalletError.expiredQuote); if (lease != null) { try { destinations.accept(lease); } on DestinationException { throw const WalletException(WalletError.destinationChanged); } } // No asynchronous gap between destination acceptance and financial mutation. _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; _destinationLeases.remove(q.id); if (loseNextResponse) { loseNextResponse = false; throw const WalletException(WalletError.uncertain); } return WalletMapper.operation(o); } @override Future findSubmission(String key) async { await _wait(); _settle(); final o = _submissions[key]; return o == null ? null : WalletMapper.operation(o); } @override Future profile() async { await _wait(); return WalletMapper.profile(ProfileDTO(_locale)); } @override Future saveLocale(String locale) async { await _wait(); if (!['en', 'ru'].contains(locale)) throw const WalletException(WalletError.unsupported); _locale = locale; return WalletMapper.profile(ProfileDTO(_locale)); } // Kept separate from wallet fetch because LimitsService has a different result. Future fetchLimits() async { await _wait(); final verified = await _verified(); _settle(); final rows = []; 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 fetch() => wallet.fetchLimits(); }