diff --git a/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart index 439df064..034a00a4 100644 --- a/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart +++ b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart @@ -1,23 +1,29 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/money.dart'; +import 'package:pshared/models/payment/asset.dart'; +import 'package:pshared/models/payment/chain_network.dart'; +import 'package:pshared/models/payment/methods/data.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; +import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/quote/status_type.dart'; -import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; import 'package:pweb/models/payment/multiple_payouts/state.dart'; import 'package:pweb/providers/multiple_payouts.dart'; import 'package:pweb/services/payments/csv_input.dart'; - class MultiplePayoutsController extends ChangeNotifier { final CsvInputService _csvInput; MultiplePayoutsProvider? _provider; PaymentSourceController? _sourceController; _PickState _pickState = _PickState.idle; Exception? _uiError; + String? _lastSourceKey; MultiplePayoutsController({required CsvInputService csvInput}) : _csvInput = csvInput; @@ -37,6 +43,7 @@ class MultiplePayoutsController extends ChangeNotifier { _sourceController?.removeListener(_onSourceChanged); _sourceController = sourceController; _sourceController?.addListener(_onSourceChanged); + _lastSourceKey = _currentSourceKey; shouldNotify = true; } if (shouldNotify) { @@ -60,16 +67,16 @@ class MultiplePayoutsController extends ChangeNotifier { _provider?.quoteStatusType ?? QuoteStatusType.missing; Duration? get quoteTimeLeft => _provider?.quoteTimeLeft; - bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null; + bool get canSend => (_provider?.canSend ?? false) && _selectedSource != null; Money? get aggregateDebitAmount => - _provider?.aggregateDebitAmountFor(_selectedWallet); + _provider?.aggregateDebitAmountForCurrency(_selectedSourceCurrencyCode); Money? get requestedSentAmount => _provider?.requestedSentAmount; - Money? get aggregateSettlementAmount => - _provider?.aggregateSettlementAmountFor(_selectedWallet); + Money? get aggregateSettlementAmount => _provider + ?.aggregateSettlementAmountForCurrency(_selectedSourceCurrencyCode); Money? get aggregateFeeAmount => - _provider?.aggregateFeeAmountFor(_selectedWallet); + _provider?.aggregateFeeAmountForCurrency(_selectedSourceCurrencyCode); double? get aggregateFeePercent => - _provider?.aggregateFeePercentFor(_selectedWallet); + _provider?.aggregateFeePercentForCurrency(_selectedSourceCurrencyCode); Future pickAndQuote() async { if (_pickState == _PickState.picking) return; @@ -84,15 +91,16 @@ class MultiplePayoutsController extends ChangeNotifier { try { final picked = await _csvInput.pickCsv(); if (picked == null) return; - final wallet = _selectedWallet; - if (wallet == null) { - _setUiError(StateError('Select source wallet first')); + final source = _selectedSource; + if (source == null) { + _setUiError(StateError('Select source of funds first')); return; } await provider.quoteFromCsv( fileName: picked.name, content: picked.content, - sourceWallet: wallet, + sourceMethod: source.method, + sourceCurrencyCode: source.currencyCode, ); } catch (e) { _setUiError(e); @@ -131,10 +139,78 @@ class MultiplePayoutsController extends ChangeNotifier { } void _onSourceChanged() { + final currentSourceKey = _currentSourceKey; + final sourceChanged = currentSourceKey != _lastSourceKey; + _lastSourceKey = currentSourceKey; + if (sourceChanged) { + unawaited(_requoteWithUploadedRows()); + } notifyListeners(); } - Wallet? get _selectedWallet => _sourceController?.selectedWallet; + String? get _selectedSourceCurrencyCode => + _sourceController?.selectedCurrencyCode; + String? get _currentSourceKey { + final source = _sourceController; + if (source == null || + source.selectedType == null || + source.selectedRef == null) { + return null; + } + return '${source.selectedType!.name}:${source.selectedRef!}'; + } + + ({PaymentMethodData method, String currencyCode})? get _selectedSource { + final source = _sourceController; + if (source == null) return null; + + final currencyCode = source.selectedCurrencyCode; + if (currencyCode == null || currencyCode.isEmpty) return null; + + final wallet = source.selectedWallet; + if (wallet != null) { + final hasAsset = (wallet.tokenSymbol ?? '').isNotEmpty; + final asset = hasAsset + ? PaymentAsset( + chain: wallet.network ?? ChainNetwork.unspecified, + tokenSymbol: wallet.tokenSymbol!, + contractAddress: wallet.contractAddress, + ) + : null; + return ( + method: ManagedWalletPaymentMethod( + managedWalletRef: wallet.id, + asset: asset, + ), + currencyCode: currencyCode, + ); + } + + final ledger = source.selectedLedgerAccount; + if (ledger != null) { + return ( + method: LedgerPaymentMethod(ledgerAccountRef: ledger.ledgerAccountRef), + currencyCode: currencyCode, + ); + } + + return null; + } + + Future _requoteWithUploadedRows() async { + final provider = _provider; + if (provider == null) return; + if (provider.selectedFileName == null || provider.rows.isEmpty) return; + + final source = _selectedSource; + if (source == null) return; + + _clearUiError(notify: false); + await provider.requoteUploadedRows( + sourceMethod: source.method, + sourceCurrencyCode: source.currencyCode, + ); + } void _setUiError(Object error) { _uiError = error is Exception ? error : Exception(error.toString()); diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart index 1463a1ec..47b3388c 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart @@ -5,6 +5,8 @@ import 'package:pshared/utils/money.dart'; import 'package:pweb/controllers/payouts/multiple_payouts.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + String moneyLabel(Money? money) { if (money == null) return 'N/A'; @@ -12,10 +14,7 @@ String moneyLabel(Money? money) { if (amount.isNaN) return '${money.amount} ${money.currency}'; try { return assetToString( - Asset( - currency: currencyStringToCode(money.currency), - amount: amount, - ), + Asset(currency: currencyStringToCode(money.currency), amount: amount), ); } catch (_) { return '${money.amount} ${money.currency}'; @@ -31,6 +30,8 @@ String sentAmountLabel(MultiplePayoutsController controller) { return moneyLabel(requested); } -String feeLabel(MultiplePayoutsController controller) { - return moneyLabel(controller.aggregateFeeAmount); +String feeLabel(MultiplePayoutsController controller, AppLocalizations l10n) { + final fee = controller.aggregateFeeAmount; + if (fee == null) return l10n.noFee; + return moneyLabel(fee); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart index 5a678ad4..af9bdc6b 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart @@ -4,7 +4,7 @@ import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/models/dashboard/summary_values.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart'; import 'package:pweb/pages/dashboard/payouts/summary/widget.dart'; - +import 'package:pweb/generated/i18n/app_localizations.dart'; class SourceQuoteSummary extends StatelessWidget { const SourceQuoteSummary({ @@ -21,7 +21,7 @@ class SourceQuoteSummary extends StatelessWidget { return PaymentSummary( spacing: spacing, values: PaymentSummaryValues( - fee: feeLabel(controller), + fee: feeLabel(controller, AppLocalizations.of(context)!), recipientReceives: moneyLabel(controller.aggregateSettlementAmount), total: moneyLabel(controller.aggregateDebitAmount), ), diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index b497b8f6..d82b8568 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -4,7 +4,7 @@ import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/status_type.dart'; -import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/provider/payment/multiple/provider.dart'; import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/utils/currency.dart'; @@ -76,12 +76,12 @@ class MultiplePayoutsProvider extends ChangeNotifier { return quoteRef != null && quoteRef.isNotEmpty; } - Money? aggregateDebitAmountFor(Wallet? sourceWallet) { + Money? aggregateDebitAmountForCurrency(String? sourceCurrencyCode) { if (_rows.isEmpty) return null; final totals = aggregateMoneyByCurrency( _quoteItems().map((quote) => quote.amounts?.sourceDebitTotal), ); - return _moneyForSourceCurrency(totals, sourceWallet); + return _moneyForSourceCurrency(totals, sourceCurrencyCode); } Money? get requestedSentAmount { @@ -97,23 +97,23 @@ class MultiplePayoutsProvider extends ChangeNotifier { return Money(amount: amountToString(total), currency: currency); } - Money? aggregateSettlementAmountFor(Wallet? sourceWallet) { + Money? aggregateSettlementAmountForCurrency(String? sourceCurrencyCode) { if (_rows.isEmpty) return null; final totals = aggregateMoneyByCurrency( _quoteItems().map((quote) => quote.amounts?.destinationSettlement), ); - return _moneyForSourceCurrency(totals, sourceWallet); + return _moneyForSourceCurrency(totals, sourceCurrencyCode); } - Money? aggregateFeeAmountFor(Wallet? sourceWallet) { + Money? aggregateFeeAmountForCurrency(String? sourceCurrencyCode) { if (_rows.isEmpty) return null; final totals = aggregateMoneyByCurrency(_quoteItems().map(quoteFeeTotal)); - return _moneyForSourceCurrency(totals, sourceWallet); + return _moneyForSourceCurrency(totals, sourceCurrencyCode); } - double? aggregateFeePercentFor(Wallet? sourceWallet) { - final debit = aggregateDebitAmountFor(sourceWallet); - final fee = aggregateFeeAmountFor(sourceWallet); + double? aggregateFeePercentForCurrency(String? sourceCurrencyCode) { + final debit = aggregateDebitAmountForCurrency(sourceCurrencyCode); + final fee = aggregateFeeAmountForCurrency(sourceCurrencyCode); if (debit == null || fee == null) return null; final debitValue = parseMoneyAmount(debit.amount, fallback: double.nan); @@ -126,7 +126,8 @@ class MultiplePayoutsProvider extends ChangeNotifier { Future quoteFromCsv({ required String fileName, required String content, - required Wallet sourceWallet, + required PaymentMethodData sourceMethod, + required String sourceCurrencyCode, }) async { if (isBusy) return; @@ -144,18 +145,43 @@ class MultiplePayoutsProvider extends ChangeNotifier { _sentCount = 0; final rows = _csvParser.parseRows(content); - final intents = _intentBuilder.buildIntents(sourceWallet, rows); + await _quoteRows( + quotation: quotation, + fileName: fileName, + rows: rows, + sourceMethod: sourceMethod, + sourceCurrencyCode: sourceCurrencyCode, + ); - _selectedFileName = fileName; - _rows = rows; + if (quotation.error != null) { + _setErrorObject(quotation.error!); + } + } catch (e) { + _setErrorObject(e); + } finally { + _setState(MultiplePayoutsState.idle); + } + } - await quotation.quotePayments( - intents, - metadata: { - 'upload_filename': fileName, - 'upload_rows': rows.length.toString(), - ...?_uploadAmountMetadata(), - }, + Future requoteUploadedRows({ + required PaymentMethodData sourceMethod, + required String sourceCurrencyCode, + }) async { + if (isBusy || _rows.isEmpty || _selectedFileName == null) return; + final quotation = _quotation; + if (quotation == null) return; + + try { + _setState(MultiplePayoutsState.quoting); + _error = null; + _sentCount = 0; + + await _quoteRows( + quotation: quotation, + fileName: _selectedFileName!, + rows: _rows, + sourceMethod: sourceMethod, + sourceCurrencyCode: sourceCurrencyCode, ); if (quotation.error != null) { @@ -254,13 +280,16 @@ class MultiplePayoutsProvider extends ChangeNotifier { }; } - Money? _moneyForSourceCurrency(List? values, Wallet? sourceWallet) { + Money? _moneyForSourceCurrency( + List? values, + String? sourceCurrencyCode, + ) { if (values == null || values.isEmpty) return null; - if (sourceWallet != null) { - final sourceCurrency = currencyCodeToString(sourceWallet.currency); + if (sourceCurrencyCode != null && sourceCurrencyCode.isNotEmpty) { + final sourceCurrency = sourceCurrencyCode.trim().toUpperCase(); for (final value in values) { - if (value.currency.toUpperCase() == sourceCurrency.toUpperCase()) { + if (value.currency.toUpperCase() == sourceCurrency) { return value; } } @@ -272,6 +301,32 @@ class MultiplePayoutsProvider extends ChangeNotifier { List _quoteItems() => _quotation?.quotation?.items ?? const []; + Future _quoteRows({ + required MultiQuotationProvider quotation, + required String fileName, + required List rows, + required PaymentMethodData sourceMethod, + required String sourceCurrencyCode, + }) async { + final intents = _intentBuilder.buildIntents( + sourceMethod: sourceMethod, + sourceCurrency: sourceCurrencyCode, + rows: rows, + ); + + _selectedFileName = fileName; + _rows = rows; + + await quotation.quotePayments( + intents, + metadata: { + 'upload_filename': fileName, + 'upload_rows': rows.length.toString(), + ...?_uploadAmountMetadata(), + }, + ); + } + @override void dispose() { _quotation?.removeListener(_onQuotationChanged); diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart index 8efb30bf..3bba0d20 100644 --- a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -1,14 +1,10 @@ import 'package:pshared/models/money.dart'; -import 'package:pshared/models/payment/asset.dart'; -import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/fees/treatment.dart'; import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; -import 'package:pshared/models/payment/methods/managed_wallet.dart'; +import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; -import 'package:pshared/models/payment/wallet.dart'; -import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/payment/fx_helpers.dart'; import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; @@ -16,19 +12,11 @@ import 'package:pweb/models/payment/multiple_payouts/csv_row.dart'; class MultipleIntentBuilder { static const String _currency = 'RUB'; - List buildIntents( - Wallet sourceWallet, - List rows, - ) { - final sourceCurrency = currencyCodeToString(sourceWallet.currency); - final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty; - final sourceAsset = hasAsset - ? PaymentAsset( - chain: sourceWallet.network ?? ChainNetwork.unspecified, - tokenSymbol: sourceWallet.tokenSymbol!, - contractAddress: sourceWallet.contractAddress, - ) - : null; + List buildIntents({ + required PaymentMethodData sourceMethod, + required String sourceCurrency, + required List rows, + }) { final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( baseCurrency: sourceCurrency, quoteCurrency: _currency, @@ -39,10 +27,7 @@ class MultipleIntentBuilder { final amount = Money(amount: row.amount, currency: _currency); return PaymentIntent( kind: PaymentKind.payout, - source: ManagedWalletPaymentMethod( - managedWalletRef: sourceWallet.id, - asset: sourceAsset, - ), + source: sourceMethod, destination: CardPaymentMethod( pan: row.pan, firstName: row.firstName,