diff --git a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart new file mode 100644 index 00000000..ed4349ca --- /dev/null +++ b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart @@ -0,0 +1,31 @@ +import 'package:pshared/api/requests/payment/base.dart'; + + +class InitiatePaymentsRequest extends PaymentBaseRequest { + final String quoteRef; + + const InitiatePaymentsRequest({ + required super.idempotencyKey, + super.metadata, + required this.quoteRef, + }); + + factory InitiatePaymentsRequest.fromJson(Map json) { + return InitiatePaymentsRequest( + idempotencyKey: json['idempotencyKey'] as String, + metadata: (json['metadata'] as Map?)?.map( + (key, value) => MapEntry(key, value as String), + ), + quoteRef: json['quoteRef'] as String, + ); + } + + @override + Map toJson() { + return { + 'idempotencyKey': idempotencyKey, + 'metadata': metadata, + 'quoteRef': quoteRef, + }; + } +} diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart index 29f9859b..5d9352f9 100644 --- a/frontend/pshared/lib/data/dto/payment/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -13,6 +13,8 @@ class PaymentDTO { final String? failureCode; final String? failureReason; final PaymentQuoteDTO? lastQuote; + final Map? metadata; + final String? createdAt; const PaymentDTO({ this.paymentRef, @@ -21,6 +23,8 @@ class PaymentDTO { this.failureCode, this.failureReason, this.lastQuote, + this.metadata, + this.createdAt, }); factory PaymentDTO.fromJson(Map json) => _$PaymentDTOFromJson(json); diff --git a/frontend/pshared/lib/data/dto/payment/quotes.dart b/frontend/pshared/lib/data/dto/payment/quotes.dart index 6a9174ad..71935cb3 100644 --- a/frontend/pshared/lib/data/dto/payment/quotes.dart +++ b/frontend/pshared/lib/data/dto/payment/quotes.dart @@ -5,19 +5,21 @@ import 'package:pshared/data/dto/payment/payment_quote.dart'; part 'quotes.g.dart'; - @JsonSerializable() class PaymentQuotesDTO { final String quoteRef; + final String? idempotencyKey; final PaymentQuoteAggregateDTO? aggregate; final List? quotes; const PaymentQuotesDTO({ required this.quoteRef, + this.idempotencyKey, this.aggregate, this.quotes, }); - factory PaymentQuotesDTO.fromJson(Map json) => _$PaymentQuotesDTOFromJson(json); + factory PaymentQuotesDTO.fromJson(Map json) => + _$PaymentQuotesDTOFromJson(json); Map toJson() => _$PaymentQuotesDTOToJson(this); } diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index b20b537e..1c5a15fc 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -11,6 +11,8 @@ extension PaymentDTOMapper on PaymentDTO { failureCode: failureCode, failureReason: failureReason, lastQuote: lastQuote?.toDomain(), + metadata: metadata, + createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), ); } @@ -22,5 +24,7 @@ extension PaymentMapper on Payment { failureCode: failureCode, failureReason: failureReason, lastQuote: lastQuote?.toDTO(), + metadata: metadata, + createdAt: createdAt?.toUtc().toIso8601String(), ); } diff --git a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart index 51117a2d..4d77f60c 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote/quotes.dart @@ -3,11 +3,10 @@ import 'package:pshared/data/mapper/payment/payment_quote.dart'; import 'package:pshared/data/mapper/payment/quote/aggregate.dart'; import 'package:pshared/models/payment/quote/quotes.dart'; - extension PaymentQuotesDTOMapper on PaymentQuotesDTO { PaymentQuotes toDomain({String? idempotencyKey}) => PaymentQuotes( quoteRef: quoteRef, - idempotencyKey: idempotencyKey, + idempotencyKey: idempotencyKey ?? this.idempotencyKey, aggregate: aggregate?.toDomain(), quotes: quotes?.map((quote) => quote.toDomain()).toList(), ); @@ -16,6 +15,7 @@ extension PaymentQuotesDTOMapper on PaymentQuotesDTO { extension PaymentQuotesMapper on PaymentQuotes { PaymentQuotesDTO toDTO() => PaymentQuotesDTO( quoteRef: quoteRef, + idempotencyKey: idempotencyKey, aggregate: aggregate?.toDTO(), quotes: quotes?.map((quote) => quote.toDTO()).toList(), ); diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 004687af..0d1c9058 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -8,6 +8,8 @@ class Payment { final String? failureCode; final String? failureReason; final PaymentQuote? lastQuote; + final Map? metadata; + final DateTime? createdAt; const Payment({ required this.paymentRef, @@ -16,5 +18,13 @@ class Payment { required this.failureCode, required this.failureReason, required this.lastQuote, + required this.metadata, + required this.createdAt, }); + + bool get isFailure { + if ((failureCode ?? '').trim().isNotEmpty) return true; + final normalized = (state ?? '').trim().toLowerCase(); + return normalized.contains('fail') || normalized.contains('cancel'); + } } diff --git a/frontend/pshared/lib/provider/payment/multiple/provider.dart b/frontend/pshared/lib/provider/payment/multiple/provider.dart new file mode 100644 index 00000000..b37c98d7 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/multiple/provider.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/payment/multiple/quotation.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/payment/multiple.dart'; +import 'package:pshared/utils/exception.dart'; + + +class MultiPaymentProvider extends ChangeNotifier { + late OrganizationsProvider _organization; + late MultiQuotationProvider _quotation; + + Resource> _payments = Resource(data: []); + bool _isLoaded = false; + + List get payments => _payments.data ?? []; + bool get isLoading => _payments.isLoading; + Exception? get error => _payments.error; + bool get isReady => + _isLoaded && !_payments.isLoading && _payments.error == null; + + void update( + OrganizationsProvider organization, + MultiQuotationProvider quotation, + ) { + _organization = organization; + _quotation = quotation; + } + + Future> pay({ + String? idempotencyKey, + Map? metadata, + }) async { + if (!_organization.isOrganizationSet) { + throw StateError('Organization is not set'); + } + + final quoteRef = _quotation.quotation?.quoteRef; + if (quoteRef == null || quoteRef.isEmpty) { + throw StateError('Multiple quotation reference is not set'); + } + + final expiresAt = _quotation.quoteExpiresAt; + if (expiresAt != null && expiresAt.isBefore(DateTime.now().toUtc())) { + throw StateError('Multiple quotation is expired'); + } + + _setResource(_payments.copyWith(isLoading: true, error: null)); + try { + final response = await MultiplePaymentsService.payByQuote( + _organization.current.id, + quoteRef, + idempotencyKey: idempotencyKey, + metadata: metadata, + ); + + _isLoaded = true; + _setResource( + _payments.copyWith(data: response, isLoading: false, error: null), + ); + } catch (e) { + _setResource( + _payments.copyWith(data: [], isLoading: false, error: toException(e)), + ); + } + + return _payments.data ?? []; + } + + void reset() { + _isLoaded = false; + _setResource(Resource(data: [])); + } + + void _setResource(Resource> payments) { + _payments = payments; + notifyListeners(); + } +} diff --git a/frontend/pshared/lib/provider/payment/multiple/quotation.dart b/frontend/pshared/lib/provider/payment/multiple/quotation.dart new file mode 100644 index 00000000..71ea87bf --- /dev/null +++ b/frontend/pshared/lib/provider/payment/multiple/quotation.dart @@ -0,0 +1,138 @@ +import 'package:flutter/foundation.dart'; + +import 'package:uuid/uuid.dart'; + +import 'package:pshared/api/requests/payment/quotes.dart'; +import 'package:pshared/data/mapper/payment/intent/payment.dart'; +import 'package:pshared/models/payment/intent.dart'; +import 'package:pshared/models/payment/quote/quotes.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/payment/multiple.dart'; +import 'package:pshared/utils/exception.dart'; + + +class MultiQuotationProvider extends ChangeNotifier { + OrganizationsProvider? _organizations; + String? _loadedOrganizationRef; + + Resource _quotation = Resource(data: null); + bool _isLoaded = false; + + List? _lastIntents; + bool _lastPreviewOnly = false; + Map? _lastMetadata; + + Resource get resource => _quotation; + PaymentQuotes? get quotation => _quotation.data; + bool get isLoading => _quotation.isLoading; + Exception? get error => _quotation.error; + bool get canRefresh => _lastIntents != null && _lastIntents!.isNotEmpty; + bool get isReady => + _isLoaded && !_quotation.isLoading && _quotation.error == null; + + DateTime? get quoteExpiresAt { + final quotes = quotation?.quotes; + if (quotes == null || quotes.isEmpty) return null; + + int? minExpiresAt; + for (final quote in quotes) { + final expiresAtUnixMs = quote.fxQuote?.expiresAtUnixMs; + if (expiresAtUnixMs == null) continue; + minExpiresAt = minExpiresAt == null + ? expiresAtUnixMs + : (expiresAtUnixMs < minExpiresAt ? expiresAtUnixMs : minExpiresAt); + } + + if (minExpiresAt == null) return null; + return DateTime.fromMillisecondsSinceEpoch(minExpiresAt, isUtc: true); + } + + void update(OrganizationsProvider organizations) { + _organizations = organizations; + if (!organizations.isOrganizationSet) { + reset(); + return; + } + + final orgRef = organizations.current.id; + if (_loadedOrganizationRef != orgRef) { + _loadedOrganizationRef = orgRef; + reset(); + } + } + + Future quotePayments( + List intents, { + bool previewOnly = false, + String? idempotencyKey, + Map? metadata, + }) async { + final organization = _organizations; + if (organization == null || !organization.isOrganizationSet) { + throw StateError('Organization is not set'); + } + if (intents.isEmpty) { + throw StateError('At least one payment intent is required'); + } + + _lastIntents = List.from(intents); + _lastPreviewOnly = previewOnly; + _lastMetadata = metadata == null + ? null + : Map.from(metadata); + + _setResource(_quotation.copyWith(isLoading: true, error: null)); + try { + final response = await MultiplePaymentsService.getQuotation( + organization.current.id, + QuotePaymentsRequest( + idempotencyKey: idempotencyKey ?? const Uuid().v4(), + metadata: metadata, + intents: intents.map((intent) => intent.toDTO()).toList(), + previewOnly: previewOnly, + ), + ); + + _isLoaded = true; + _setResource( + _quotation.copyWith(data: response, isLoading: false, error: null), + ); + } catch (e) { + _setResource( + _quotation.copyWith( + data: null, + isLoading: false, + error: toException(e), + ), + ); + } + + return _quotation.data; + } + + Future refreshQuotation() async { + final intents = _lastIntents; + if (intents == null || intents.isEmpty) return null; + + return quotePayments( + intents, + previewOnly: _lastPreviewOnly, + metadata: _lastMetadata, + ); + } + + void reset() { + _isLoaded = false; + _lastIntents = null; + _lastPreviewOnly = false; + _lastMetadata = null; + _quotation = Resource(data: null); + notifyListeners(); + } + + void _setResource(Resource quotation) { + _quotation = quotation; + notifyListeners(); + } +} diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index bfde3dbe..234d35fa 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -158,6 +158,28 @@ class PaymentsProvider with ChangeNotifier { notifyListeners(); } + void addPayments(List items, {bool prepend = true}) { + if (items.isEmpty) return; + final current = List.from(payments); + final existingRefs = {}; + for (final payment in current) { + final ref = payment.paymentRef; + if (ref != null && ref.isNotEmpty) { + existingRefs.add(ref); + } + } + + final newItems = items.where((payment) { + final ref = payment.paymentRef; + if (ref == null || ref.isEmpty) return true; + return !existingRefs.contains(ref); + }).toList(); + + if (newItems.isEmpty) return; + final combined = prepend ? [...newItems, ...current] : [...current, ...newItems]; + _applyResource(_resource.copyWith(data: combined, error: null), notify: true); + } + void _applyResource(Resource> newResource, {required bool notify}) { _resource = newResource; if (notify) notifyListeners(); diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index b205bb03..c03b9b94 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -7,6 +7,7 @@ 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'; import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/iban.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart'; @@ -44,13 +45,17 @@ class QuotationIntentBuilder { // 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, - ); + 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, + ); return PaymentIntent( kind: PaymentKind.payout, amount: amount, diff --git a/frontend/pshared/lib/service/payment/multiple.dart b/frontend/pshared/lib/service/payment/multiple.dart new file mode 100644 index 00000000..a181e68d --- /dev/null +++ b/frontend/pshared/lib/service/payment/multiple.dart @@ -0,0 +1,60 @@ +import 'package:logging/logging.dart'; + +import 'package:uuid/uuid.dart'; + +import 'package:pshared/api/requests/payment/initiate_payments.dart'; +import 'package:pshared/api/requests/payment/quotes.dart'; +import 'package:pshared/api/responses/payment/payments.dart'; +import 'package:pshared/api/responses/payment/quotes.dart'; +import 'package:pshared/data/mapper/payment/payment_response.dart'; +import 'package:pshared/data/mapper/payment/quote/quotes.dart'; +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/quote/quotes.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/services.dart'; + + +class MultiplePaymentsService { + static final _logger = Logger('service.payment.multiple'); + static const String _objectType = Services.payments; + + static Future getQuotation( + String organizationRef, + QuotePaymentsRequest request, + ) async { + _logger.fine('Quoting multiple payments for organization $organizationRef'); + final response = await AuthorizationService.getPOSTResponse( + _objectType, + '/multiquote/$organizationRef', + request.toJson(), + ); + + final parsed = PaymentQuotesResponse.fromJson(response); + return parsed.quote.toDomain(); + } + + static Future> payByQuote( + String organizationRef, + String quoteRef, { + String? idempotencyKey, + Map? metadata, + }) async { + _logger.fine( + 'Executing multiple payments for quote $quoteRef in $organizationRef', + ); + final request = InitiatePaymentsRequest( + idempotencyKey: idempotencyKey ?? const Uuid().v4(), + quoteRef: quoteRef, + metadata: metadata, + ); + + final response = await AuthorizationService.getPOSTResponse( + _objectType, + '/by-multiquote/$organizationRef', + request.toJson(), + ); + + final parsed = PaymentsResponse.fromJson(response); + return parsed.payments.map((payment) => payment.toDomain()).toList(); + } +} diff --git a/frontend/pshared/lib/service/payment/quotation.dart b/frontend/pshared/lib/service/payment/quotation.dart index 7055fc8a..756a894c 100644 --- a/frontend/pshared/lib/service/payment/quotation.dart +++ b/frontend/pshared/lib/service/payment/quotation.dart @@ -16,18 +16,24 @@ 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', + _objectType, + '/quote/$organizationRef', request.toJson(), ); final parsed = PaymentQuoteResponse.fromJson(response); return parsed.quote.toDomain(idempotencyKey: parsed.idempotencyKey); } - static Future getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async { + static Future getMultiQuotation( + String organizationRef, + QuotePaymentsRequest request, + ) async { _logger.fine('Quoting payments for organization $organizationRef'); final response = await AuthorizationService.getPOSTResponse( _objectType, @@ -35,7 +41,6 @@ class QuotationService { request.toJson(), ); final parsed = PaymentQuotesResponse.fromJson(response); - final idempotencyKey = response['idempotencyKey'] as String?; - return parsed.quote.toDomain(idempotencyKey: idempotencyKey); + return parsed.quote.toDomain(); } } diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 8422972d..d09d489c 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -10,6 +10,8 @@ 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/multiple/provider.dart'; +import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -18,6 +20,8 @@ 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/controllers/multiple_payouts.dart'; +import 'package:pweb/controllers/payment_page.dart'; import 'package:pweb/providers/quotation/quotation.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/pages/address_book/form/page.dart'; @@ -34,52 +38,126 @@ import 'package:pweb/widgets/error/snackbar.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/page.dart'; import 'package:pweb/utils/payment/availability.dart'; +import 'package:pweb/services/payments/csv_input.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - RouteBase payoutShellRoute() => ShellRoute( builder: (context, state, child) => MultiProvider( providers: [ - ChangeNotifierProxyProvider2( + ChangeNotifierProxyProvider2< + OrganizationsProvider, + RecipientsProvider, + PaymentMethodsProvider + >( create: (_) => PaymentMethodsProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), + update: (context, organizations, recipients, provider) => + provider!..updateProviders(organizations, recipients), ), - ChangeNotifierProxyProvider2( + ChangeNotifierProxyProvider2< + OrganizationsProvider, + RecipientsProvider, + RecipientMethodsCacheProvider + >( create: (_) => RecipientMethodsCacheProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), + update: (context, organizations, recipients, provider) => + provider!..updateProviders(organizations, recipients), ), - ChangeNotifierProxyProvider2( - create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first), - update: (context, recipients, methods, provider) => provider!..update( - recipients, - methods, - ), + ChangeNotifierProxyProvider2< + RecipientsProvider, + PaymentMethodsProvider, + PaymentFlowProvider + >( + create: (_) => + PaymentFlowProvider(initialType: enabledPaymentTypes.first), + update: (context, recipients, methods, provider) => + provider!..update(recipients, methods), ), - ChangeNotifierProvider( - create: (_) => PaymentAmountProvider(), - ), - ChangeNotifierProxyProvider6( + ChangeNotifierProvider(create: (_) => PaymentAmountProvider()), + ChangeNotifierProxyProvider6< + OrganizationsProvider, + PaymentAmountProvider, + WalletsController, + PaymentFlowProvider, + RecipientsProvider, + PaymentMethodsProvider, + QuotationProvider + >( create: (_) => QuotationProvider(), - update: (_, organization, payment, wallet, flow, recipients, methods, provider) => - provider!..update(organization, payment, wallet, flow, recipients, methods), + 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( + ChangeNotifierProxyProvider2< + OrganizationsProvider, + QuotationProvider, + PaymentProvider + >( create: (_) => PaymentProvider(), - update: (context, organization, quotation, provider) => provider!..update( - organization, - quotation, - ), + update: (context, organization, quotation, provider) => + provider!..update(organization, quotation), + ), + ChangeNotifierProxyProvider4< + PaymentProvider, + QuotationProvider, + PaymentFlowProvider, + RecipientsProvider, + PaymentPageController + >( + create: (_) => PaymentPageController(), + update: (context, payment, quotation, flow, recipients, controller) => + controller!..update(payment, quotation, flow, recipients), + ), + ChangeNotifierProxyProvider< + OrganizationsProvider, + MultiQuotationProvider + >( + create: (_) => MultiQuotationProvider(), + update: (context, organization, provider) => + provider!..update(organization), + ), + ChangeNotifierProxyProvider2< + OrganizationsProvider, + MultiQuotationProvider, + MultiPaymentProvider + >( + create: (_) => MultiPaymentProvider(), + update: (context, organization, quotation, provider) => + provider!..update(organization, quotation), + ), + ChangeNotifierProxyProvider3< + WalletsController, + MultiQuotationProvider, + MultiPaymentProvider, + MultiplePayoutsController + >( + create: (_) => + MultiplePayoutsController(csvInput: WebCsvInputService()), + update: (context, wallets, quotation, payment, provider) => + provider!..update(wallets, quotation, payment), ), ], - child: PageSelector( - child: child, - routerState: state, - ), + child: PageSelector(child: child, routerState: state), ), routes: [ GoRoute( @@ -135,11 +213,11 @@ RouteBase payoutShellRoute() => ShellRoute( ); if (!confirmed) return; await executeActionWithNotification( - context: context, - action: () async => - context.read().delete(recipient.id), - successMessage: loc.recipientDeletedSuccessfully, - errorMessage: loc.errorDeleteRecipient, + context: context, + action: () async => + context.read().delete(recipient.id), + successMessage: loc.recipientDeletedSuccessfully, + errorMessage: loc.errorDeleteRecipient, ); }, ), @@ -149,9 +227,7 @@ RouteBase payoutShellRoute() => ShellRoute( GoRoute( name: PayoutRoutes.invitations, path: PayoutRoutes.invitationsPath, - pageBuilder: (_, _) => const NoTransitionPage( - child: InvitationsPage(), - ), + pageBuilder: (_, _) => const NoTransitionPage(child: InvitationsPage()), ), GoRoute( name: PayoutRoutes.addRecipient, @@ -187,9 +263,8 @@ RouteBase payoutShellRoute() => ShellRoute( GoRoute( name: PayoutRoutes.settings, path: PayoutRoutes.settingsPath, - pageBuilder: (_, _) => const NoTransitionPage( - child: ProfileSettingsPage(), - ), + pageBuilder: (_, _) => + const NoTransitionPage(child: ProfileSettingsPage()), ), GoRoute( name: PayoutRoutes.reports, @@ -249,16 +324,10 @@ void _startPayment( required PayoutDestination returnTo, }) { context.read().setCurrentObject(recipient?.id); - context.pushToPayment( - paymentType: paymentType, - returnTo: returnTo, - ); + context.pushToPayment(paymentType: paymentType, returnTo: returnTo); } -void _openAddRecipient( - BuildContext context, { - Recipient? recipient, -}) { +void _openAddRecipient(BuildContext context, {Recipient? recipient}) { context.read().setCurrentObject(recipient?.id); context.pushNamed(PayoutRoutes.addRecipient); } diff --git a/frontend/pweb/lib/controllers/multiple_payouts.dart b/frontend/pweb/lib/controllers/multiple_payouts.dart new file mode 100644 index 00000000..191e24fe --- /dev/null +++ b/frontend/pweb/lib/controllers/multiple_payouts.dart @@ -0,0 +1,244 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/models/money.dart'; +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/provider/payment/multiple/provider.dart'; +import 'package:pshared/provider/payment/multiple/quotation.dart'; +import 'package:pshared/utils/currency.dart'; + +import 'package:pweb/models/multiple_payouts/csv_row.dart'; +import 'package:pweb/models/multiple_payouts/state.dart'; +import 'package:pweb/services/payments/csv_input.dart'; +import 'package:pweb/utils/payment/multiple_csv_parser.dart'; +import 'package:pweb/utils/payment/multiple_intent_builder.dart'; + + +class MultiplePayoutsController extends ChangeNotifier { + final CsvInputService _csvInput; + final MultipleCsvParser _csvParser; + final MultipleIntentBuilder _intentBuilder; + + WalletsController? _wallets; + MultiQuotationProvider? _quotation; + MultiPaymentProvider? _payment; + + MultiplePayoutsState _state = MultiplePayoutsState.idle; + String? _selectedFileName; + List _rows = const []; + int _sentCount = 0; + Exception? _error; + + MultiplePayoutsController({ + required CsvInputService csvInput, + MultipleCsvParser? csvParser, + MultipleIntentBuilder? intentBuilder, + }) : _csvInput = csvInput, + _csvParser = csvParser ?? MultipleCsvParser(), + _intentBuilder = intentBuilder ?? MultipleIntentBuilder(); + + void update( + WalletsController wallets, + MultiQuotationProvider quotation, + MultiPaymentProvider payment, + ) { + _wallets = wallets; + _quotation = quotation; + _payment = payment; + } + + MultiplePayoutsState get state => _state; + String? get selectedFileName => _selectedFileName; + List get rows => List.unmodifiable(_rows); + int get sentCount => _sentCount; + Exception? get error => _error; + + bool get isQuoting => _state == MultiplePayoutsState.quoting; + bool get isSending => _state == MultiplePayoutsState.sending; + bool get isBusy => isQuoting || isSending; + + bool get canSend { + if (isBusy || _rows.isEmpty) return false; + final quoteRef = _quotation?.quotation?.quoteRef; + return quoteRef != null && quoteRef.isNotEmpty; + } + + Money? get aggregateDebitAmount { + if (_rows.isEmpty) return null; + return _moneyForSourceCurrency( + _quotation?.quotation?.aggregate?.debitAmounts, + ); + } + + Money? get requestedSentAmount { + if (_rows.isEmpty) return null; + const currency = 'RUB'; + + double total = 0; + for (final row in _rows) { + final value = double.tryParse(row.amount); + if (value == null) return null; + total += value; + } + return Money(amount: amountToString(total), currency: currency); + } + + Money? get aggregateSettlementAmount { + if (_rows.isEmpty) return null; + return _moneyForSourceCurrency( + _quotation?.quotation?.aggregate?.expectedSettlementAmounts, + ); + } + + Money? get aggregateFeeAmount { + if (_rows.isEmpty) return null; + return _moneyForSourceCurrency( + _quotation?.quotation?.aggregate?.expectedFeeTotals, + ); + } + + double? get aggregateFeePercent { + final debit = aggregateDebitAmount; + final fee = aggregateFeeAmount; + if (debit == null || fee == null) return null; + + final debitValue = double.tryParse(debit.amount); + final feeValue = double.tryParse(fee.amount); + if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null; + if (debitValue == null || feeValue == null || debitValue <= 0) return null; + return (feeValue / debitValue) * 100; + } + + Future pickAndQuote() async { + if (isBusy) return; + + final wallets = _wallets; + final quotation = _quotation; + if (wallets == null || quotation == null) { + _setErrorObject( + StateError('Multiple payouts dependencies are not ready'), + ); + return; + } + + try { + _setState(MultiplePayoutsState.quoting); + _error = null; + _sentCount = 0; + + final picked = await _csvInput.pickCsv(); + if (picked == null) { + return; + } + + final rows = _csvParser.parseRows(picked.content); + final intents = _intentBuilder.buildIntents(wallets, rows); + + _selectedFileName = picked.name; + _rows = rows; + + await quotation.quotePayments( + intents, + metadata: { + 'upload_filename': picked.name, + 'upload_rows': rows.length.toString(), + ...?_uploadAmountMetadata(), + }, + ); + + if (quotation.error != null) { + _setErrorObject(quotation.error!); + } + } catch (e) { + _setErrorObject(e); + } finally { + _setState(MultiplePayoutsState.idle); + } + } + + Future> send() async { + if (isBusy) return const []; + + final payment = _payment; + if (payment == null) { + _setErrorObject( + StateError('Multiple payouts payment provider is not ready'), + ); + return const []; + } + if (!canSend) { + _setErrorObject( + StateError('Upload CSV and wait for quote before sending'), + ); + return const []; + } + + try { + _setState(MultiplePayoutsState.sending); + _error = null; + + final result = await payment.pay( + metadata: { + ...?_selectedFileName == null + ? null + : {'upload_filename': _selectedFileName!}, + 'upload_rows': _rows.length.toString(), + ...?_uploadAmountMetadata(), + }, + ); + + _sentCount = result.length; + return result; + } catch (e) { + _setErrorObject(e); + return const []; + } finally { + _setState(MultiplePayoutsState.idle); + } + } + + void removeUploadedFile() { + if (isBusy) return; + + _selectedFileName = null; + _rows = const []; + _sentCount = 0; + _error = null; + notifyListeners(); + } + + void _setState(MultiplePayoutsState value) { + _state = value; + notifyListeners(); + } + + void _setErrorObject(Object error) { + _error = error is Exception ? error : Exception(error.toString()); + notifyListeners(); + } + + Map? _uploadAmountMetadata() { + final sentAmount = requestedSentAmount; + if (sentAmount == null) return null; + return { + 'upload_amount': sentAmount.amount, + 'upload_currency': sentAmount.currency, + }; + } + + Money? _moneyForSourceCurrency(List? values) { + if (values == null || values.isEmpty) return null; + + final selectedWallet = _wallets?.selectedWallet; + if (selectedWallet != null) { + final sourceCurrency = currencyCodeToString(selectedWallet.currency); + for (final value in values) { + if (value.currency.toUpperCase() == sourceCurrency.toUpperCase()) { + return value; + } + } + } + + return values.first; + } +} diff --git a/frontend/pweb/lib/controllers/payment_page.dart b/frontend/pweb/lib/controllers/payment_page.dart new file mode 100644 index 00000000..5ea7eaf3 --- /dev/null +++ b/frontend/pweb/lib/controllers/payment_page.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/provider/payment/flow.dart'; +import 'package:pshared/provider/payment/provider.dart'; +import 'package:pshared/provider/payment/quotation/quotation.dart'; +import 'package:pshared/provider/recipient/provider.dart'; + + +class PaymentPageController extends ChangeNotifier { + PaymentProvider? _payment; + QuotationProvider? _quotation; + PaymentFlowProvider? _flow; + RecipientsProvider? _recipients; + + bool _isSending = false; + Exception? _error; + + bool get isSending => _isSending; + Exception? get error => _error; + + void update( + PaymentProvider payment, + QuotationProvider quotation, + PaymentFlowProvider flow, + RecipientsProvider recipients, + ) { + _payment = payment; + _quotation = quotation; + _flow = flow; + _recipients = recipients; + } + + Future sendPayment() async { + if (_isSending) return false; + final payment = _payment; + if (payment == null) { + _setError(StateError('Payment provider is not ready')); + return false; + } + + try { + _setSending(true); + _error = null; + final result = await payment.pay(); + return result != null && payment.error == null; + } catch (e) { + _setError(e); + return false; + } finally { + _setSending(false); + } + } + + void resetAfterSuccess() { + _quotation?.reset(); + _payment?.reset(); + _flow?.setManualPaymentData(null); + _recipients?.setCurrentObject(null); + } + + void _setSending(bool value) { + if (_isSending == value) return; + _isSending = value; + notifyListeners(); + } + + void _setError(Object error) { + _error = error is Exception ? error : Exception(error.toString()); + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index b99854e4..5e23591a 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -335,6 +335,11 @@ "description": "Table column header for file name" }, + "rowsColumn": "Rows", + "@rowsColumn": { + "description": "Table column header for row count" + }, + "amountColumn": "Amount", "@amountColumn": { "description": "Table column header for the original amount" @@ -562,6 +567,15 @@ } } }, + "recipientsWillReceive": "Recipients will receive: {amount}", + "@recipientsWillReceive": { + "description": "Label showing how much the recipients will receive", + "placeholders": { + "amount": { + "type": "String" + } + } + }, "total": "Total: {total}", "@total": { diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 8c2a92dc..be6590a2 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -335,6 +335,11 @@ "description": "Заголовок столбца таблицы для имени файла" }, + "rowsColumn": "Строки", + "@rowsColumn": { + "description": "Заголовок столбца таблицы для количества строк" + }, + "amountColumn": "Сумма", "@amountColumn": { "description": "Заголовок столбца таблицы для исходной суммы" @@ -563,6 +568,16 @@ } }, + "recipientsWillReceive": "Получатели получат: {amount}", + "@recipientsWillReceive": { + "description": "Метка, показывающая, сколько получат получатели", + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "total": "Итого: {total}", "@total": { "description": "Метка, показывающая общую сумму транзакции", diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index 9e2570b6..b1ee2638 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -29,19 +29,18 @@ import 'package:pweb/app/app.dart'; import 'package:pweb/pages/invitations/widgets/list/view_model.dart'; import 'package:pweb/app/timeago.dart'; import 'package:pweb/providers/two_factor.dart'; -import 'package:pweb/providers/upload_history.dart'; import 'package:pweb/providers/wallet_transactions.dart'; -import 'package:pweb/services/payments/history.dart'; import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/wallet_transactions.dart'; import 'package:pweb/providers/account.dart'; - void _setupLogging() { - Logger.root.level = Level.ALL; + Logger.root.level = Level.ALL; Logger.root.onRecord.listen((record) { // ignore: avoid_print - print('${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); + print( + '${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}', + ); }); } @@ -50,7 +49,6 @@ void main() async { await Constants.initialize(); await PosthogService.initialize(); - _setupLogging(); setUrlStrategy(PathUrlStrategy()); @@ -62,54 +60,68 @@ void main() async { ChangeNotifierProvider(create: (_) => LocaleProvider(null)), ChangeNotifierProxyProvider( create: (_) => PwebAccountProvider(), - update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider), + update: (context, localeProvider, provider) => + provider!..updateProvider(localeProvider), ), ChangeNotifierProxyProvider( create: (_) => TwoFactorProvider(), - update: (context, accountProvider, provider) => provider!..update(accountProvider), + update: (context, accountProvider, provider) => + provider!..update(accountProvider), ), ChangeNotifierProvider(create: (_) => OrganizationsProvider()), ChangeNotifierProxyProvider( create: (_) => PermissionsProvider(), - update: (context, orgnization, provider) => provider!..update(orgnization), + update: (context, orgnization, provider) => + provider!..update(orgnization), ), ChangeNotifierProxyProvider( create: (_) => EmployeesProvider(), - update: (context, organizations, provider) => provider!..updateProviders(organizations), + update: (context, organizations, provider) => + provider!..updateProviders(organizations), ), ChangeNotifierProxyProvider( create: (_) => PaymentsProvider(), - update: (context, organizations, provider) => provider!..update(organizations), + update: (context, organizations, provider) => + provider!..update(organizations), ), ChangeNotifierProvider(create: (_) => EmailVerificationProvider()), - - ChangeNotifierProvider( - create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(), - ), ChangeNotifierProxyProvider( create: (_) => RecipientsProvider(), - update: (context, organizations, provider) => provider!..updateProviders(organizations), + update: (context, organizations, provider) => + provider!..updateProviders(organizations), ), ChangeNotifierProxyProvider( create: (_) => InvitationsProvider(), - update: (context, organizations, provider) => provider!..updateProviders(organizations), + update: (context, organizations, provider) => + provider!..updateProviders(organizations), ), - ChangeNotifierProxyProvider2( - create: (_) => PaymentMethodsProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), - ), - ChangeNotifierProvider( - create: (_) => InvitationListViewModel(), + ChangeNotifierProxyProvider2< + OrganizationsProvider, + RecipientsProvider, + PaymentMethodsProvider + >( + create: (_) => PaymentMethodsProvider(), + update: (context, organizations, recipients, provider) => + provider!..updateProviders(organizations, recipients), ), + ChangeNotifierProvider(create: (_) => InvitationListViewModel()), ChangeNotifierProxyProvider( create: (_) => WalletsProvider(ApiWalletsService()), - update: (context, organizations, provider) => provider!..update(organizations), + update: (context, organizations, provider) => + provider!..update(organizations), ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider< + OrganizationsProvider, + LedgerAccountsProvider + >( create: (_) => LedgerAccountsProvider(LedgerService()), - update: (context, organizations, provider) => provider!..update(organizations), + update: (context, organizations, provider) => + provider!..update(organizations), ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider< + LedgerAccountsProvider, + LedgerBalanceMaskController + >( create: (_) => LedgerBalanceMaskController(), update: (context, ledger, controller) => controller!..update(ledger), ), @@ -118,11 +130,12 @@ void main() async { update: (_, wallets, controller) => controller!..update(wallets), ), ChangeNotifierProvider( - create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(), + create: (_) => + WalletTransactionsProvider(MockWalletTransactionsService()) + ..load(), ), ], child: const PayApp(), ), - ); } diff --git a/frontend/pweb/lib/models/multiple_payouts/csv_row.dart b/frontend/pweb/lib/models/multiple_payouts/csv_row.dart new file mode 100644 index 00000000..7fa773d5 --- /dev/null +++ b/frontend/pweb/lib/models/multiple_payouts/csv_row.dart @@ -0,0 +1,17 @@ +class CsvPayoutRow { + final String pan; + final String firstName; + final String lastName; + final int expMonth; + final int expYear; + final String amount; + + const CsvPayoutRow({ + required this.pan, + required this.firstName, + required this.lastName, + required this.expMonth, + required this.expYear, + required this.amount, + }); +} diff --git a/frontend/pweb/lib/models/multiple_payouts/state.dart b/frontend/pweb/lib/models/multiple_payouts/state.dart new file mode 100644 index 00000000..2d539ea2 --- /dev/null +++ b/frontend/pweb/lib/models/multiple_payouts/state.dart @@ -0,0 +1 @@ +enum MultiplePayoutsState { idle, quoting, sending } diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index 18038981..17a25260 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -72,17 +72,16 @@ class _DashboardPageState extends State { icon: Icons.person_add, ), ), - //TODO bring back multiple payouts - // const SizedBox(width: AppSpacing.small), - // Expanded( - // flex: 0, - // child: TransactionRefButton( - // onTap: () => _setActive(false), - // isActive: _showContainerMultiple, - // label: l10n.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), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart index 8a169a4f..a5635201 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/csv.dart @@ -1,5 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/source_quote_panel.dart'; +import 'package:pweb/pages/dashboard/payouts/multiple/upload_panel.dart'; + import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -8,11 +16,11 @@ class UploadCSVSection extends StatelessWidget { static const double _verticalSpacing = 10; static const double _iconTextSpacing = 5; - static const double _buttonVerticalPadding = 12; - static const double _buttonHorizontalPadding = 24; @override Widget build(BuildContext context) { + final controller = context.watch(); + final walletsController = context.watch(); final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; @@ -34,34 +42,57 @@ class UploadCSVSection extends StatelessWidget { ), const SizedBox(height: _verticalSpacing), Container( - height: 140, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: theme.colorScheme.outline), borderRadius: BorderRadius.circular(8), ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () {}, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: _buttonHorizontalPadding, - vertical: _buttonVerticalPadding, + child: LayoutBuilder( + builder: (context, constraints) { + final useHorizontal = constraints.maxWidth >= 760; + if (!useHorizontal) { + return Column( + children: [ + UploadPanel( + controller: controller, + theme: theme, + l10n: l10n, + ), + const SizedBox(height: 12), + SourceQuotePanel( + controller: controller, + walletsController: walletsController, + theme: theme, + l10n: l10n, + ), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: UploadPanel( + controller: controller, + theme: theme, + l10n: l10n, ), ), - child: Text(l10n.upload), - ), - const SizedBox(height: 8), - Text( - l10n.hintUpload, - style: const TextStyle(fontSize: 12), - ), - ], - ), + const SizedBox(width: 12), + Expanded( + flex: 5, + child: SourceQuotePanel( + controller: controller, + walletsController: walletsController, + theme: theme, + l10n: l10n, + ), + ), + ], + ); + }, ), ), ], diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart index 333a09ab..fa333a2c 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/form.dart @@ -1,13 +1,17 @@ class MultiplePayoutRow { - final String token; + final String pan; + final String firstName; + final String lastName; + final int expMonth; + final int expYear; final String amount; - final String currency; - final String comment; const MultiplePayoutRow({ - required this.token, + required this.pan, + required this.firstName, + required this.lastName, + required this.expMonth, + required this.expYear, required this.amount, - required this.currency, - required this.comment, }); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart index 056109d2..6db4994e 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/history.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:intl/intl.dart'; -import 'package:pweb/providers/upload_history.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/payments.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; class UploadHistorySection extends StatelessWidget { @@ -14,18 +17,28 @@ class UploadHistorySection extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); + final provider = context.watch(); final theme = Theme.of(context); final l10 = AppLocalizations.of(context)!; - + final dateFormat = DateFormat.yMMMd().add_Hm(); if (provider.isLoading) { return const Center(child: CircularProgressIndicator()); } if (provider.error != null) { - return Text(l10.notificationError(provider.error ?? l10.noErrorInformation)); + return Text( + l10.notificationError(provider.error ?? l10.noErrorInformation), + ); } - final items = provider.data ?? []; + final items = List.of(provider.payments); + items.sort((a, b) { + final left = a.createdAt; + final right = b.createdAt; + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); return Column( children: [ @@ -36,33 +49,72 @@ class UploadHistorySection extends StatelessWidget { Text(l10.uploadHistory, style: theme.textTheme.bodyLarge), ], ), - DataTable( - columns: [ - DataColumn(label: Text(l10.fileNameColumn)), - DataColumn(label: Text(l10.colStatus)), - DataColumn(label: Text(l10.dateColumn)), - DataColumn(label: Text(l10.details)), - ], - rows: items.map((file) { - final isError = file.status == "Error"; - final statusColor = isError ? Colors.red : Colors.green; - return DataRow( - cells: [ - DataCell(Text(file.name)), - DataCell(Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor.withAlpha(20), - borderRadius: BorderRadius.circular(_radius), + const SizedBox(height: 8), + if (items.isEmpty) + Align( + alignment: Alignment.centerLeft, + child: Text( + l10.walletHistoryEmpty, + style: theme.textTheme.bodyMedium, + ), + ) + else + DataTable( + columns: [ + DataColumn(label: Text(l10.fileNameColumn)), + DataColumn(label: Text(l10.rowsColumn)), + DataColumn(label: Text(l10.dateColumn)), + DataColumn(label: Text(l10.amountColumn)), + DataColumn(label: Text(l10.statusColumn)), + ], + rows: items.map((payment) { + final metadata = payment.metadata; + final state = payment.state ?? '-'; + final statusColor = + payment.isFailure ? Colors.red : Colors.green; + final fileName = metadata?['upload_filename']; + final fileNameText = + (fileName == null || fileName.isEmpty) ? '-' : fileName; + final rows = metadata?['upload_rows']; + final rowsText = (rows == null || rows.isEmpty) ? '-' : rows; + final createdAt = payment.createdAt; + final dateText = createdAt == null + ? '-' + : dateFormat.format(createdAt.toLocal()); + final amountValue = metadata?['upload_amount']; + final amountCurrency = metadata?['upload_currency']; + final fallbackAmount = payment.lastQuote?.debitAmount; + final amountText = (amountValue == null || amountValue.isEmpty) + ? (fallbackAmount == null + ? '-' + : '${fallbackAmount.amount} ${fallbackAmount.currency}') + : (amountCurrency == null || amountCurrency.isEmpty + ? amountValue + : '$amountValue $amountCurrency'); + + return DataRow( + cells: [ + DataCell(Text(fileNameText)), + DataCell(Text(rowsText)), + DataCell(Text(dateText)), + DataCell(Text(amountText)), + DataCell( + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withAlpha(20), + borderRadius: BorderRadius.circular(_radius), + ), + child: Text(state, style: TextStyle(color: statusColor)), + ), ), - child: Text(file.status, style: TextStyle(color: statusColor)), - )), - DataCell(Text(file.time)), - DataCell(TextButton(onPressed: () {}, child: Text(l10.showDetails))), - ], - ); - }).toList(), - ), + ], + ); + }).toList(), + ), ], ); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart index ce39fce1..877285f7 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sample.dart @@ -1,18 +1,47 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pshared/models/file/downloaded_file.dart'; + import 'package:pweb/pages/dashboard/payouts/multiple/form.dart'; +import 'package:pweb/utils/download.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; class FileFormatSampleSection extends StatelessWidget { const FileFormatSampleSection({super.key}); static final List sampleRows = [ - MultiplePayoutRow(token: "d921...161", amount: "500", currency: "RUB", comment: "cashback001"), - MultiplePayoutRow(token: "d921...162", amount: "100", currency: "USD", comment: "cashback002"), - MultiplePayoutRow(token: "d921...163", amount: "120", currency: "EUR", comment: "cashback003"), + MultiplePayoutRow( + pan: "9022****11", + firstName: "Alex", + lastName: "Ivanov", + expMonth: 12, + expYear: 27, + amount: "500", + ), + MultiplePayoutRow( + pan: "9022****12", + firstName: "Maria", + lastName: "Sokolova", + expMonth: 7, + expYear: 26, + amount: "100", + ), + MultiplePayoutRow( + pan: "9022****13", + firstName: "Dmitry", + lastName: "Smirnov", + expMonth: 3, + expYear: 28, + amount: "120", + ), ]; + static const String _sampleFileName = 'sample.csv'; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -41,7 +70,7 @@ class FileFormatSampleSection extends StatelessWidget { _buildDataTable(l10n), const SizedBox(height: 10), TextButton( - onPressed: () {}, + onPressed: _downloadSampleCsv, style: TextButton.styleFrom(padding: EdgeInsets.zero), child: Text(l10n.downloadSampleCSV, style: linkStyle), ), @@ -53,19 +82,44 @@ class FileFormatSampleSection extends StatelessWidget { return DataTable( columnSpacing: 20, columns: [ - DataColumn(label: Text(l10n.tokenColumn)), + DataColumn(label: Text(l10n.cardNumberColumn)), + DataColumn(label: Text(l10n.firstName)), + DataColumn(label: Text(l10n.lastName)), + DataColumn(label: Text(l10n.expiryDate)), DataColumn(label: Text(l10n.amount)), - DataColumn(label: Text(l10n.currency)), - DataColumn(label: Text(l10n.comment)), ], rows: sampleRows.map((row) { - return DataRow(cells: [ - DataCell(Text(row.token)), - DataCell(Text(row.amount)), - DataCell(Text(row.currency)), - DataCell(Text(row.comment)), - ]); + return DataRow( + cells: [ + DataCell(Text(row.pan)), + DataCell(Text(row.firstName)), + DataCell(Text(row.lastName)), + DataCell( + Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'), + ), + DataCell(Text(row.amount)), + ], + ); }).toList(), ); } -} \ No newline at end of file + + Future _downloadSampleCsv() async { + final rows = [ + 'pan,first_name,last_name,exp_month,exp_year,amount', + ...sampleRows.map( + (row) => + '${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}', + ), + ]; + final content = rows.join('\n'); + + await downloadFile( + DownloadedFile( + bytes: utf8.encode(content), + filename: _sampleFileName, + mimeType: 'text/csv;charset=utf-8', + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/source_quote_panel.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/source_quote_panel.dart new file mode 100644 index 00000000..d568b37a --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/source_quote_panel.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/models/money.dart'; +import 'package:pshared/utils/currency.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class SourceQuotePanel extends StatelessWidget { + const SourceQuotePanel({ + super.key, + required this.controller, + required this.walletsController, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final WalletsController walletsController; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final wallets = walletsController.wallets; + final selectedWalletRef = walletsController.selectedWalletRef; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.sourceOfFunds, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + if (wallets.isEmpty) + Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall) + else + DropdownButtonFormField( + initialValue: selectedWalletRef, + isExpanded: true, + decoration: InputDecoration( + labelText: l10n.whereGetMoney, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: wallets + .map( + (wallet) => DropdownMenuItem( + value: wallet.id, + child: Text( + '${wallet.name} · ${amountToString(wallet.balance)} ${currencyCodeToString(wallet.currency)}', + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(growable: false), + onChanged: controller.isBusy + ? null + : (value) { + if (value == null) return; + walletsController.selectWalletByRef(value); + }, + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + Text( + controller.aggregateDebitAmount == null + ? l10n.quoteUnavailable + : l10n.quoteActive, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.sentAmount(_sentAmountLabel(controller)), + style: theme.textTheme.bodyMedium, + ), + Text( + l10n.recipientsWillReceive( + _moneyLabel(controller.aggregateSettlementAmount), + ), + style: theme.textTheme.bodyMedium, + ), + Text( + controller.aggregateFeePercent == null + ? l10n.fee(_moneyLabel(controller.aggregateFeeAmount)) + : '${l10n.fee(_moneyLabel(controller.aggregateFeeAmount))} (${controller.aggregateFeePercent!.toStringAsFixed(2)}%)', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ); + } + + String _moneyLabel(Money? money) { + if (money == null) return '-'; + return '${money.amount} ${money.currency}'; + } + + String _sentAmountLabel(MultiplePayoutsController controller) { + final requested = controller.requestedSentAmount; + final sourceDebit = controller.aggregateDebitAmount; + + if (requested == null && sourceDebit == null) return '-'; + if (requested == null) return _moneyLabel(sourceDebit); + if (sourceDebit == null) return _moneyLabel(requested); + + if (requested.currency.toUpperCase() == + sourceDebit.currency.toUpperCase()) { + return _moneyLabel(sourceDebit); + } + return '${_moneyLabel(requested)} (${_moneyLabel(sourceDebit)})'; + } +} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/upload_panel.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/upload_panel.dart new file mode 100644 index 00000000..641ed19c --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/upload_panel.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/payment/payments.dart'; + +import 'package:pweb/controllers/multiple_payouts.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +//TODO this file is too long +class UploadPanel extends StatelessWidget { + const UploadPanel({ + super.key, + required this.controller, + required this.theme, + required this.l10n, + }); + + final MultiplePayoutsController controller; + final ThemeData theme; + final AppLocalizations l10n; + + static const double _buttonVerticalPadding = 12; + static const double _buttonHorizontalPadding = 24; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + ElevatedButton( + onPressed: controller.isBusy + ? null + : () => context + .read() + .pickAndQuote(), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: _buttonHorizontalPadding, + vertical: _buttonVerticalPadding, + ), + ), + child: Text(l10n.upload), + ), + ElevatedButton( + onPressed: controller.canSend + ? () => _handleSend(context) + : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: _buttonHorizontalPadding, + vertical: _buttonVerticalPadding, + ), + ), + child: Text(l10n.send), + ), + ], + ), + const SizedBox(height: 8), + Text( + l10n.hintUpload, + style: const TextStyle(fontSize: 12), + textAlign: TextAlign.center, + ), + if (controller.isQuoting || controller.isSending) ...[ + const SizedBox(height: 10), + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + if (controller.selectedFileName != null) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + '${controller.selectedFileName} · ${controller.rows.length}', + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: l10n.close, + visualDensity: VisualDensity.compact, + onPressed: controller.isBusy + ? null + : () => context + .read() + .removeUploadedFile(), + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + ], + if (controller.sentCount > 0) ...[ + const SizedBox(height: 8), + Text( + '${l10n.payout}: ${controller.sentCount}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + if (controller.error != null) ...[ + const SizedBox(height: 8), + Text( + controller.error.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ); + } + + Future _handleSend(BuildContext context) async { + final paymentsProvider = context.read(); + final result = await controller.send(); + paymentsProvider.addPayments(result); + await paymentsProvider.refresh(); + + if (!context.mounted) return; + + final isSuccess = controller.error == null && result.isNotEmpty; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + isSuccess + ? l10n.paymentStatusSuccessTitle + : l10n.paymentStatusFailureTitle, + ), + content: Text( + isSuccess + ? l10n.paymentStatusSuccessMessage + : l10n.paymentStatusFailureMessage, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + + controller.removeUploadedFile(); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart index 72d59968..aa2091df 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart @@ -79,9 +79,9 @@ class InvitationFormFields extends StatelessWidget { SizedBox( width: _fieldWidth, child: TextFormField( - controller: firstNameController, + controller: lastNameController, decoration: InputDecoration( - labelText: loc.firstName, + labelText: loc.lastName, prefixIcon: const Icon(Icons.person_outline), ), ), @@ -89,9 +89,9 @@ class InvitationFormFields extends StatelessWidget { SizedBox( width: _fieldWidth, child: TextFormField( - controller: lastNameController, + controller: firstNameController, decoration: InputDecoration( - labelText: loc.lastName, + labelText: loc.firstName, prefixIcon: const Icon(Icons.person_outline), ), ), diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 6b1770de..9c1e5d58 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -11,10 +11,12 @@ import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/payment_methods/payment_page/body.dart'; +import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/utils/recipient/filtering.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/services/posthog.dart'; import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; +import 'package:pweb/controllers/payment_page.dart'; class PaymentPage extends StatefulWidget { @@ -92,19 +94,23 @@ class _PaymentPageState extends State { }); } - void _handleSendPayment() { + Future _handleSendPayment() async { final flowProvider = context.read(); final paymentProvider = context.read(); + final controller = context.read(); if (paymentProvider.isLoading) return; - paymentProvider.pay().then((payment) { - if (!mounted) return; - final isSuccess = payment != null && paymentProvider.error == null; - showPaymentStatusDialog(context, isSuccess: isSuccess); - if (isSuccess) { - PosthogService.paymentInitiated(method: flowProvider.selectedType); - } - }); + final isSuccess = await controller.sendPayment(); + if (!mounted) return; + + await showPaymentStatusDialog(context, isSuccess: isSuccess); + if (!mounted) return; + + if (isSuccess) { + PosthogService.paymentInitiated(method: flowProvider.selectedType); + controller.resetAfterSuccess(); + context.goToPayout(widget.fallbackDestination); + } } @override diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/text.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text.dart index 7fd154fd..e27ac2f2 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/text.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/text.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pweb/pages/settings/profile/account/name/editing.dart'; -import 'package:pweb/pages/settings/profile/account/name/view.dart'; +import 'package:pweb/pages/settings/profile/account/name/text_view.dart'; import 'package:pweb/providers/account_name.dart'; diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/line.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text_line.dart similarity index 100% rename from frontend/pweb/lib/pages/settings/profile/account/name/line.dart rename to frontend/pweb/lib/pages/settings/profile/account/name/text_line.dart diff --git a/frontend/pweb/lib/pages/settings/profile/account/name/view.dart b/frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart similarity index 96% rename from frontend/pweb/lib/pages/settings/profile/account/name/view.dart rename to frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart index ca090c8f..e9a17dc2 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/name/view.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/name/text_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pweb/pages/settings/profile/account/name/line.dart'; +import 'package:pweb/pages/settings/profile/account/name/text_line.dart'; class AccountNameViewText extends StatelessWidget { diff --git a/frontend/pweb/lib/providers/upload_history.dart b/frontend/pweb/lib/providers/upload_history.dart deleted file mode 100644 index ab5b4ff2..00000000 --- a/frontend/pweb/lib/providers/upload_history.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:pshared/models/payment/upload_history_item.dart'; - -import 'package:pweb/providers/template.dart'; -import 'package:pweb/services/payments/history.dart'; - - -class UploadHistoryProvider extends FutureProviderTemplate> { - UploadHistoryProvider({required UploadHistoryService service}) : super(loader: service.fetchHistory); -} \ No newline at end of file diff --git a/frontend/pweb/lib/services/payments/csv_input.dart b/frontend/pweb/lib/services/payments/csv_input.dart new file mode 100644 index 00000000..eb70e733 --- /dev/null +++ b/frontend/pweb/lib/services/payments/csv_input.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:universal_html/html.dart' as html; + + +class PickedCsvFile { + final String name; + final String content; + + const PickedCsvFile({required this.name, required this.content}); +} + +abstract class CsvInputService { + Future pickCsv(); +} + +class WebCsvInputService implements CsvInputService { + @override + Future pickCsv() async { + final input = html.FileUploadInputElement() + ..accept = '.csv,text/csv' + ..multiple = false; + + final completer = Completer(); + input.onChange.listen((_) { + completer.complete( + input.files?.isNotEmpty == true ? input.files!.first : null, + ); + }); + input.click(); + + final file = await completer.future; + if (file == null) return null; + + final reader = html.FileReader(); + final readCompleter = Completer(); + reader.onError.listen((_) { + readCompleter.completeError(StateError('Failed to read file')); + }); + reader.onLoadEnd.listen((_) { + final result = reader.result; + if (result is String) { + readCompleter.complete(result); + } else { + readCompleter.completeError(StateError('Unsupported file payload')); + } + }); + reader.readAsText(file); + + final content = await readCompleter.future; + return PickedCsvFile(name: file.name, content: content); + } +} diff --git a/frontend/pweb/lib/services/payments/history.dart b/frontend/pweb/lib/services/payments/history.dart deleted file mode 100644 index a4e1285b..00000000 --- a/frontend/pweb/lib/services/payments/history.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:pshared/models/payment/upload_history_item.dart'; - - -abstract class UploadHistoryService { - Future> fetchHistory(); -} - -class MockUploadHistoryService implements UploadHistoryService { - @override - Future> fetchHistory() async { - await Future.delayed(const Duration(milliseconds: 300)); - - return [ - UploadHistoryItem(name: "cards_payout_single.csv", status: "Valid", time: "5 hours ago"), - UploadHistoryItem(name: "rfba_norm.csv", status: "Valid", time: "Yesterday"), - UploadHistoryItem(name: "iban (4).csv", status: "Valid", time: "Yesterday"), - UploadHistoryItem(name: "rfba_wrong.csv", status: "Error", time: "2 days ago"), - ]; - } -} diff --git a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart new file mode 100644 index 00000000..18e57112 --- /dev/null +++ b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart @@ -0,0 +1,157 @@ +import 'package:pweb/models/multiple_payouts/csv_row.dart'; + + +class MultipleCsvParser { + List parseRows(String content) { + final lines = content + .replaceAll('\r\n', '\n') + .replaceAll('\r', '\n') + .split('\n') + .where((line) => line.trim().isNotEmpty) + .toList(growable: false); + + if (lines.isEmpty) { + throw FormatException('CSV is empty'); + } + + final header = _parseCsvLine( + lines.first, + ).map((value) => value.trim().toLowerCase()).toList(growable: false); + + final panIndex = _resolveHeaderIndex(header, const ['pan', 'card_pan']); + final firstNameIndex = _resolveHeaderIndex(header, const [ + 'first_name', + 'firstname', + ]); + final lastNameIndex = _resolveHeaderIndex(header, const [ + 'last_name', + 'lastname', + ]); + final expMonthIndex = _resolveHeaderIndex(header, const [ + 'exp_month', + 'expiry_month', + ]); + final expYearIndex = _resolveHeaderIndex(header, const [ + 'exp_year', + 'expiry_year', + ]); + final amountIndex = _resolveHeaderIndex(header, const ['amount', 'sum']); + + if (panIndex < 0 || + firstNameIndex < 0 || + lastNameIndex < 0 || + expMonthIndex < 0 || + expYearIndex < 0 || + amountIndex < 0) { + throw FormatException( + 'CSV header must contain pan, first_name, last_name, exp_month, exp_year, amount columns', + ); + } + + final rows = []; + for (var i = 1; i < lines.length; i++) { + final raw = _parseCsvLine(lines[i]); + final pan = _cell(raw, panIndex); + final firstName = _cell(raw, firstNameIndex); + final lastName = _cell(raw, lastNameIndex); + final expMonthRaw = _cell(raw, expMonthIndex); + final expYearRaw = _cell(raw, expYearIndex); + final amount = _normalizeAmount(_cell(raw, amountIndex)); + + if (pan.isEmpty) { + throw FormatException('CSV row ${i + 1}: pan is required'); + } + if (firstName.isEmpty) { + throw FormatException('CSV row ${i + 1}: first_name is required'); + } + if (lastName.isEmpty) { + throw FormatException('CSV row ${i + 1}: last_name is required'); + } + if (amount.isEmpty) { + throw FormatException('CSV row ${i + 1}: amount is required'); + } + + final parsedAmount = double.tryParse(amount); + if (parsedAmount == null || parsedAmount <= 0) { + throw FormatException( + 'CSV row ${i + 1}: amount must be greater than 0', + ); + } + + final expMonth = int.tryParse(expMonthRaw); + if (expMonth == null || expMonth < 1 || expMonth > 12) { + throw FormatException('CSV row ${i + 1}: exp_month must be 1-12'); + } + final expYear = int.tryParse(expYearRaw); + if (expYear == null || expYear < 0) { + throw FormatException('CSV row ${i + 1}: exp_year is invalid'); + } + + rows.add( + CsvPayoutRow( + pan: pan, + firstName: firstName, + lastName: lastName, + expMonth: expMonth, + expYear: expYear, + amount: amount, + ), + ); + } + + if (rows.isEmpty) { + throw FormatException('CSV does not contain payout rows'); + } + + return rows; + } + + int _resolveHeaderIndex(List header, List candidates) { + for (final key in candidates) { + final idx = header.indexOf(key); + if (idx >= 0) return idx; + } + return -1; + } + + List _parseCsvLine(String line) { + final values = []; + final buffer = StringBuffer(); + var inQuotes = false; + + for (var i = 0; i < line.length; i++) { + final char = line[i]; + + if (char == '"') { + final isEscaped = inQuotes && i + 1 < line.length && line[i + 1] == '"'; + if (isEscaped) { + buffer.write('"'); + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char == ',' && !inQuotes) { + values.add(buffer.toString()); + buffer.clear(); + continue; + } + + buffer.write(char); + } + + values.add(buffer.toString()); + return values; + } + + String _cell(List row, int index) { + if (index < 0 || index >= row.length) return ''; + return row[index].trim(); + } + + String _normalizeAmount(String value) { + return value.trim().replaceAll(' ', '').replaceAll(',', '.'); + } +} diff --git a/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart new file mode 100644 index 00000000..bee45cc9 --- /dev/null +++ b/frontend/pweb/lib/utils/payment/multiple_intent_builder.dart @@ -0,0 +1,57 @@ +import 'package:pshared/controllers/balance_mask/wallets.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/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/settlement_mode.dart'; + +import 'package:pweb/models/multiple_payouts/csv_row.dart'; + + +class MultipleIntentBuilder { + static const String _currency = 'RUB'; + + List buildIntents( + WalletsController wallets, + List rows, + ) { + final sourceWallet = wallets.selectedWallet; + if (sourceWallet == null) { + throw StateError('Select source wallet first'); + } + + final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty; + final sourceAsset = hasAsset + ? PaymentAsset( + chain: sourceWallet.network ?? ChainNetwork.unspecified, + tokenSymbol: sourceWallet.tokenSymbol!, + contractAddress: sourceWallet.contractAddress, + ) + : null; + + return rows + .map( + (row) => PaymentIntent( + kind: PaymentKind.payout, + source: ManagedWalletPaymentMethod( + managedWalletRef: sourceWallet.id, + asset: sourceAsset, + ), + destination: CardPaymentMethod( + pan: row.pan, + firstName: row.firstName, + lastName: row.lastName, + expMonth: row.expMonth, + expYear: row.expYear, + ), + amount: Money(amount: row.amount, currency: _currency), + settlementMode: SettlementMode.fixSource, + settlementCurrency: _currency, + ), + ) + .toList(growable: false); + } +}