Add saved payout destination UX and service contracts (#54)

This commit is contained in:
Stephan D committed 2026-10-16 12:01:33 +02:00
1 parent a5689876ff
commit 24af794112
49 files changed
+4687 -62

No files matched your search

@@ -94,10 +94,15 @@ class BackendWalletService implements WalletService {
@override
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
@override
Future<Quote> quote(TransferRequest request) => _mapped(
send('/quote', body: BackendMapper.request(request)),
BackendMapper.quote,
);
Future<Quote> quote(TransferRequest request) async {
if (request.payoutDestination != null)
throw const WalletException(WalletError.unsupported);
return _mapped(
send('/quote', body: BackendMapper.request(request)),
BackendMapper.quote,
);
}
@override
Future<WalletOperation> submit(
String quoteId,
@@ -5,6 +5,9 @@ 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';
@@ -14,13 +17,17 @@ 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',
}) : now = now ?? DateTime.now,
}) : destinations = destinations ?? DummyDestinationService(now: now),
now = now ?? DateTime.now,
_locale = initialLocale;
final IdentityService identity;
final DummyDestinationService destinations;
final _destinationLeases = <String, DestinationLease>{};
final TwoFactorService? factors;
final Duration delay, settlement;
final DateTime Function() now;
@@ -59,6 +66,10 @@ class DummyWalletService implements WalletService {
};
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';
@@ -133,9 +144,19 @@ class DummyWalletService implements WalletService {
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);
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,
@@ -146,19 +167,30 @@ class DummyWalletService implements WalletService {
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
final q = QuoteDTO(
'quote-${++_serial}',
WalletMapper.request(r),
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);
}
@@ -193,6 +225,17 @@ class DummyWalletService implements WalletService {
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;
@@ -205,6 +248,7 @@ class DummyWalletService implements WalletService {
);
failNextOperation = false;
_submissions[idempotencyKey] = o;
_destinationLeases.remove(q.id);
if (loseNextResponse) {
loseNextResponse = false;
throw const WalletException(WalletError.uncertain);