diff --git a/frontend/pshared/lib/api/responses/payment/quotation.dart b/frontend/pshared/lib/api/responses/payment/quotation.dart index 3bee1df2..b1f49031 100644 --- a/frontend/pshared/lib/api/responses/payment/quotation.dart +++ b/frontend/pshared/lib/api/responses/payment/quotation.dart @@ -11,11 +11,15 @@ part 'quotation.g.dart'; class PaymentQuoteResponse extends BaseAuthorizedResponse { final PaymentQuoteDTO quote; - final String idempotencyKey; + final String? idempotencyKey; - const PaymentQuoteResponse({required super.accessToken, required this.idempotencyKey, required this.quote}); + const PaymentQuoteResponse({ + required super.accessToken, + required this.quote, + this.idempotencyKey, + }); factory PaymentQuoteResponse.fromJson(Map json) => _$PaymentQuoteResponseFromJson(json); @override Map toJson() => _$PaymentQuoteResponseToJson(this); -} \ No newline at end of file +} diff --git a/frontend/pshared/lib/data/dto/payment/quotes.dart b/frontend/pshared/lib/data/dto/payment/quotes.dart index 9f8ce8ce..6a9174ad 100644 --- a/frontend/pshared/lib/data/dto/payment/quotes.dart +++ b/frontend/pshared/lib/data/dto/payment/quotes.dart @@ -9,7 +9,6 @@ part 'quotes.g.dart'; @JsonSerializable() class PaymentQuotesDTO { final String quoteRef; - final String idempotencyKey; final PaymentQuoteAggregateDTO? aggregate; final List? quotes; @@ -17,7 +16,6 @@ class PaymentQuotesDTO { required this.quoteRef, this.aggregate, this.quotes, - required this.idempotencyKey, }); factory PaymentQuotesDTO.fromJson(Map json) => _$PaymentQuotesDTOFromJson(json); diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart index d7c136b6..1b1a4f20 100644 --- a/frontend/pshared/lib/data/mapper/payment/enums.dart +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -160,16 +160,20 @@ String insufficientNetPolicyToValue(InsufficientNetPolicy policy) { PaymentType endpointTypeFromValue(String? value) { switch (value) { case 'managedWallet': + case 'managed_wallet': return PaymentType.managedWallet; case 'externalChain': + case 'external_chain': return PaymentType.externalChain; case 'card': return PaymentType.card; case 'cardToken': + case 'card_token': return PaymentType.cardToken; case 'ledger': return PaymentType.ledger; case 'bankAccount': + case 'bank_account': return PaymentType.bankAccount; case 'iban': return PaymentType.iban; @@ -185,15 +189,15 @@ String endpointTypeToValue(PaymentType type) { case PaymentType.ledger: return 'ledger'; case PaymentType.managedWallet: - return 'managedWallet'; + return 'managed_wallet'; case PaymentType.externalChain: - return 'externalChain'; + return 'external_chain'; case PaymentType.card: return 'card'; case PaymentType.cardToken: - return 'cardToken'; + return 'card'; case PaymentType.bankAccount: - return 'bankAccount'; + return 'bank_account'; case PaymentType.iban: return 'iban'; case PaymentType.wallet: diff --git a/frontend/pshared/lib/data/mapper/payment/payment.dart b/frontend/pshared/lib/data/mapper/payment/payment.dart index 6c36078f..39b6483d 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment.dart @@ -5,6 +5,7 @@ import 'package:pshared/data/dto/payment/external_chain.dart'; import 'package:pshared/data/dto/payment/ledger.dart'; import 'package:pshared/data/dto/payment/managed_wallet.dart'; import 'package:pshared/data/mapper/payment/asset.dart'; +import 'package:pshared/data/mapper/payment/enums.dart'; import 'package:pshared/data/mapper/payment/type.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/card_token.dart'; @@ -23,7 +24,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.ledger: final payload = this as LedgerPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: LedgerEndpointDTO( ledgerAccountRef: payload.ledgerAccountRef, contraLedgerAccountRef: payload.contraLedgerAccountRef, @@ -33,7 +34,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.managedWallet: final payload = this as ManagedWalletPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: ManagedWalletEndpointDTO( managedWalletRef: payload.managedWalletRef, asset: payload.asset?.toDTO(), @@ -43,7 +44,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.externalChain: final payload = this as CryptoAddressPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: ExternalChainEndpointDTO( asset: payload.asset?.toDTO(), address: payload.address, @@ -54,7 +55,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.card: final payload = this as CardPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: CardEndpointDTO( pan: payload.pan, expMonth: payload.expMonth, @@ -68,7 +69,7 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { case PaymentType.cardToken: final payload = this as CardTokenPaymentMethod; return PaymentEndpointDTO( - type: paymentTypeToValue(type), + type: endpointTypeToValue(type), data: CardTokenEndpointDTO( token: payload.token, maskedPan: payload.maskedPan, @@ -85,7 +86,7 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO { PaymentMethodData toDomain() { final metadata = this.metadata; - switch (paymentTypeFromValue(type)) { + switch (_resolveEndpointType(type, data)) { case PaymentType.ledger: final payload = LedgerEndpointDTO.fromJson(data); return LedgerPaymentMethod( @@ -131,3 +132,10 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO { } } } + +PaymentType _resolveEndpointType(String type, Map data) { + if (type == 'card' && (data.containsKey('token') || data.containsKey('masked_pan'))) { + return PaymentType.cardToken; + } + return endpointTypeFromValue(type); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart b/frontend/pshared/lib/data/mapper/payment/payment_quote.dart index f083c9e6..51bec34d 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_quote.dart @@ -3,12 +3,13 @@ import 'package:pshared/data/mapper/payment/fee_line.dart'; import 'package:pshared/data/mapper/payment/fx_quote.dart'; import 'package:pshared/data/mapper/payment/money.dart'; import 'package:pshared/data/mapper/payment/network_fee.dart'; -import 'package:pshared/models/payment/quote.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; extension PaymentQuoteDTOMapper on PaymentQuoteDTO { - PaymentQuote toDomain() => PaymentQuote( + PaymentQuote toDomain({String? idempotencyKey}) => PaymentQuote( quoteRef: quoteRef, + idempotencyKey: idempotencyKey, debitAmount: debitAmount?.toDomain(), expectedSettlementAmount: expectedSettlementAmount?.toDomain(), expectedFeeTotal: expectedFeeTotal?.toDomain(), diff --git a/frontend/pshared/lib/data/mapper/payment/quote_aggregate.dart b/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart similarity index 94% rename from frontend/pshared/lib/data/mapper/payment/quote_aggregate.dart rename to frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart index 45d9fc0b..563c0c2a 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote_aggregate.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/aggregate.dart @@ -1,6 +1,6 @@ import 'package:pshared/data/dto/payment/quote_aggregate.dart'; import 'package:pshared/data/mapper/payment/money.dart'; -import 'package:pshared/models/payment/quote_aggregate.dart'; +import 'package:pshared/models/payment/quote/aggregate.dart'; extension PaymentQuoteAggregateDTOMapper on PaymentQuoteAggregateDTO { diff --git a/frontend/pshared/lib/data/mapper/payment/quotes.dart b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart similarity index 75% rename from frontend/pshared/lib/data/mapper/payment/quotes.dart rename to frontend/pshared/lib/data/mapper/payment/quote/quotes.dart index 300e25b0..75015ebd 100644 --- a/frontend/pshared/lib/data/mapper/payment/quotes.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart @@ -1,7 +1,7 @@ import 'package:pshared/data/dto/payment/quotes.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/quote_aggregate.dart'; -import 'package:pshared/models/payment/quotes.dart'; +import 'package:pshared/data/mapper/payment/quote/aggregate.dart'; +import 'package:pshared/models/payment/quote/quotes.dart'; extension PaymentQuotesDTOMapper on PaymentQuotesDTO { @@ -9,13 +9,11 @@ extension PaymentQuotesDTOMapper on PaymentQuotesDTO { quoteRef: quoteRef, aggregate: aggregate?.toDomain(), quotes: quotes?.map((quote) => quote.toDomain()).toList(), - idempotencyKey: idempotencyKey ); } extension PaymentQuotesMapper on PaymentQuotes { PaymentQuotesDTO toDTO() => PaymentQuotesDTO( - idempotencyKey: idempotencyKey, quoteRef: quoteRef, aggregate: aggregate?.toDTO(), quotes: quotes?.map((quote) => quote.toDTO()).toList(), diff --git a/frontend/pshared/lib/models/auto_refresh_mode.dart b/frontend/pshared/lib/models/auto_refresh_mode.dart new file mode 100644 index 00000000..7de4c0fd --- /dev/null +++ b/frontend/pshared/lib/models/auto_refresh_mode.dart @@ -0,0 +1 @@ +enum AutoRefreshMode { off, on } diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 42219f82..004687af 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,4 +1,4 @@ -import 'package:pshared/models/payment/quote.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; class Payment { diff --git a/frontend/pshared/lib/models/payment/quote.dart b/frontend/pshared/lib/models/payment/quote.dart deleted file mode 100644 index a56b2b91..00000000 --- a/frontend/pshared/lib/models/payment/quote.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:pshared/api/responses/payment/quotation.dart'; -import 'package:pshared/models/payment/fees/line.dart'; -import 'package:pshared/models/payment/fx/quote.dart'; -import 'package:pshared/models/payment/money.dart'; -import 'package:pshared/models/payment/fees/network.dart'; - - -class PaymentQuote { - final String? quoteRef; - final Money? debitAmount; - final Money? expectedSettlementAmount; - final Money? expectedFeeTotal; - final List? feeLines; - final NetworkFee? networkFee; - final FxQuote? fxQuote; - - const PaymentQuote({ - required this.quoteRef, - required this.debitAmount, - required this.expectedSettlementAmount, - required this.expectedFeeTotal, - required this.feeLines, - required this.networkFee, - required this.fxQuote, - }); -} - -class PaymentQuoteX extends PaymentQuote { - final String idempotencyKey; - - const PaymentQuoteX({ - required super.quoteRef, - required super.debitAmount, - required super.expectedSettlementAmount, - required super.expectedFeeTotal, - required super.feeLines, - required super.networkFee, - required super.fxQuote, - required this.idempotencyKey, - }); - - factory PaymentQuoteX.build({required PaymentQuote quote, required String idempotencyKey}) => PaymentQuoteX( - quoteRef: quote.quoteRef, - debitAmount: quote.debitAmount, - expectedSettlementAmount: quote.expectedSettlementAmount, - expectedFeeTotal: quote.expectedFeeTotal, - feeLines: quote.feeLines, - networkFee: quote.networkFee, - fxQuote: quote.fxQuote, - idempotencyKey: idempotencyKey, - ); -} diff --git a/frontend/pshared/lib/models/payment/quote_aggregate.dart b/frontend/pshared/lib/models/payment/quote/aggregate.dart similarity index 100% rename from frontend/pshared/lib/models/payment/quote_aggregate.dart rename to frontend/pshared/lib/models/payment/quote/aggregate.dart diff --git a/frontend/pshared/lib/models/payment/quote/quote.dart b/frontend/pshared/lib/models/payment/quote/quote.dart new file mode 100644 index 00000000..757b2381 --- /dev/null +++ b/frontend/pshared/lib/models/payment/quote/quote.dart @@ -0,0 +1,27 @@ +import 'package:pshared/models/payment/fees/line.dart'; +import 'package:pshared/models/payment/fx/quote.dart'; +import 'package:pshared/models/payment/money.dart'; +import 'package:pshared/models/payment/fees/network.dart'; + + +class PaymentQuote { + final String? quoteRef; + final String? idempotencyKey; + final Money? debitAmount; + final Money? expectedSettlementAmount; + final Money? expectedFeeTotal; + final List? feeLines; + final NetworkFee? networkFee; + final FxQuote? fxQuote; + + const PaymentQuote({ + required this.quoteRef, + required this.idempotencyKey, + required this.debitAmount, + required this.expectedSettlementAmount, + required this.expectedFeeTotal, + required this.feeLines, + required this.networkFee, + required this.fxQuote, + }); +} diff --git a/frontend/pshared/lib/models/payment/quotes.dart b/frontend/pshared/lib/models/payment/quote/quotes.dart similarity index 57% rename from frontend/pshared/lib/models/payment/quotes.dart rename to frontend/pshared/lib/models/payment/quote/quotes.dart index 4d475faa..e4bc7e5e 100644 --- a/frontend/pshared/lib/models/payment/quotes.dart +++ b/frontend/pshared/lib/models/payment/quote/quotes.dart @@ -1,10 +1,9 @@ -import 'package:pshared/models/payment/quote.dart'; -import 'package:pshared/models/payment/quote_aggregate.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/models/payment/quote/aggregate.dart'; class PaymentQuotes { final String quoteRef; - final String idempotencyKey; final PaymentQuoteAggregate? aggregate; final List? quotes; @@ -12,6 +11,5 @@ class PaymentQuotes { required this.quoteRef, required this.aggregate, required this.quotes, - required this.idempotencyKey, }); } diff --git a/frontend/pshared/lib/models/payment/quote/status_type.dart b/frontend/pshared/lib/models/payment/quote/status_type.dart new file mode 100644 index 00000000..ad2334aa --- /dev/null +++ b/frontend/pshared/lib/models/payment/quote/status_type.dart @@ -0,0 +1 @@ +enum QuoteStatusType { loading, error, missing, expired, active } diff --git a/frontend/pshared/lib/provider/payment/provider.dart b/frontend/pshared/lib/provider/payment/provider.dart index 6afd032e..10615049 100644 --- a/frontend/pshared/lib/provider/payment/provider.dart +++ b/frontend/pshared/lib/provider/payment/provider.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/service.dart'; @@ -31,18 +31,23 @@ class PaymentProvider extends ChangeNotifier { Future pay({String? idempotencyKey, Map? metadata}) async { if (!_organization.isOrganizationSet) throw StateError('Organization is not set'); - if (!_quotation.isReady) throw StateError('Quotation is not ready'); final quoteRef = _quotation.quotation?.quoteRef; if (quoteRef == null || quoteRef.isEmpty) { throw StateError('Quotation reference is not set'); } + final expiresAt = _quotation.quoteExpiresAt; + if (expiresAt != null && expiresAt.isBefore(DateTime.now().toUtc())) { + throw StateError('Quotation is expired'); + } _setResource(_payment.copyWith(isLoading: true, error: null)); try { + final resolvedIdempotencyKey = + idempotencyKey ?? _quotation.quotation?.idempotencyKey; final response = await PaymentService.pay( _organization.current.id, quoteRef, - idempotencyKey: idempotencyKey, + idempotencyKey: resolvedIdempotencyKey, metadata: metadata, ); _isLoaded = true; diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart deleted file mode 100644 index 285b48cd..00000000 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:uuid/uuid.dart'; - -import 'package:pshared/api/requests/payment/quote.dart'; -import 'package:pshared/data/mapper/payment/intent/payment.dart'; -import 'package:pshared/models/asset.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/managed_wallet.dart'; -import 'package:pshared/models/payment/methods/type.dart'; -import 'package:pshared/models/payment/money.dart'; -import 'package:pshared/models/payment/settlement_mode.dart'; -import 'package:pshared/models/payment/intent.dart'; -import 'package:pshared/models/payment/quote.dart'; -import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/provider/payment/amount.dart'; -import 'package:pshared/provider/payment/flow.dart'; -import 'package:pshared/provider/payment/wallets.dart'; -import 'package:pshared/provider/recipient/provider.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; -import 'package:pshared/provider/resource.dart'; -import 'package:pshared/service/payment/quotation.dart'; -import 'package:pshared/utils/currency.dart'; -import 'package:pshared/utils/exception.dart'; - - -class QuotationProvider extends ChangeNotifier { - Resource _quotation = Resource(data: null, isLoading: false, error: null); - late OrganizationsProvider _organizations; - bool _isLoaded = false; - - void update( - OrganizationsProvider venue, - PaymentAmountProvider payment, - WalletsProvider wallets, - PaymentFlowProvider flow, - RecipientsProvider recipients, - PaymentMethodsProvider methods, - ) { - _organizations = venue; - final t = flow.selectedType; - final method = methods.methods.firstWhereOrNull((m) => m.type == t); - if ((wallets.selectedWallet != null) && (method != null)) { - final customer = _buildCustomer( - recipient: recipients.currentObject, - method: method, - ); - final amount = Money( - amount: payment.amount.toString(), - // TODO: adapt to possible other sources - currency: currencyCodeToString(wallets.selectedWallet!.currency), - ); - final fxIntent = FxIntent( - pair: CurrencyPair( - base: currencyCodeToString(wallets.selectedWallet!.currency), - quote: 'RUB', // TODO: exentd target currencies - ), - side: FxSide.sellBaseBuyQuote, - ); - getQuotation(PaymentIntent( - kind: PaymentKind.payout, - amount: amount, - destination: method.data, - source: ManagedWalletPaymentMethod( - managedWalletRef: wallets.selectedWallet!.id, - ), - fx: fxIntent, - settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, - settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), - customer: customer, - )); - } - } - - PaymentQuote? get quotation => _quotation.data; - - bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; - - Asset? get fee => quotation == null ? null : createAsset(quotation!.expectedFeeTotal!.currency, quotation!.expectedFeeTotal!.amount); - Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount); - Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount); - - 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, - }) { - final name = _resolveCustomerName(method, recipient); - String? firstName; - String? middleName; - String? lastName; - - if (name != null && name.isNotEmpty) { - final parts = name.split(RegExp(r'\s+')); - if (parts.length == 1) { - firstName = parts.first; - } else if (parts.length == 2) { - firstName = parts.first; - lastName = parts.last; - } else { - firstName = parts.first; - lastName = parts.last; - middleName = parts.sublist(1, parts.length - 1).join(' '); - } - } - - return Customer( - id: recipient?.id ?? method.recipientRef, - firstName: firstName, - middleName: middleName, - lastName: lastName, - country: method.cardData?.country, - ); - } - - String? _resolveCustomerName(PaymentMethod method, Recipient? recipient) { - final card = method.cardData; - if (card != null) { - return '${card.firstName} ${card.lastName}'.trim(); - } - - final iban = method.ibanData; - if (iban != null && iban.accountHolder.trim().isNotEmpty) { - return iban.accountHolder.trim(); - } - - final bank = method.bankAccountData; - if (bank != null && bank.recipientName.trim().isNotEmpty) { - return bank.recipientName.trim(); - } - - final recipientName = recipient?.name.trim(); - return recipientName?.isNotEmpty == true ? recipientName : null; - } - - void _setResource(Resource quotation) { - _quotation = quotation; - notifyListeners(); - } - - Future getQuotation(PaymentIntent intent) async { - if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); - try { - _quotation = _quotation.copyWith(isLoading: true, error: null); - final response = await QuotationService.getQuotation( - _organizations.current.id, - QuotePaymentRequest( - idempotencyKey: Uuid().v4(), - intent: intent.toDTO(), - ), - ); - _isLoaded = true; - _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); - } catch (e) { - _setResource(_quotation.copyWith(data: null, error: toException(e), isLoading: false)); - } - notifyListeners(); - return _quotation.data; - } - - void reset() { - _setResource(Resource(data: null, isLoading: false, error: null)); - _isLoaded = false; - notifyListeners(); - } -} diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart new file mode 100644 index 00000000..ec242cd7 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -0,0 +1,138 @@ +import 'package:collection/collection.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/managed_wallet.dart'; +import 'package:pshared/models/payment/methods/type.dart'; +import 'package:pshared/models/payment/money.dart'; +import 'package:pshared/models/payment/settlement_mode.dart'; +import 'package:pshared/models/payment/intent.dart'; +import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/payment/amount.dart'; +import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/wallets.dart'; +import 'package:pshared/provider/recipient/provider.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; +import 'package:pshared/utils/currency.dart'; + + +class QuotationIntentBuilder { + PaymentIntent? build({ + required PaymentAmountProvider payment, + required WalletsProvider wallets, + required PaymentFlowProvider flow, + required RecipientsProvider recipients, + required PaymentMethodsProvider methods, + }) { + final selectedWallet = wallets.selectedWallet; + final method = methods.methods.firstWhereOrNull((m) => m.type == flow.selectedType); + if (selectedWallet == null || method == null) return null; + + final customer = _buildCustomer( + recipient: recipients.currentObject, + method: method, + ); + final amount = Money( + amount: payment.amount.toString(), + // TODO: adapt to possible other sources + currency: currencyCodeToString(selectedWallet.currency), + ); + final fxIntent = FxIntent( + pair: CurrencyPair( + base: currencyCodeToString(selectedWallet.currency), + quote: 'RUB', // TODO: exentd target currencies + ), + side: FxSide.sellBaseBuyQuote, + ); + return PaymentIntent( + kind: PaymentKind.payout, + amount: amount, + destination: method.data, + source: ManagedWalletPaymentMethod( + managedWalletRef: selectedWallet.id, + ), + fx: fxIntent, + settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, + settlementCurrency: _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, + }) { + final name = _resolveCustomerName(method, recipient); + String? firstName; + String? middleName; + String? lastName; + + if (name != null && name.isNotEmpty) { + final parts = name.split(RegExp(r'\s+')); + if (parts.isNotEmpty) { + firstName = parts.first; + } + if (parts.length == 2) { + lastName = parts.last; + } else if (parts.length > 2) { + lastName = parts.last; + middleName = parts.sublist(1, parts.length - 1).join(' '); + } + } + + return Customer( + id: recipient?.id ?? method.recipientRef, + firstName: firstName, + middleName: middleName, + lastName: lastName, + country: method.cardData?.country, + ); + } + + String? _resolveCustomerName(PaymentMethod method, Recipient? recipient) { + final card = method.cardData; + if (card != null) { + return '${card.firstName} ${card.lastName}'.trim(); + } + + final iban = method.ibanData; + if (iban != null && iban.accountHolder.trim().isNotEmpty) { + return iban.accountHolder.trim(); + } + + final bank = method.bankAccountData; + if (bank != null && bank.recipientName.trim().isNotEmpty) { + return bank.recipientName.trim(); + } + + final recipientName = recipient?.name.trim(); + return recipientName?.isNotEmpty == true ? recipientName : null; + } +} diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart new file mode 100644 index 00000000..3cf4daae --- /dev/null +++ b/frontend/pshared/lib/provider/payment/quotation/quotation.dart @@ -0,0 +1,126 @@ +import 'package:flutter/foundation.dart'; + +import 'package:logging/logging.dart'; +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/data/mapper/payment/intent/payment.dart'; +import 'package:pshared/models/asset.dart'; +import 'package:pshared/models/payment/intent.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/models/payment/money.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/payment/amount.dart'; +import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/wallets.dart'; +import 'package:pshared/provider/recipient/provider.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/provider/payment/quotation/intent_builder.dart'; +import 'package:pshared/service/payment/quotation.dart'; +import 'package:pshared/utils/exception.dart'; + + +class QuotationProvider extends ChangeNotifier { + static final _logger = Logger('provider.payment.quotation'); + Resource _quotation = Resource(data: null, isLoading: false, error: null); + late OrganizationsProvider _organizations; + bool _isLoaded = false; + PaymentIntent? _lastIntent; + final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder(); + + void update( + OrganizationsProvider venue, + PaymentAmountProvider payment, + WalletsProvider wallets, + PaymentFlowProvider flow, + RecipientsProvider recipients, + PaymentMethodsProvider methods, + ) { + _organizations = venue; + final intent = _intentBuilder.build( + payment: payment, + wallets: wallets, + flow: flow, + recipients: recipients, + methods: methods, + ); + if (intent == null) return; + final intentKey = _buildIntentKey(intent); + final lastIntent = _lastIntent; + if (lastIntent != null && intentKey == _buildIntentKey(lastIntent)) return; + getQuotation(intent, idempotencyKey: intentKey); + } + + PaymentQuote? get quotation => _quotation.data; + bool get isLoading => _quotation.isLoading; + Exception? get error => _quotation.error; + bool get canRefresh => _lastIntent != null; + bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; + + DateTime? get quoteExpiresAt { + final expiresAtUnixMs = quotation?.fxQuote?.expiresAtUnixMs; + if (expiresAtUnixMs == null) return null; + return DateTime.fromMillisecondsSinceEpoch(expiresAtUnixMs, isUtc: true); + } + + + Asset? get fee => _assetFromMoney(quotation?.expectedFeeTotal); + Asset? get total => _assetFromMoney(quotation?.debitAmount); + Asset? get recipientGets => _assetFromMoney(quotation?.expectedSettlementAmount); + + Asset? _assetFromMoney(Money? money) { + if (money == null) return null; + return createAsset(money.currency, money.amount); + } + + void _setResource(Resource quotation) { + _quotation = quotation; + notifyListeners(); + } + + Future refreshQuotation() async { + final intent = _lastIntent; + if (intent == null) return null; + return getQuotation(intent, idempotencyKey: _buildIntentKey(intent)); + } + + Future getQuotation(PaymentIntent intent, {String? idempotencyKey}) async { + if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); + _lastIntent = intent; + final intentKey = idempotencyKey ?? _buildIntentKey(intent); + try { + _setResource(_quotation.copyWith(isLoading: true, error: null)); + final response = await QuotationService.getQuotation( + _organizations.current.id, + QuotePaymentRequest( + idempotencyKey: intentKey, + intent: intent.toDTO(), + ), + ); + _isLoaded = true; + _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); + } catch (e, st) { + _logger.warning('Failed to get quotation', e, st); + _setResource(_quotation.copyWith( + data: null, + error: toException(e), + isLoading: false, + )); + } + return _quotation.data; + } + + void reset() { + _isLoaded = false; + _lastIntent = null; + _setResource(Resource(data: null, isLoading: false, error: null)); + } + + String _buildIntentKey(PaymentIntent intent) { + final payload = jsonEncode(intent.toDTO().toJson()); + return Uuid().v5(Namespace.url.value, 'quote:$payload'); + } +} diff --git a/frontend/pshared/lib/provider/resource.dart b/frontend/pshared/lib/provider/resource.dart index 6d127f28..321caebc 100644 --- a/frontend/pshared/lib/provider/resource.dart +++ b/frontend/pshared/lib/provider/resource.dart @@ -5,11 +5,14 @@ class Resource { Resource({this.data, this.isLoading = false, this.error}); - Resource copyWith({T? data, bool? isLoading, Exception? error}) { + static const _unset = Object(); + + Resource copyWith({T? data, bool? isLoading, Object? error = _unset}) { return Resource( data: data ?? this.data, isLoading: isLoading ?? this.isLoading, - error: error ?? this.error, + // Distinguish "not provided" from an explicit null to allow clearing error. + error: error == _unset ? this.error : error as Exception?, ); } } diff --git a/frontend/pshared/lib/service/payment/quotation.dart b/frontend/pshared/lib/service/payment/quotation.dart index 54c2a72f..62b874f9 100644 --- a/frontend/pshared/lib/service/payment/quotation.dart +++ b/frontend/pshared/lib/service/payment/quotation.dart @@ -5,9 +5,9 @@ import 'package:pshared/api/requests/payment/quotes.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/api/responses/payment/quotes.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart'; -import 'package:pshared/data/mapper/payment/quotes.dart'; -import 'package:pshared/models/payment/quote.dart'; -import 'package:pshared/models/payment/quotes.dart'; +import 'package:pshared/data/mapper/payment/quote/quotes.dart'; +import 'package:pshared/models/payment/quote/quote.dart'; +import 'package:pshared/models/payment/quote/quotes.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; @@ -16,15 +16,15 @@ class QuotationService { static final _logger = Logger('service.payment.quotation'); static const String _objectType = Services.payments; - static Future getQuotation(String organizationRef, QuotePaymentRequest request) async { + static Future getQuotation(String organizationRef, QuotePaymentRequest request) async { _logger.fine('Quoting payment for organization $organizationRef'); final response = await AuthorizationService.getPOSTResponse( _objectType, '/quote/$organizationRef', request.toJson(), ); - final resp = PaymentQuoteResponse.fromJson(response); - return PaymentQuoteX.build(quote: resp.quote.toDomain(), idempotencyKey: resp.idempotencyKey); + final parsed = PaymentQuoteResponse.fromJson(response); + return parsed.quote.toDomain(idempotencyKey: parsed.idempotencyKey); } static Future getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async { diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 11e7a9b8..eaeb30bb 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -10,12 +10,13 @@ import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pweb/app/router/pages.dart'; import 'package:pweb/app/router/payout_routes.dart'; +import 'package:pweb/providers/quotation/quotation.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/pages/address_book/form/page.dart'; import 'package:pweb/pages/address_book/page/page.dart'; @@ -57,6 +58,10 @@ RouteBase payoutShellRoute() => ShellRoute( update: (_, organization, payment, wallet, flow, recipients, methods, provider) => provider!..update(organization, payment, wallet, flow, recipients, methods), ), + ChangeNotifierProxyProvider( + create: (_) => QuotationController(), + update: (_, quotation, controller) => controller!..update(quotation), + ), ChangeNotifierProxyProvider2( create: (_) => PaymentProvider(), update: (context, organization, quotation, provider) => provider!..update( diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 9eee34dc..610c9293 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -438,6 +438,29 @@ "payout": "Payout", "sendTo": "Send Payout To", "send": "Send Payout", + "quoteUnavailable": "Waiting for a quote...", + "quoteUpdating": "Refreshing quote...", + "quoteExpiresIn": "Quote expires in {time}", + "@quoteExpiresIn": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "quoteActive": "Quote is active", + "quoteExpired": "Quote expired, request a new one", + "quoteAutoRefresh": "Auto-refresh quote", + "quoteAutoRefreshHint": "Keeps the quote current while you prepare the payout", + "quoteAutoRefreshEnabled": "Auto-refresh is on", + "quoteAutoRefreshDisabled": "Auto-refresh is off", + "quoteEnableAutoRefresh": "Enable auto-refresh", + "quoteDisableAutoRefresh": "Disable auto-refresh", + "quoteRefresh": "Refresh quote", + "quoteRefreshRequired": "Refresh the quote to enable payout", + "quoteErrorGeneric": "Could not refresh quote, try again later", + "toggleOn": "On", + "toggleOff": "Off", "refreshBalance": "Refresh balance", "recipientPaysFee": "Recipient pays the fee", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 2341ddcf..e9329b6a 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -438,6 +438,29 @@ "payout": "Выплата", "sendTo": "Отправить выплату", "send": "Отправить выплату", + "quoteUnavailable": "Ожидание котировки...", + "quoteUpdating": "Обновляем котировку...", + "quoteExpiresIn": "Котировка истекает через {time}", + "@quoteExpiresIn": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "quoteActive": "Котировка активна", + "quoteExpired": "Срок котировки истек, запросите новую", + "quoteAutoRefresh": "Автообновление котировки", + "quoteAutoRefreshHint": "Поддерживает котировку актуальной во время подготовки выплаты", + "quoteAutoRefreshEnabled": "Автообновление включено", + "quoteAutoRefreshDisabled": "Автообновление выключено", + "quoteEnableAutoRefresh": "Включить автообновление", + "quoteDisableAutoRefresh": "Выключить автообновление", + "quoteRefresh": "Обновить котировку", + "quoteRefreshRequired": "Обновите котировку, чтобы продолжить выплату", + "quoteErrorGeneric": "Не удалось обновить котировку, повторите позже", + "toggleOn": "Вкл", + "toggleOff": "Выкл", "refreshBalance": "Обновить баланс", "recipientPaysFee": "Получатель оплачивает комиссию", diff --git a/frontend/pweb/lib/models/button_state.dart b/frontend/pweb/lib/models/button_state.dart new file mode 100644 index 00000000..859dd78c --- /dev/null +++ b/frontend/pweb/lib/models/button_state.dart @@ -0,0 +1 @@ +enum ButtonState { enabled, disabled, loading } diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index e1d036b4..ce1bc2c6 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -48,51 +48,54 @@ class _DashboardPageState extends State { } @override - Widget build(BuildContext context) => PageViewLoader( - child: SafeArea( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - flex: 0, - child: TransactionRefButton( - onTap: () => _setActive(true), - isActive: _showContainerSingle, - label: AppLocalizations.of(context)!.sendSingle, - icon: Icons.person_add, + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return PageViewLoader( + child: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + flex: 0, + child: TransactionRefButton( + onTap: () => _setActive(true), + isActive: _showContainerSingle, + label: l10n.sendSingle, + icon: Icons.person_add, + ), ), - ), - const SizedBox(width: AppSpacing.small), - Expanded( - flex: 0, - child: TransactionRefButton( - onTap: () => _setActive(false), - isActive: _showContainerMultiple, - label: AppLocalizations.of(context)!.sendMultiple, - icon: Icons.group_add, + const SizedBox(width: AppSpacing.small), + Expanded( + flex: 0, + child: TransactionRefButton( + onTap: () => _setActive(false), + isActive: _showContainerMultiple, + label: l10n.sendMultiple, + icon: Icons.group_add, + ), ), - ), - ], - ), - const SizedBox(height: AppSpacing.medium), - BalanceWidget( - onTopUp: widget.onTopUp, - ), - const SizedBox(height: AppSpacing.small), - if (_showContainerMultiple) TitleMultiplePayout(), - const SizedBox(height: AppSpacing.medium), - if (_showContainerSingle) - SinglePayoutForm( - onRecipientSelected: widget.onRecipientSelected, - onGoToPayment: widget.onGoToPaymentWithoutRecipient, + ], ), - if (_showContainerMultiple) MultiplePayoutForm(), - ], + const SizedBox(height: AppSpacing.medium), + BalanceWidget( + onTopUp: widget.onTopUp, + ), + const SizedBox(height: AppSpacing.small), + if (_showContainerMultiple) TitleMultiplePayout(), + const SizedBox(height: AppSpacing.medium), + if (_showContainerSingle) + SinglePayoutForm( + onRecipientSelected: widget.onRecipientSelected, + onGoToPayment: widget.onGoToPaymentWithoutRecipient, + ), + if (_showContainerMultiple) MultiplePayoutForm(), + ], + ), ), ), - ), - ); + ); + } } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/form.dart index e076d525..36e1c379 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/form.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/form.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/pages/dashboard/payouts/amount.dart'; import 'package:pweb/pages/dashboard/payouts/fee_payer.dart'; +import 'package:pweb/pages/dashboard/payouts/quote_status/quote_status.dart'; import 'package:pweb/pages/dashboard/payouts/summary/widget.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -36,8 +37,9 @@ class PaymentFormWidget extends StatelessWidget { const SizedBox(height: _largeSpacing), const PaymentSummary(spacing: _extraSpacing), + const SizedBox(height: _mediumSpacing), + const QuoteStatus(spacing: _smallSpacing), ], ); } } - diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart new file mode 100644 index 00000000..1eccad99 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/quote_status.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pweb/providers/quotation/quotation.dart'; + +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/body.dart'; +import 'package:pweb/utils/quote_duration_format.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class QuoteStatus extends StatelessWidget { + final double spacing; + + const QuoteStatus({super.key, required this.spacing}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final controller = context.watch(); + final timeLeft = controller.timeLeft; + final isLoading = controller.isLoading; + final statusType = controller.quoteStatus; + final autoRefreshMode = controller.autoRefreshMode; + + String statusText; + String? helperText; + switch (statusType) { + case QuoteStatusType.loading: + statusText = loc.quoteUpdating; + break; + case QuoteStatusType.error: + statusText = loc.quoteErrorGeneric; + break; + case QuoteStatusType.missing: + statusText = loc.quoteUnavailable; + break; + case QuoteStatusType.expired: + statusText = loc.quoteExpired; + helperText = loc.quoteRefreshRequired; + break; + case QuoteStatusType.active: + statusText = timeLeft == null + ? loc.quoteActive + : loc.quoteExpiresIn(formatQuoteDuration(timeLeft)); + break; + } + + final canRefresh = controller.canRefresh && !isLoading; + final showPrimaryRefresh = canRefresh && + (statusType == QuoteStatusType.expired || + statusType == QuoteStatusType.error || + statusType == QuoteStatusType.missing); + + return QuoteStatusBody( + spacing: spacing, + statusType: statusType, + statusText: statusText, + helperText: helperText, + isLoading: isLoading, + canRefresh: canRefresh, + showPrimaryRefresh: showPrimaryRefresh, + autoRefreshMode: autoRefreshMode, + onAutoRefreshModeChanged: controller.setAutoRefreshMode, + onRefresh: controller.refreshQuotation, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/body.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/body.dart new file mode 100644 index 00000000..2ac16695 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/body.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/models/auto_refresh_mode.dart'; + +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart'; +import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart'; + + +class QuoteStatusBody extends StatelessWidget { + final double spacing; + final QuoteStatusType statusType; + final String statusText; + final String? helperText; + final bool isLoading; + final bool canRefresh; + final bool showPrimaryRefresh; + final AutoRefreshMode autoRefreshMode; + final ValueChanged onAutoRefreshModeChanged; + final VoidCallback onRefresh; + + const QuoteStatusBody({ + super.key, + required this.spacing, + required this.statusType, + required this.statusText, + required this.helperText, + required this.isLoading, + required this.canRefresh, + required this.showPrimaryRefresh, + required this.autoRefreshMode, + required this.onAutoRefreshModeChanged, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QuoteStatusCard( + statusType: statusType, + isLoading: isLoading, + statusText: statusText, + helperText: helperText, + canRefresh: canRefresh, + showPrimaryRefresh: showPrimaryRefresh, + onRefresh: onRefresh, + ), + SizedBox(height: spacing), + QuoteAutoRefreshSection( + autoRefreshMode: autoRefreshMode, + canRefresh: canRefresh, + onModeChanged: onAutoRefreshModeChanged, + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/card.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/card.dart new file mode 100644 index 00000000..e2ed8c55 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/card.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/quote/status_type.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class QuoteStatusCard extends StatelessWidget { + final QuoteStatusType statusType; + final bool isLoading; + final String statusText; + final String? helperText; + final bool canRefresh; + final bool showPrimaryRefresh; + final VoidCallback onRefresh; + + const QuoteStatusCard({ + super.key, + required this.statusType, + required this.isLoading, + required this.statusText, + required this.helperText, + required this.canRefresh, + required this.showPrimaryRefresh, + required this.onRefresh, + }); + + static const double _cardRadius = 12; + static const double _cardSpacing = 12; + static const double _iconSize = 18; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final foregroundColor = _resolveForegroundColor(theme, statusType); + final statusStyle = theme.textTheme.bodyMedium?.copyWith(color: foregroundColor); + final helperStyle = theme.textTheme.bodySmall?.copyWith( + color: foregroundColor.withValues(alpha: 0.8), + ); + + return Container( + padding: const EdgeInsets.all(_cardSpacing), + decoration: BoxDecoration( + color: _resolveCardColor(theme, statusType), + borderRadius: BorderRadius.circular(_cardRadius), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: isLoading + ? SizedBox( + width: _iconSize, + height: _iconSize, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(foregroundColor), + ), + ) + : Icon( + _resolveIcon(statusType), + size: _iconSize, + color: foregroundColor, + ), + ), + const SizedBox(width: _cardSpacing), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(statusText, style: statusStyle), + if (helperText != null) ...[ + const SizedBox(height: 4), + Text(helperText!, style: helperStyle), + ], + ], + ), + ), + if (canRefresh) + Padding( + padding: const EdgeInsets.only(left: _cardSpacing), + child: showPrimaryRefresh + ? ElevatedButton( + onPressed: canRefresh ? onRefresh : null, + child: Text(AppLocalizations.of(context)!.quoteRefresh), + ) + : TextButton( + onPressed: canRefresh ? onRefresh : null, + child: Text(AppLocalizations.of(context)!.quoteRefresh), + ), + ), + ], + ), + ); + } + + Color _resolveCardColor(ThemeData theme, QuoteStatusType status) { + switch (status) { + case QuoteStatusType.loading: + return theme.colorScheme.secondaryContainer; + case QuoteStatusType.error: + case QuoteStatusType.expired: + return theme.colorScheme.errorContainer; + case QuoteStatusType.active: + return theme.colorScheme.primaryContainer; + case QuoteStatusType.missing: + return theme.colorScheme.surfaceContainerHighest; + } + } + + Color _resolveForegroundColor(ThemeData theme, QuoteStatusType status) { + switch (status) { + case QuoteStatusType.loading: + return theme.colorScheme.onSecondaryContainer; + case QuoteStatusType.error: + case QuoteStatusType.expired: + return theme.colorScheme.onErrorContainer; + case QuoteStatusType.active: + return theme.colorScheme.onPrimaryContainer; + case QuoteStatusType.missing: + return theme.colorScheme.onSurfaceVariant; + } + } + + IconData _resolveIcon(QuoteStatusType status) { + switch (status) { + case QuoteStatusType.loading: + return Icons.sync_rounded; + case QuoteStatusType.error: + return Icons.warning_amber_rounded; + case QuoteStatusType.expired: + return Icons.error_outline_rounded; + case QuoteStatusType.active: + return Icons.timer_outlined; + case QuoteStatusType.missing: + return Icons.info_outline_rounded; + } + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart new file mode 100644 index 00000000..2c25a715 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/auto_refresh_mode.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class QuoteAutoRefreshSection extends StatelessWidget { + final AutoRefreshMode autoRefreshMode; + final bool canRefresh; + final ValueChanged onModeChanged; + + const QuoteAutoRefreshSection({ + super.key, + required this.autoRefreshMode, + required this.canRefresh, + required this.onModeChanged, + }); + + static const double _autoRefreshSpacing = 8; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + autoRefreshMode == AutoRefreshMode.on + ? loc.quoteAutoRefreshEnabled + : loc.quoteAutoRefreshDisabled, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 2), + Text( + loc.quoteAutoRefreshHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: _autoRefreshSpacing), + ToggleButtons( + isSelected: [ + autoRefreshMode == AutoRefreshMode.off, + autoRefreshMode == AutoRefreshMode.on, + ], + onPressed: canRefresh + ? (index) { + final nextMode = + index == 1 ? AutoRefreshMode.on : AutoRefreshMode.off; + if (nextMode == autoRefreshMode) return; + onModeChanged(nextMode); + } + : null, + borderRadius: BorderRadius.circular(999), + constraints: const BoxConstraints(minHeight: 32, minWidth: 56), + selectedColor: theme.colorScheme.onPrimary, + fillColor: theme.colorScheme.primary, + color: theme.colorScheme.onSurface, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(loc.toggleOff), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(loc.toggleOn), + ), + ], + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart index 95f8aa5d..6c1a005f 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart index e2f23a7f..e82c5aed 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/recipient_receives.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart index 47f1b649..0e6aa7da 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/total.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/provider/payment/quotation.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pweb/pages/dashboard/payouts/summary/row.dart'; diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart index 12b12890..59b9348c 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart @@ -54,7 +54,7 @@ class PaymentPageContent extends StatelessWidget { Widget build(BuildContext context) { final dimensions = AppDimensions(); final loc = AppLocalizations.of(context)!; - + return Align( alignment: Alignment.topCenter, child: ConstrainedBox( diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart index c94c3630..dd6101fb 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -50,7 +50,7 @@ class PaymentPageContent extends StatelessWidget { Widget build(BuildContext context) { final dimensions = AppDimensions(); final loc = AppLocalizations.of(context)!; - + return Align( alignment: Alignment.topCenter, child: ConstrainedBox( diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart index 019cb291..dd0cab66 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/send_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:pweb/models/button_state.dart'; import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -7,38 +8,61 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class SendButton extends StatelessWidget { final VoidCallback onPressed; + final ButtonState state; - const SendButton({super.key, required this.onPressed}); + const SendButton({ + super.key, + required this.onPressed, + this.state = ButtonState.enabled, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final dimensions = AppDimensions(); + final isEnabled = state == ButtonState.enabled; + final isLoading = state == ButtonState.loading; + final backgroundColor = isEnabled || isLoading + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.12); + final textColor = isEnabled || isLoading + ? theme.colorScheme.onSecondary + : theme.colorScheme.onSurface.withValues(alpha: 0.38); + return Center( child: SizedBox( width: dimensions.buttonWidth, height: dimensions.buttonHeight, child: InkWell( borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), - onTap: onPressed, + onTap: isEnabled ? onPressed : null, child: Container( decoration: BoxDecoration( - color: theme.colorScheme.primary, + color: backgroundColor, borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), ), child: Center( - child: Text( - AppLocalizations.of(context)!.send, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSecondary, - fontWeight: FontWeight.w600, - ), - ), + child: isLoading + ? SizedBox( + width: dimensions.iconSizeMedium, + height: dimensions.iconSizeMedium, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(textColor), + ), + ) + : Text( + AppLocalizations.of(context)!.send, + style: theme.textTheme.bodyLarge?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ), + ), ), ), ), ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/providers/quotation/auto_refresh.dart b/frontend/pweb/lib/providers/quotation/auto_refresh.dart new file mode 100644 index 00000000..407f26c4 --- /dev/null +++ b/frontend/pweb/lib/providers/quotation/auto_refresh.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + + +class QuotationAutoRefreshController { + bool _enabled = true; + Timer? _timer; + DateTime? _scheduledAt; + DateTime? _triggeredAt; + + void setEnabled(bool enabled) { + if (_enabled == enabled) return; + _enabled = enabled; + } + + void sync({ + required bool isLoading, + required bool canRefresh, + required DateTime? expiresAt, + required Future Function() onRefresh, + }) { + if (!_enabled || isLoading || !canRefresh) { + _clearTimer(); + _scheduledAt = null; + _triggeredAt = null; + return; + } + + if (expiresAt == null) { + _clearTimer(); + _scheduledAt = null; + _triggeredAt = null; + return; + } + + final delay = expiresAt.difference(DateTime.now().toUtc()); + if (delay <= Duration.zero) { + if (_triggeredAt != null && _triggeredAt!.isAtSameMomentAs(expiresAt)) { + return; + } + _triggeredAt = expiresAt; + _clearTimer(); + onRefresh(); + return; + } + + if (_scheduledAt != null && + _scheduledAt!.isAtSameMomentAs(expiresAt) && + _timer?.isActive == true) { + return; + } + + _triggeredAt = null; + _clearTimer(); + _scheduledAt = expiresAt; + _timer = Timer(delay, () { + onRefresh(); + }); + } + + void reset() { + _enabled = false; + _scheduledAt = null; + _triggeredAt = null; + _clearTimer(); + } + + void dispose() { + _clearTimer(); + } + + void _clearTimer() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/frontend/pweb/lib/providers/quotation/quotation.dart b/frontend/pweb/lib/providers/quotation/quotation.dart new file mode 100644 index 00000000..658d9ebf --- /dev/null +++ b/frontend/pweb/lib/providers/quotation/quotation.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/auto_refresh_mode.dart'; +import 'package:pshared/models/payment/quote/status_type.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; + +import 'package:pweb/providers/quotation/auto_refresh.dart'; + + +class QuotationController extends ChangeNotifier { + QuotationProvider? _quotation; + AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on; + final QuotationAutoRefreshController _autoRefreshController = + QuotationAutoRefreshController(); + Timer? _ticker; + + void update(QuotationProvider quotation) { + if (identical(_quotation, quotation)) return; + _quotation?.removeListener(_handleQuotationChanged); + _quotation = quotation; + _quotation?.addListener(_handleQuotationChanged); + _handleQuotationChanged(); + } + + bool get isLoading => _quotation?.isLoading ?? false; + Exception? get error => _quotation?.error; + bool get canRefresh => _quotation?.canRefresh ?? false; + bool get isReady => _quotation?.isReady ?? false; + AutoRefreshMode get autoRefreshMode => _autoRefreshMode; + + DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt; + + Duration? get timeLeft { + final expiresAt = quoteExpiresAt; + if (expiresAt == null) return null; + return expiresAt.difference(DateTime.now().toUtc()); + } + + bool get isExpired { + final remaining = timeLeft; + if (remaining == null) return false; + return remaining <= Duration.zero; + } + + QuoteStatusType get quoteStatus { + if (isLoading) return QuoteStatusType.loading; + if (error != null) return QuoteStatusType.error; + if (_quotation?.quotation == null) return QuoteStatusType.missing; + if (isExpired) return QuoteStatusType.expired; + return QuoteStatusType.active; + } + + bool get hasLiveQuote => isReady && _quotation?.quotation != null && !isExpired; + + void setAutoRefreshMode(AutoRefreshMode mode) { + if (_autoRefreshMode == mode) return; + _autoRefreshMode = mode; + _syncAutoRefresh(); + notifyListeners(); + } + + void refreshQuotation() { + _quotation?.refreshQuotation(); + } + + void _handleQuotationChanged() { + _syncAutoRefresh(); + _syncTicker(); + notifyListeners(); + } + + void _syncTicker() { + final expiresAt = quoteExpiresAt; + if (expiresAt == null) { + _stopTicker(); + return; + } + + final remaining = expiresAt.difference(DateTime.now().toUtc()); + if (remaining <= Duration.zero) { + _stopTicker(); + return; + } + + _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) { + final expiresAt = quoteExpiresAt; + if (expiresAt == null) { + _stopTicker(); + return; + } + final remaining = expiresAt.difference(DateTime.now().toUtc()); + if (remaining <= Duration.zero) { + _stopTicker(); + } + notifyListeners(); + }); + } + + void _stopTicker() { + _ticker?.cancel(); + _ticker = null; + } + + void _syncAutoRefresh() { + final quotation = _quotation; + if (quotation == null) { + _autoRefreshController.reset(); + return; + } + + final isAutoRefreshEnabled = _autoRefreshMode == AutoRefreshMode.on; + _autoRefreshController.setEnabled(isAutoRefreshEnabled); + final canAutoRefresh = isAutoRefreshEnabled && quotation.canRefresh; + _autoRefreshController.sync( + isLoading: quotation.isLoading, + canRefresh: canAutoRefresh, + expiresAt: quoteExpiresAt, + onRefresh: _refreshQuotation, + ); + } + + Future _refreshQuotation() async { + await _quotation?.refreshQuotation(); + } + + @override + void dispose() { + _quotation?.removeListener(_handleQuotationChanged); + _autoRefreshController.dispose(); + _stopTicker(); + super.dispose(); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/quote_duration_format.dart b/frontend/pweb/lib/utils/quote_duration_format.dart new file mode 100644 index 00000000..6ba237e0 --- /dev/null +++ b/frontend/pweb/lib/utils/quote_duration_format.dart @@ -0,0 +1,31 @@ +import 'package:duration/duration.dart'; + +String formatQuoteDuration(Duration duration) { + final clampedDuration = duration.isNegative ? Duration.zero : duration; + final pretty = prettyDuration( + clampedDuration, + tersity: DurationTersity.second, + upperTersity: DurationTersity.hour, + abbreviated: true, + delimiter: ':', + spacer: '', + ); + final units = _extractHms(pretty); + final hours = units['h'] ?? 0; + final minutes = units['m'] ?? 0; + final seconds = units['s'] ?? 0; + + if (hours > 0) { + return '${hours.toString()}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + return '${minutes.toString()}:${seconds.toString().padLeft(2, '0')}'; +} + +Map _extractHms(String pretty) { + final matches = RegExp(r'(\d+)([hms])').allMatches(pretty); + final units = {}; + for (final match in matches) { + units[match.group(2)!] = int.parse(match.group(1)!); + } + return units; +} diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index 4705cf16..e4ad4c4a 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: flutter_multi_formatter: ^2.13.7 dotted_border: ^3.1.0 qr_flutter: ^4.1.0 + duration: ^4.0.3