multiple payout page and small fixes
This commit is contained in:
@@ -10,12 +10,13 @@ part 'login_pending.g.dart';
|
||||
class PendingLoginResponse {
|
||||
final AccountResponse account;
|
||||
final TokenData pendingToken;
|
||||
final String destination;
|
||||
@JsonKey(name: 'destination')
|
||||
final String target;
|
||||
|
||||
const PendingLoginResponse({
|
||||
required this.account,
|
||||
required this.pendingToken,
|
||||
required this.destination,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
factory PendingLoginResponse.fromJson(Map<String, dynamic> json) => _$PendingLoginResponseFromJson(json);
|
||||
|
||||
@@ -9,6 +9,7 @@ part 'payments.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class PaymentsResponse extends CursorPageResponse {
|
||||
@JsonKey(defaultValue: <PaymentDTO>[])
|
||||
final List<PaymentDTO> payments;
|
||||
|
||||
const PaymentsResponse({
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:pshared/models/session_identifier.dart';
|
||||
class PendingLogin {
|
||||
final Account account;
|
||||
final TokenData pendingToken;
|
||||
final String destination;
|
||||
final String target;
|
||||
final SessionIdentifier session;
|
||||
|
||||
final int? ttlSeconds;
|
||||
@@ -19,7 +19,7 @@ class PendingLogin {
|
||||
const PendingLogin({
|
||||
required this.account,
|
||||
required this.pendingToken,
|
||||
required this.destination,
|
||||
required this.target,
|
||||
this.ttlSeconds,
|
||||
required this.session,
|
||||
this.cooldownSeconds,
|
||||
@@ -33,14 +33,14 @@ class PendingLogin {
|
||||
}) => PendingLogin(
|
||||
account: response.account.account.toDomain(),
|
||||
pendingToken: response.pendingToken,
|
||||
destination: response.destination,
|
||||
target: response.target,
|
||||
session: session,
|
||||
);
|
||||
|
||||
PendingLogin copyWith({
|
||||
Account? account,
|
||||
TokenData? pendingToken,
|
||||
String? destination,
|
||||
String? target,
|
||||
int? ttlSeconds,
|
||||
SessionIdentifier? session,
|
||||
int? cooldownSeconds,
|
||||
@@ -51,7 +51,7 @@ class PendingLogin {
|
||||
return PendingLogin(
|
||||
account: account ?? this.account,
|
||||
pendingToken: pendingToken ?? this.pendingToken,
|
||||
destination: destination ?? this.destination,
|
||||
target: target ?? this.target,
|
||||
ttlSeconds: ttlSeconds ?? this.cooldownSeconds,
|
||||
session: session ?? this.session,
|
||||
cooldownSeconds: clearCooldown ? null : cooldownSeconds ?? this.cooldownSeconds,
|
||||
|
||||
@@ -120,12 +120,12 @@ class AccountProvider extends ChangeNotifier {
|
||||
VerificationResponse confirmation,
|
||||
) {
|
||||
final ttlSeconds = confirmation.ttlSeconds != 0 ? confirmation.ttlSeconds : pending.ttlSeconds;
|
||||
final destination = confirmation.target.isNotEmpty ? confirmation.target : pending.destination;
|
||||
final target = confirmation.target.isNotEmpty ? confirmation.target : pending.target;
|
||||
final cooldownSeconds = confirmation.cooldownSeconds;
|
||||
|
||||
return pending.copyWith(
|
||||
ttlSeconds: ttlSeconds,
|
||||
destination: destination,
|
||||
target: target,
|
||||
cooldownSeconds: cooldownSeconds > 0 ? cooldownSeconds : null,
|
||||
cooldownUntil: cooldownSeconds > 0 ? DateTime.now().add(confirmation.cooldownDuration) : null,
|
||||
clearCooldown: cooldownSeconds <= 0,
|
||||
|
||||
@@ -13,11 +13,21 @@ class EmailVerificationProvider extends ChangeNotifier {
|
||||
bool get isLoading => _resource.isLoading;
|
||||
bool get isSuccess => _resource.data == true;
|
||||
Exception? get error => _resource.error;
|
||||
int? get errorCode => _resource.error is ErrorResponse
|
||||
? (_resource.error as ErrorResponse).code
|
||||
: null;
|
||||
bool get canResendVerification =>
|
||||
errorCode == 400 || errorCode == 410 || errorCode == 500;
|
||||
ErrorResponse? get errorResponse =>
|
||||
_resource.error is ErrorResponse ? _resource.error as ErrorResponse : null;
|
||||
bool get canResendVerification {
|
||||
final err = errorResponse;
|
||||
if (err == null) return false;
|
||||
switch (err.error) {
|
||||
case 'not_found':
|
||||
case 'token_expired':
|
||||
case 'data_conflict':
|
||||
case 'internal_error':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verify(String token) async {
|
||||
final trimmed = token.trim();
|
||||
@@ -38,10 +48,6 @@ class EmailVerificationProvider extends ChangeNotifier {
|
||||
await AccountService.verifyEmail(trimmed);
|
||||
_setResource(Resource(data: true, isLoading: false));
|
||||
} catch (e) {
|
||||
if (e is ErrorResponse && e.code == 404) {
|
||||
_setResource(Resource(data: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
_setResource(
|
||||
Resource(data: null, isLoading: false, error: toException(e)),
|
||||
);
|
||||
|
||||
@@ -12,14 +12,13 @@ class MultiPaymentProvider extends ChangeNotifier {
|
||||
late OrganizationsProvider _organization;
|
||||
late MultiQuotationProvider _quotation;
|
||||
|
||||
Resource<List<Payment>> _payments = Resource(data: []);
|
||||
bool _isLoaded = false;
|
||||
Resource<List<Payment>> _payments = Resource(data: null);
|
||||
|
||||
List<Payment> get payments => _payments.data ?? [];
|
||||
bool get isLoading => _payments.isLoading;
|
||||
Exception? get error => _payments.error;
|
||||
bool get isReady =>
|
||||
_isLoaded && !_payments.isLoading && _payments.error == null;
|
||||
_payments.data != null && !_payments.isLoading && _payments.error == null;
|
||||
|
||||
void update(
|
||||
OrganizationsProvider organization,
|
||||
@@ -56,7 +55,6 @@ class MultiPaymentProvider extends ChangeNotifier {
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
_isLoaded = true;
|
||||
_setResource(
|
||||
_payments.copyWith(data: response, isLoading: false, error: null),
|
||||
);
|
||||
@@ -70,8 +68,7 @@ class MultiPaymentProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_isLoaded = false;
|
||||
_setResource(Resource(data: []));
|
||||
_setResource(Resource(data: null));
|
||||
}
|
||||
|
||||
void _setResource(Resource<List<Payment>> payments) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -17,11 +19,13 @@ class MultiQuotationProvider extends ChangeNotifier {
|
||||
String? _loadedOrganizationRef;
|
||||
|
||||
Resource<PaymentQuotes> _quotation = Resource(data: null);
|
||||
bool _isLoaded = false;
|
||||
|
||||
List<PaymentIntent>? _lastIntents;
|
||||
bool _lastPreviewOnly = false;
|
||||
Map<String, String>? _lastMetadata;
|
||||
Timer? _autoRefreshTimer;
|
||||
|
||||
static const Duration _autoRefreshLead = Duration(seconds: 5);
|
||||
|
||||
Resource<PaymentQuotes> get resource => _quotation;
|
||||
PaymentQuotes? get quotation => _quotation.data;
|
||||
@@ -29,7 +33,7 @@ class MultiQuotationProvider extends ChangeNotifier {
|
||||
Exception? get error => _quotation.error;
|
||||
bool get canRefresh => _lastIntents != null && _lastIntents!.isNotEmpty;
|
||||
bool get isReady =>
|
||||
_isLoaded && !_quotation.isLoading && _quotation.error == null;
|
||||
quotation != null && !_quotation.isLoading && _quotation.error == null;
|
||||
|
||||
DateTime? get quoteExpiresAt {
|
||||
final quotes = quotation?.quotes;
|
||||
@@ -82,6 +86,7 @@ class MultiQuotationProvider extends ChangeNotifier {
|
||||
? null
|
||||
: Map<String, String>.from(metadata);
|
||||
|
||||
_cancelAutoRefresh();
|
||||
_setResource(_quotation.copyWith(isLoading: true, error: null));
|
||||
try {
|
||||
final response = await MultiplePaymentsService.getQuotation(
|
||||
@@ -94,10 +99,10 @@ class MultiQuotationProvider extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
|
||||
_isLoaded = true;
|
||||
_setResource(
|
||||
_quotation.copyWith(data: response, isLoading: false, error: null),
|
||||
);
|
||||
_scheduleAutoRefresh();
|
||||
} catch (e) {
|
||||
_setResource(
|
||||
_quotation.copyWith(
|
||||
@@ -123,10 +128,10 @@ class MultiQuotationProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_isLoaded = false;
|
||||
_lastIntents = null;
|
||||
_lastPreviewOnly = false;
|
||||
_lastMetadata = null;
|
||||
_cancelAutoRefresh();
|
||||
_quotation = Resource(data: null);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -135,4 +140,37 @@ class MultiQuotationProvider extends ChangeNotifier {
|
||||
_quotation = quotation;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _scheduleAutoRefresh() {
|
||||
_autoRefreshTimer?.cancel();
|
||||
final expiresAt = quoteExpiresAt;
|
||||
if (expiresAt == null) return;
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
var delay = expiresAt.difference(now) - _autoRefreshLead;
|
||||
if (delay.isNegative) delay = Duration.zero;
|
||||
_autoRefreshTimer = Timer(delay, _triggerAutoRefresh);
|
||||
}
|
||||
|
||||
Future<void> _triggerAutoRefresh() async {
|
||||
if (_quotation.isLoading) return;
|
||||
final intents = _lastIntents;
|
||||
if (intents == null || intents.isEmpty) return;
|
||||
await quotePayments(
|
||||
intents,
|
||||
previewOnly: _lastPreviewOnly,
|
||||
metadata: _lastMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelAutoRefresh() {
|
||||
_autoRefreshTimer?.cancel();
|
||||
_autoRefreshTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelAutoRefresh();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/models/payment/asset.dart';
|
||||
import 'package:pshared/models/payment/chain_network.dart';
|
||||
import 'package:pshared/models/payment/currency_pair.dart';
|
||||
import 'package:pshared/models/payment/customer.dart';
|
||||
import 'package:pshared/models/payment/fx/intent.dart';
|
||||
import 'package:pshared/models/payment/fx/side.dart';
|
||||
import 'package:pshared/models/payment/kind.dart';
|
||||
import 'package:pshared/models/payment/methods/card.dart';
|
||||
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
||||
@@ -21,6 +18,7 @@ import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/provider/payment/flow.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/payment/fx_helpers.dart';
|
||||
|
||||
|
||||
class QuotationIntentBuilder {
|
||||
@@ -40,22 +38,19 @@ class QuotationIntentBuilder {
|
||||
method: selectedMethod,
|
||||
data: paymentData,
|
||||
);
|
||||
final sourceCurrency = currencyCodeToString(selectedWallet.currency);
|
||||
final amount = Money(
|
||||
amount: payment.amount.toString(),
|
||||
// TODO: adapt to possible other sources
|
||||
currency: currencyCodeToString(selectedWallet.currency),
|
||||
currency: sourceCurrency,
|
||||
);
|
||||
final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod &&
|
||||
(paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency;
|
||||
final fxIntent = isCryptoToCrypto
|
||||
? null
|
||||
: FxIntent(
|
||||
pair: CurrencyPair(
|
||||
base: currencyCodeToString(selectedWallet.currency),
|
||||
quote: 'RUB', // TODO: exentd target currencies
|
||||
),
|
||||
side: FxSide.sellBaseBuyQuote,
|
||||
);
|
||||
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
|
||||
baseCurrency: sourceCurrency,
|
||||
quoteCurrency: 'RUB', // TODO: exentd target currencies
|
||||
enabled: !isCryptoToCrypto,
|
||||
);
|
||||
return PaymentIntent(
|
||||
kind: PaymentKind.payout,
|
||||
amount: amount,
|
||||
@@ -69,35 +64,14 @@ class QuotationIntentBuilder {
|
||||
),
|
||||
fx: fxIntent,
|
||||
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
|
||||
settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent),
|
||||
settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
|
||||
amount: amount,
|
||||
fx: fxIntent,
|
||||
),
|
||||
customer: customer,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveSettlementCurrency({
|
||||
required Money amount,
|
||||
required FxIntent? fx,
|
||||
}) {
|
||||
final pair = fx?.pair;
|
||||
if (pair != null) {
|
||||
switch (fx?.side ?? FxSide.unspecified) {
|
||||
case FxSide.buyBaseSellQuote:
|
||||
if (pair.base.isNotEmpty) return pair.base;
|
||||
break;
|
||||
case FxSide.sellBaseBuyQuote:
|
||||
if (pair.quote.isNotEmpty) return pair.quote;
|
||||
break;
|
||||
case FxSide.unspecified:
|
||||
break;
|
||||
}
|
||||
if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote;
|
||||
if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base;
|
||||
if (pair.quote.isNotEmpty) return pair.quote;
|
||||
if (pair.base.isNotEmpty) return pair.base;
|
||||
}
|
||||
return amount.currency;
|
||||
}
|
||||
|
||||
Customer? _buildCustomer({
|
||||
required Recipient? recipient,
|
||||
required PaymentMethod? method,
|
||||
|
||||
@@ -32,13 +32,13 @@ class VerificationService {
|
||||
return VerificationResponse.fromJson(response);
|
||||
}
|
||||
|
||||
static Future<VerificationResponse> resendLoginCode(PendingLogin pending, {String? destination}) async {
|
||||
static Future<VerificationResponse> resendLoginCode(PendingLogin pending, {String? target}) async {
|
||||
_logger.fine('Resending login confirmation code');
|
||||
final response = await getPOSTResponse(
|
||||
_objectType,
|
||||
'/resend',
|
||||
LoginVerificationRequest(
|
||||
target: destination,
|
||||
target: target,
|
||||
idempotencyKey: pending.idempotencyKey ?? Uuid().v4(),
|
||||
).toJson(),
|
||||
authToken: pending.pendingToken.token,
|
||||
|
||||
47
frontend/pshared/lib/utils/payment/fx_helpers.dart
Normal file
47
frontend/pshared/lib/utils/payment/fx_helpers.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:pshared/models/payment/currency_pair.dart';
|
||||
import 'package:pshared/models/payment/fx/intent.dart';
|
||||
import 'package:pshared/models/payment/fx/side.dart';
|
||||
import 'package:pshared/models/money.dart';
|
||||
|
||||
|
||||
class FxIntentHelper {
|
||||
static FxIntent? buildSellBaseBuyQuote({
|
||||
required String baseCurrency,
|
||||
required String quoteCurrency,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
if (!enabled) return null;
|
||||
final base = baseCurrency.trim();
|
||||
final quote = quoteCurrency.trim();
|
||||
if (base.isEmpty || quote.isEmpty) return null;
|
||||
if (base.toUpperCase() == quote.toUpperCase()) return null;
|
||||
return FxIntent(
|
||||
pair: CurrencyPair(base: base, quote: quote),
|
||||
side: FxSide.sellBaseBuyQuote,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveSettlementCurrency({
|
||||
required Money amount,
|
||||
FxIntent? fx,
|
||||
}) {
|
||||
final pair = fx?.pair;
|
||||
if (pair != null) {
|
||||
switch (fx?.side ?? FxSide.unspecified) {
|
||||
case FxSide.buyBaseSellQuote:
|
||||
if (pair.base.isNotEmpty) return pair.base;
|
||||
break;
|
||||
case FxSide.sellBaseBuyQuote:
|
||||
if (pair.quote.isNotEmpty) return pair.quote;
|
||||
break;
|
||||
case FxSide.unspecified:
|
||||
break;
|
||||
}
|
||||
if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote;
|
||||
if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base;
|
||||
if (pair.quote.isNotEmpty) return pair.quote;
|
||||
if (pair.base.isNotEmpty) return pair.base;
|
||||
}
|
||||
return amount.currency;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,17 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserX(ScaffoldMessengerState sm, String message, { int delaySeconds = 3 })
|
||||
{
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUserX(
|
||||
ScaffoldMessengerState sm,
|
||||
String message, {
|
||||
int delaySeconds = 3,
|
||||
}) {
|
||||
final durationSeconds = _normalizeDelaySeconds(delaySeconds);
|
||||
sm.clearSnackBars();
|
||||
return sm.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: Duration(seconds: delaySeconds),
|
||||
duration: Duration(seconds: durationSeconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -18,8 +23,10 @@ ScaffoldFeatureController<SnackBar, SnackBarClosedReason> notifyUser(BuildContex
|
||||
}
|
||||
|
||||
Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser(
|
||||
BuildContext context, String message, {int delaySeconds = 3}) {
|
||||
|
||||
BuildContext context,
|
||||
String message, {
|
||||
int delaySeconds = 3,
|
||||
}) {
|
||||
final completer = Completer<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -29,3 +36,6 @@ Future<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> postNotifyUser
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
int _normalizeDelaySeconds(int delaySeconds) =>
|
||||
delaySeconds <= 0 ? 3 : delaySeconds;
|
||||
|
||||
Reference in New Issue
Block a user