small fixes for single payout and big chunck for multiple payouts #439
@@ -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<String, dynamic> json) {
|
||||||
|
return InitiatePaymentsRequest(
|
||||||
|
idempotencyKey: json['idempotencyKey'] as String,
|
||||||
|
metadata: (json['metadata'] as Map<String, dynamic>?)?.map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
),
|
||||||
|
quoteRef: json['quoteRef'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'idempotencyKey': idempotencyKey,
|
||||||
|
'metadata': metadata,
|
||||||
|
'quoteRef': quoteRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ class PaymentDTO {
|
|||||||
final String? failureCode;
|
final String? failureCode;
|
||||||
final String? failureReason;
|
final String? failureReason;
|
||||||
final PaymentQuoteDTO? lastQuote;
|
final PaymentQuoteDTO? lastQuote;
|
||||||
|
final Map<String, String>? metadata;
|
||||||
|
final String? createdAt;
|
||||||
|
|
||||||
const PaymentDTO({
|
const PaymentDTO({
|
||||||
this.paymentRef,
|
this.paymentRef,
|
||||||
@@ -21,6 +23,8 @@ class PaymentDTO {
|
|||||||
this.failureCode,
|
this.failureCode,
|
||||||
this.failureReason,
|
this.failureReason,
|
||||||
this.lastQuote,
|
this.lastQuote,
|
||||||
|
this.metadata,
|
||||||
|
this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json);
|
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json);
|
||||||
|
|||||||
@@ -5,19 +5,21 @@ import 'package:pshared/data/dto/payment/payment_quote.dart';
|
|||||||
|
|
||||||
part 'quotes.g.dart';
|
part 'quotes.g.dart';
|
||||||
|
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class PaymentQuotesDTO {
|
class PaymentQuotesDTO {
|
||||||
final String quoteRef;
|
final String quoteRef;
|
||||||
|
final String? idempotencyKey;
|
||||||
final PaymentQuoteAggregateDTO? aggregate;
|
final PaymentQuoteAggregateDTO? aggregate;
|
||||||
final List<PaymentQuoteDTO>? quotes;
|
final List<PaymentQuoteDTO>? quotes;
|
||||||
|
|
||||||
const PaymentQuotesDTO({
|
const PaymentQuotesDTO({
|
||||||
required this.quoteRef,
|
required this.quoteRef,
|
||||||
|
this.idempotencyKey,
|
||||||
this.aggregate,
|
this.aggregate,
|
||||||
this.quotes,
|
this.quotes,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PaymentQuotesDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuotesDTOFromJson(json);
|
factory PaymentQuotesDTO.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PaymentQuotesDTOFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$PaymentQuotesDTOToJson(this);
|
Map<String, dynamic> toJson() => _$PaymentQuotesDTOToJson(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ extension PaymentDTOMapper on PaymentDTO {
|
|||||||
failureCode: failureCode,
|
failureCode: failureCode,
|
||||||
failureReason: failureReason,
|
failureReason: failureReason,
|
||||||
lastQuote: lastQuote?.toDomain(),
|
lastQuote: lastQuote?.toDomain(),
|
||||||
|
metadata: metadata,
|
||||||
|
createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,5 +24,7 @@ extension PaymentMapper on Payment {
|
|||||||
failureCode: failureCode,
|
failureCode: failureCode,
|
||||||
failureReason: failureReason,
|
failureReason: failureReason,
|
||||||
lastQuote: lastQuote?.toDTO(),
|
lastQuote: lastQuote?.toDTO(),
|
||||||
|
metadata: metadata,
|
||||||
|
createdAt: createdAt?.toUtc().toIso8601String(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/data/mapper/payment/quote/aggregate.dart';
|
||||||
import 'package:pshared/models/payment/quote/quotes.dart';
|
import 'package:pshared/models/payment/quote/quotes.dart';
|
||||||
|
|
||||||
|
|
||||||
extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
|
extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
|
||||||
PaymentQuotes toDomain({String? idempotencyKey}) => PaymentQuotes(
|
PaymentQuotes toDomain({String? idempotencyKey}) => PaymentQuotes(
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
idempotencyKey: idempotencyKey,
|
idempotencyKey: idempotencyKey ?? this.idempotencyKey,
|
||||||
aggregate: aggregate?.toDomain(),
|
aggregate: aggregate?.toDomain(),
|
||||||
quotes: quotes?.map((quote) => quote.toDomain()).toList(),
|
quotes: quotes?.map((quote) => quote.toDomain()).toList(),
|
||||||
);
|
);
|
||||||
@@ -16,6 +15,7 @@ extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
|
|||||||
extension PaymentQuotesMapper on PaymentQuotes {
|
extension PaymentQuotesMapper on PaymentQuotes {
|
||||||
PaymentQuotesDTO toDTO() => PaymentQuotesDTO(
|
PaymentQuotesDTO toDTO() => PaymentQuotesDTO(
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
aggregate: aggregate?.toDTO(),
|
aggregate: aggregate?.toDTO(),
|
||||||
quotes: quotes?.map((quote) => quote.toDTO()).toList(),
|
quotes: quotes?.map((quote) => quote.toDTO()).toList(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ class Payment {
|
|||||||
final String? failureCode;
|
final String? failureCode;
|
||||||
final String? failureReason;
|
final String? failureReason;
|
||||||
final PaymentQuote? lastQuote;
|
final PaymentQuote? lastQuote;
|
||||||
|
final Map<String, String>? metadata;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
const Payment({
|
const Payment({
|
||||||
required this.paymentRef,
|
required this.paymentRef,
|
||||||
@@ -16,5 +18,13 @@ class Payment {
|
|||||||
required this.failureCode,
|
required this.failureCode,
|
||||||
required this.failureReason,
|
required this.failureReason,
|
||||||
required this.lastQuote,
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
frontend/pshared/lib/provider/payment/multiple/provider.dart
Normal file
81
frontend/pshared/lib/provider/payment/multiple/provider.dart
Normal file
@@ -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<List<Payment>> _payments = Resource(data: []);
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
List<Payment> get payments => _payments.data ?? [];
|
||||||
|
bool get isLoading => _payments.isLoading;
|
||||||
|
Exception? get error => _payments.error;
|
||||||
|
bool get isReady =>
|
||||||
|
_isLoaded && !_payments.isLoading && _payments.error == null;
|
||||||
|
|
||||||
|
void update(
|
||||||
|
OrganizationsProvider organization,
|
||||||
|
MultiQuotationProvider quotation,
|
||||||
|
) {
|
||||||
|
_organization = organization;
|
||||||
|
_quotation = quotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Payment>> pay({
|
||||||
|
String? idempotencyKey,
|
||||||
|
Map<String, String>? 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<List<Payment>> payments) {
|
||||||
|
_payments = payments;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
138
frontend/pshared/lib/provider/payment/multiple/quotation.dart
Normal file
138
frontend/pshared/lib/provider/payment/multiple/quotation.dart
Normal file
@@ -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<PaymentQuotes> _quotation = Resource(data: null);
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
List<PaymentIntent>? _lastIntents;
|
||||||
|
bool _lastPreviewOnly = false;
|
||||||
|
Map<String, String>? _lastMetadata;
|
||||||
|
|
||||||
|
Resource<PaymentQuotes> 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<PaymentQuotes?> quotePayments(
|
||||||
|
List<PaymentIntent> intents, {
|
||||||
|
bool previewOnly = false,
|
||||||
|
String? idempotencyKey,
|
||||||
|
Map<String, String>? 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<PaymentIntent>.from(intents);
|
||||||
|
_lastPreviewOnly = previewOnly;
|
||||||
|
_lastMetadata = metadata == null
|
||||||
|
? null
|
||||||
|
: Map<String, String>.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<PaymentQuotes?> 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<PaymentQuotes> quotation) {
|
||||||
|
_quotation = quotation;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,28 @@ class PaymentsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void addPayments(List<Payment> items, {bool prepend = true}) {
|
||||||
|
if (items.isEmpty) return;
|
||||||
|
final current = List<Payment>.from(payments);
|
||||||
|
final existingRefs = <String>{};
|
||||||
|
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<List<Payment>> newResource, {required bool notify}) {
|
void _applyResource(Resource<List<Payment>> newResource, {required bool notify}) {
|
||||||
_resource = newResource;
|
_resource = newResource;
|
||||||
if (notify) notifyListeners();
|
if (notify) notifyListeners();
|
||||||
|
|||||||
@@ -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/fx/side.dart';
|
||||||
import 'package:pshared/models/payment/kind.dart';
|
import 'package:pshared/models/payment/kind.dart';
|
||||||
import 'package:pshared/models/payment/methods/card.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/data.dart';
|
||||||
import 'package:pshared/models/payment/methods/iban.dart';
|
import 'package:pshared/models/payment/methods/iban.dart';
|
||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
@@ -44,7 +45,11 @@ class QuotationIntentBuilder {
|
|||||||
// TODO: adapt to possible other sources
|
// TODO: adapt to possible other sources
|
||||||
currency: currencyCodeToString(selectedWallet.currency),
|
currency: currencyCodeToString(selectedWallet.currency),
|
||||||
);
|
);
|
||||||
final fxIntent = FxIntent(
|
final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod &&
|
||||||
|
(paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency;
|
||||||
|
final fxIntent = isCryptoToCrypto
|
||||||
|
? null
|
||||||
|
: FxIntent(
|
||||||
pair: CurrencyPair(
|
pair: CurrencyPair(
|
||||||
base: currencyCodeToString(selectedWallet.currency),
|
base: currencyCodeToString(selectedWallet.currency),
|
||||||
quote: 'RUB', // TODO: exentd target currencies
|
quote: 'RUB', // TODO: exentd target currencies
|
||||||
|
|||||||
60
frontend/pshared/lib/service/payment/multiple.dart
Normal file
60
frontend/pshared/lib/service/payment/multiple.dart
Normal file
@@ -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<PaymentQuotes> 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<List<Payment>> payByQuote(
|
||||||
|
String organizationRef,
|
||||||
|
String quoteRef, {
|
||||||
|
String? idempotencyKey,
|
||||||
|
Map<String, String>? 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,10 @@ class QuotationService {
|
|||||||
static final _logger = Logger('service.payment.quotation');
|
static final _logger = Logger('service.payment.quotation');
|
||||||
static const String _objectType = Services.payments;
|
static const String _objectType = Services.payments;
|
||||||
|
|
||||||
static Future<PaymentQuote> getQuotation(String organizationRef, QuotePaymentRequest request) async {
|
static Future<PaymentQuote> getQuotation(
|
||||||
|
String organizationRef,
|
||||||
|
QuotePaymentRequest request,
|
||||||
|
) async {
|
||||||
_logger.fine('Quoting payment for organization $organizationRef');
|
_logger.fine('Quoting payment for organization $organizationRef');
|
||||||
final response = await AuthorizationService.getPOSTResponse(
|
final response = await AuthorizationService.getPOSTResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
@@ -27,7 +30,10 @@ class QuotationService {
|
|||||||
return parsed.quote.toDomain(idempotencyKey: parsed.idempotencyKey);
|
return parsed.quote.toDomain(idempotencyKey: parsed.idempotencyKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<PaymentQuotes> getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async {
|
static Future<PaymentQuotes> getMultiQuotation(
|
||||||
|
String organizationRef,
|
||||||
|
QuotePaymentsRequest request,
|
||||||
|
) async {
|
||||||
_logger.fine('Quoting payments for organization $organizationRef');
|
_logger.fine('Quoting payments for organization $organizationRef');
|
||||||
final response = await AuthorizationService.getPOSTResponse(
|
final response = await AuthorizationService.getPOSTResponse(
|
||||||
_objectType,
|
_objectType,
|
||||||
@@ -35,7 +41,6 @@ class QuotationService {
|
|||||||
request.toJson(),
|
request.toJson(),
|
||||||
);
|
);
|
||||||
final parsed = PaymentQuotesResponse.fromJson(response);
|
final parsed = PaymentQuotesResponse.fromJson(response);
|
||||||
final idempotencyKey = response['idempotencyKey'] as String?;
|
return parsed.quote.toDomain();
|
||||||
return parsed.quote.toDomain(idempotencyKey: idempotencyKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:pshared/models/recipient/recipient.dart';
|
|||||||
import 'package:pshared/provider/organizations.dart';
|
import 'package:pshared/provider/organizations.dart';
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
import 'package:pshared/provider/payment/flow.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/provider.dart';
|
||||||
import 'package:pshared/provider/payment/quotation/quotation.dart';
|
import 'package:pshared/provider/payment/quotation/quotation.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.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/pages.dart';
|
||||||
import 'package:pweb/app/router/payout_routes.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:pweb/providers/quotation/quotation.dart';
|
||||||
import 'package:pshared/models/payment/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pweb/pages/address_book/form/page.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/destinations.dart';
|
||||||
import 'package:pweb/widgets/sidebar/page.dart';
|
import 'package:pweb/widgets/sidebar/page.dart';
|
||||||
import 'package:pweb/utils/payment/availability.dart';
|
import 'package:pweb/utils/payment/availability.dart';
|
||||||
|
import 'package:pweb/services/payments/csv_input.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
RouteBase payoutShellRoute() => ShellRoute(
|
RouteBase payoutShellRoute() => ShellRoute(
|
||||||
builder: (context, state, child) => MultiProvider(
|
builder: (context, state, child) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>(
|
ChangeNotifierProxyProvider2<
|
||||||
|
OrganizationsProvider,
|
||||||
|
RecipientsProvider,
|
||||||
|
PaymentMethodsProvider
|
||||||
|
>(
|
||||||
create: (_) => PaymentMethodsProvider(),
|
create: (_) => PaymentMethodsProvider(),
|
||||||
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
|
update: (context, organizations, recipients, provider) =>
|
||||||
|
provider!..updateProviders(organizations, recipients),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, RecipientMethodsCacheProvider>(
|
ChangeNotifierProxyProvider2<
|
||||||
|
OrganizationsProvider,
|
||||||
|
RecipientsProvider,
|
||||||
|
RecipientMethodsCacheProvider
|
||||||
|
>(
|
||||||
create: (_) => RecipientMethodsCacheProvider(),
|
create: (_) => RecipientMethodsCacheProvider(),
|
||||||
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
|
update: (context, organizations, recipients, provider) =>
|
||||||
|
provider!..updateProviders(organizations, recipients),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
|
ChangeNotifierProxyProvider2<
|
||||||
create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first),
|
RecipientsProvider,
|
||||||
update: (context, recipients, methods, provider) => provider!..update(
|
PaymentMethodsProvider,
|
||||||
|
PaymentFlowProvider
|
||||||
|
>(
|
||||||
|
create: (_) =>
|
||||||
|
PaymentFlowProvider(initialType: enabledPaymentTypes.first),
|
||||||
|
update: (context, recipients, methods, provider) =>
|
||||||
|
provider!..update(recipients, methods),
|
||||||
|
),
|
||||||
|
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,
|
recipients,
|
||||||
methods,
|
methods,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
),
|
|
||||||
ChangeNotifierProxyProvider<QuotationProvider, QuotationController>(
|
ChangeNotifierProxyProvider<QuotationProvider, QuotationController>(
|
||||||
create: (_) => QuotationController(),
|
create: (_) => QuotationController(),
|
||||||
update: (_, quotation, controller) => controller!..update(quotation),
|
update: (_, quotation, controller) => controller!..update(quotation),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
|
ChangeNotifierProxyProvider2<
|
||||||
|
OrganizationsProvider,
|
||||||
|
QuotationProvider,
|
||||||
|
PaymentProvider
|
||||||
|
>(
|
||||||
create: (_) => PaymentProvider(),
|
create: (_) => PaymentProvider(),
|
||||||
update: (context, organization, quotation, provider) => provider!..update(
|
update: (context, organization, quotation, provider) =>
|
||||||
organization,
|
provider!..update(organization, quotation),
|
||||||
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: PageSelector(child: child, routerState: state),
|
||||||
child: child,
|
|
||||||
routerState: state,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -149,9 +227,7 @@ RouteBase payoutShellRoute() => ShellRoute(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
name: PayoutRoutes.invitations,
|
name: PayoutRoutes.invitations,
|
||||||
path: PayoutRoutes.invitationsPath,
|
path: PayoutRoutes.invitationsPath,
|
||||||
pageBuilder: (_, _) => const NoTransitionPage(
|
pageBuilder: (_, _) => const NoTransitionPage(child: InvitationsPage()),
|
||||||
child: InvitationsPage(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: PayoutRoutes.addRecipient,
|
name: PayoutRoutes.addRecipient,
|
||||||
@@ -187,9 +263,8 @@ RouteBase payoutShellRoute() => ShellRoute(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
name: PayoutRoutes.settings,
|
name: PayoutRoutes.settings,
|
||||||
path: PayoutRoutes.settingsPath,
|
path: PayoutRoutes.settingsPath,
|
||||||
pageBuilder: (_, _) => const NoTransitionPage(
|
pageBuilder: (_, _) =>
|
||||||
child: ProfileSettingsPage(),
|
const NoTransitionPage(child: ProfileSettingsPage()),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: PayoutRoutes.reports,
|
name: PayoutRoutes.reports,
|
||||||
@@ -249,16 +324,10 @@ void _startPayment(
|
|||||||
required PayoutDestination returnTo,
|
required PayoutDestination returnTo,
|
||||||
}) {
|
}) {
|
||||||
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
|
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
|
||||||
context.pushToPayment(
|
context.pushToPayment(paymentType: paymentType, returnTo: returnTo);
|
||||||
paymentType: paymentType,
|
|
||||||
returnTo: returnTo,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openAddRecipient(
|
void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
|
||||||
BuildContext context, {
|
|
||||||
Recipient? recipient,
|
|
||||||
}) {
|
|
||||||
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
|
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
|
||||||
context.pushNamed(PayoutRoutes.addRecipient);
|
context.pushNamed(PayoutRoutes.addRecipient);
|
||||||
}
|
}
|
||||||
|
|||||||
244
frontend/pweb/lib/controllers/multiple_payouts.dart
Normal file
244
frontend/pweb/lib/controllers/multiple_payouts.dart
Normal file
@@ -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<CsvPayoutRow> _rows = const <CsvPayoutRow>[];
|
||||||
|
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<CsvPayoutRow> 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<void> 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: <String, String>{
|
||||||
|
'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<List<Payment>> send() async {
|
||||||
|
if (isBusy) return const <Payment>[];
|
||||||
|
|
||||||
|
final payment = _payment;
|
||||||
|
if (payment == null) {
|
||||||
|
_setErrorObject(
|
||||||
|
StateError('Multiple payouts payment provider is not ready'),
|
||||||
|
);
|
||||||
|
return const <Payment>[];
|
||||||
|
}
|
||||||
|
if (!canSend) {
|
||||||
|
_setErrorObject(
|
||||||
|
StateError('Upload CSV and wait for quote before sending'),
|
||||||
|
);
|
||||||
|
return const <Payment>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_setState(MultiplePayoutsState.sending);
|
||||||
|
_error = null;
|
||||||
|
|
||||||
|
final result = await payment.pay(
|
||||||
|
metadata: <String, String>{
|
||||||
|
...?_selectedFileName == null
|
||||||
|
? null
|
||||||
|
: <String, String>{'upload_filename': _selectedFileName!},
|
||||||
|
'upload_rows': _rows.length.toString(),
|
||||||
|
...?_uploadAmountMetadata(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_sentCount = result.length;
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
_setErrorObject(e);
|
||||||
|
return const <Payment>[];
|
||||||
|
} finally {
|
||||||
|
_setState(MultiplePayoutsState.idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeUploadedFile() {
|
||||||
|
if (isBusy) return;
|
||||||
|
|
||||||
|
_selectedFileName = null;
|
||||||
|
_rows = const <CsvPayoutRow>[];
|
||||||
|
_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<String, String>? _uploadAmountMetadata() {
|
||||||
|
final sentAmount = requestedSentAmount;
|
||||||
|
if (sentAmount == null) return null;
|
||||||
|
return <String, String>{
|
||||||
|
'upload_amount': sentAmount.amount,
|
||||||
|
'upload_currency': sentAmount.currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Money? _moneyForSourceCurrency(List<Money>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/pweb/lib/controllers/payment_page.dart
Normal file
71
frontend/pweb/lib/controllers/payment_page.dart
Normal file
@@ -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<bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -335,6 +335,11 @@
|
|||||||
"description": "Table column header for file name"
|
"description": "Table column header for file name"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"rowsColumn": "Rows",
|
||||||
|
"@rowsColumn": {
|
||||||
|
"description": "Table column header for row count"
|
||||||
|
},
|
||||||
|
|
||||||
"amountColumn": "Amount",
|
"amountColumn": "Amount",
|
||||||
"@amountColumn": {
|
"@amountColumn": {
|
||||||
"description": "Table column header for the original amount"
|
"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": "Total: {total}",
|
||||||
"@total": {
|
"@total": {
|
||||||
|
|||||||
@@ -335,6 +335,11 @@
|
|||||||
"description": "Заголовок столбца таблицы для имени файла"
|
"description": "Заголовок столбца таблицы для имени файла"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"rowsColumn": "Строки",
|
||||||
|
"@rowsColumn": {
|
||||||
|
"description": "Заголовок столбца таблицы для количества строк"
|
||||||
|
},
|
||||||
|
|
||||||
"amountColumn": "Сумма",
|
"amountColumn": "Сумма",
|
||||||
"@amountColumn": {
|
"@amountColumn": {
|
||||||
"description": "Заголовок столбца таблицы для исходной суммы"
|
"description": "Заголовок столбца таблицы для исходной суммы"
|
||||||
@@ -563,6 +568,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"recipientsWillReceive": "Получатели получат: {amount}",
|
||||||
|
"@recipientsWillReceive": {
|
||||||
|
"description": "Метка, показывающая, сколько получат получатели",
|
||||||
|
"placeholders": {
|
||||||
|
"amount": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"total": "Итого: {total}",
|
"total": "Итого: {total}",
|
||||||
"@total": {
|
"@total": {
|
||||||
"description": "Метка, показывающая общую сумму транзакции",
|
"description": "Метка, показывающая общую сумму транзакции",
|
||||||
|
|||||||
@@ -29,19 +29,18 @@ import 'package:pweb/app/app.dart';
|
|||||||
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
|
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
|
||||||
import 'package:pweb/app/timeago.dart';
|
import 'package:pweb/app/timeago.dart';
|
||||||
import 'package:pweb/providers/two_factor.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/providers/wallet_transactions.dart';
|
||||||
import 'package:pweb/services/payments/history.dart';
|
|
||||||
import 'package:pweb/services/posthog.dart';
|
import 'package:pweb/services/posthog.dart';
|
||||||
import 'package:pweb/services/wallet_transactions.dart';
|
import 'package:pweb/services/wallet_transactions.dart';
|
||||||
import 'package:pweb/providers/account.dart';
|
import 'package:pweb/providers/account.dart';
|
||||||
|
|
||||||
|
|
||||||
void _setupLogging() {
|
void _setupLogging() {
|
||||||
Logger.root.level = Level.ALL;
|
Logger.root.level = Level.ALL;
|
||||||
Logger.root.onRecord.listen((record) {
|
Logger.root.onRecord.listen((record) {
|
||||||
// ignore: avoid_print
|
// 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 Constants.initialize();
|
||||||
await PosthogService.initialize();
|
await PosthogService.initialize();
|
||||||
|
|
||||||
|
|
||||||
_setupLogging();
|
_setupLogging();
|
||||||
setUrlStrategy(PathUrlStrategy());
|
setUrlStrategy(PathUrlStrategy());
|
||||||
|
|
||||||
@@ -62,54 +60,68 @@ void main() async {
|
|||||||
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
|
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
|
||||||
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
|
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
|
||||||
create: (_) => PwebAccountProvider(),
|
create: (_) => PwebAccountProvider(),
|
||||||
update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider),
|
update: (context, localeProvider, provider) =>
|
||||||
|
provider!..updateProvider(localeProvider),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>(
|
ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>(
|
||||||
create: (_) => TwoFactorProvider(),
|
create: (_) => TwoFactorProvider(),
|
||||||
update: (context, accountProvider, provider) => provider!..update(accountProvider),
|
update: (context, accountProvider, provider) =>
|
||||||
|
provider!..update(accountProvider),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
|
ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>(
|
ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>(
|
||||||
create: (_) => PermissionsProvider(),
|
create: (_) => PermissionsProvider(),
|
||||||
update: (context, orgnization, provider) => provider!..update(orgnization),
|
update: (context, orgnization, provider) =>
|
||||||
|
provider!..update(orgnization),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>(
|
ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>(
|
||||||
create: (_) => EmployeesProvider(),
|
create: (_) => EmployeesProvider(),
|
||||||
update: (context, organizations, provider) => provider!..updateProviders(organizations),
|
update: (context, organizations, provider) =>
|
||||||
|
provider!..updateProviders(organizations),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, PaymentsProvider>(
|
ChangeNotifierProxyProvider<OrganizationsProvider, PaymentsProvider>(
|
||||||
create: (_) => PaymentsProvider(),
|
create: (_) => PaymentsProvider(),
|
||||||
update: (context, organizations, provider) => provider!..update(organizations),
|
update: (context, organizations, provider) =>
|
||||||
|
provider!..update(organizations),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => EmailVerificationProvider()),
|
ChangeNotifierProvider(create: (_) => EmailVerificationProvider()),
|
||||||
|
|
||||||
ChangeNotifierProvider(
|
|
||||||
create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(),
|
|
||||||
),
|
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, RecipientsProvider>(
|
ChangeNotifierProxyProvider<OrganizationsProvider, RecipientsProvider>(
|
||||||
create: (_) => RecipientsProvider(),
|
create: (_) => RecipientsProvider(),
|
||||||
update: (context, organizations, provider) => provider!..updateProviders(organizations),
|
update: (context, organizations, provider) =>
|
||||||
|
provider!..updateProviders(organizations),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>(
|
ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>(
|
||||||
create: (_) => InvitationsProvider(),
|
create: (_) => InvitationsProvider(),
|
||||||
update: (context, organizations, provider) => provider!..updateProviders(organizations),
|
update: (context, organizations, provider) =>
|
||||||
|
provider!..updateProviders(organizations),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>(
|
ChangeNotifierProxyProvider2<
|
||||||
|
OrganizationsProvider,
|
||||||
|
RecipientsProvider,
|
||||||
|
PaymentMethodsProvider
|
||||||
|
>(
|
||||||
create: (_) => PaymentMethodsProvider(),
|
create: (_) => PaymentMethodsProvider(),
|
||||||
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
|
update: (context, organizations, recipients, provider) =>
|
||||||
),
|
provider!..updateProviders(organizations, recipients),
|
||||||
ChangeNotifierProvider(
|
|
||||||
create: (_) => InvitationListViewModel(),
|
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => InvitationListViewModel()),
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>(
|
ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>(
|
||||||
create: (_) => WalletsProvider(ApiWalletsService()),
|
create: (_) => WalletsProvider(ApiWalletsService()),
|
||||||
update: (context, organizations, provider) => provider!..update(organizations),
|
update: (context, organizations, provider) =>
|
||||||
|
provider!..update(organizations),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<OrganizationsProvider, LedgerAccountsProvider>(
|
ChangeNotifierProxyProvider<
|
||||||
|
OrganizationsProvider,
|
||||||
|
LedgerAccountsProvider
|
||||||
|
>(
|
||||||
create: (_) => LedgerAccountsProvider(LedgerService()),
|
create: (_) => LedgerAccountsProvider(LedgerService()),
|
||||||
update: (context, organizations, provider) => provider!..update(organizations),
|
update: (context, organizations, provider) =>
|
||||||
|
provider!..update(organizations),
|
||||||
),
|
),
|
||||||
ChangeNotifierProxyProvider<LedgerAccountsProvider, LedgerBalanceMaskController>(
|
ChangeNotifierProxyProvider<
|
||||||
|
LedgerAccountsProvider,
|
||||||
|
LedgerBalanceMaskController
|
||||||
|
>(
|
||||||
create: (_) => LedgerBalanceMaskController(),
|
create: (_) => LedgerBalanceMaskController(),
|
||||||
update: (context, ledger, controller) => controller!..update(ledger),
|
update: (context, ledger, controller) => controller!..update(ledger),
|
||||||
),
|
),
|
||||||
@@ -118,11 +130,12 @@ void main() async {
|
|||||||
update: (_, wallets, controller) => controller!..update(wallets),
|
update: (_, wallets, controller) => controller!..update(wallets),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(),
|
create: (_) =>
|
||||||
|
WalletTransactionsProvider(MockWalletTransactionsService())
|
||||||
|
..load(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: const PayApp(),
|
child: const PayApp(),
|
||||||
),
|
),
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
frontend/pweb/lib/models/multiple_payouts/csv_row.dart
Normal file
17
frontend/pweb/lib/models/multiple_payouts/csv_row.dart
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
1
frontend/pweb/lib/models/multiple_payouts/state.dart
Normal file
1
frontend/pweb/lib/models/multiple_payouts/state.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum MultiplePayoutsState { idle, quoting, sending }
|
||||||
@@ -72,17 +72,16 @@ class _DashboardPageState extends State<DashboardPage> {
|
|||||||
icon: Icons.person_add,
|
icon: Icons.person_add,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
//TODO bring back multiple payouts
|
const SizedBox(width: AppSpacing.small),
|
||||||
// const SizedBox(width: AppSpacing.small),
|
Expanded(
|
||||||
// Expanded(
|
flex: 0,
|
||||||
// flex: 0,
|
child: TransactionRefButton(
|
||||||
// child: TransactionRefButton(
|
onTap: () => _setActive(false),
|
||||||
// onTap: () => _setActive(false),
|
isActive: _showContainerMultiple,
|
||||||
// isActive: _showContainerMultiple,
|
label: l10n.sendMultiple,
|
||||||
// label: l10n.sendMultiple,
|
icon: Icons.group_add,
|
||||||
// icon: Icons.group_add,
|
),
|
||||||
// ),
|
),
|
||||||
// ),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.medium),
|
const SizedBox(height: AppSpacing.medium),
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
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';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -8,11 +16,11 @@ class UploadCSVSection extends StatelessWidget {
|
|||||||
|
|
||||||
static const double _verticalSpacing = 10;
|
static const double _verticalSpacing = 10;
|
||||||
static const double _iconTextSpacing = 5;
|
static const double _iconTextSpacing = 5;
|
||||||
static const double _buttonVerticalPadding = 12;
|
|
||||||
static const double _buttonHorizontalPadding = 24;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final controller = context.watch<MultiplePayoutsController>();
|
||||||
|
final walletsController = context.watch<WalletsController>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
@@ -34,35 +42,58 @@ class UploadCSVSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: _verticalSpacing),
|
const SizedBox(height: _verticalSpacing),
|
||||||
Container(
|
Container(
|
||||||
height: 140,
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: theme.colorScheme.outline),
|
border: Border.all(color: theme.colorScheme.outline),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: LayoutBuilder(
|
||||||
child: Column(
|
builder: (context, constraints) {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
final useHorizontal = constraints.maxWidth >= 760;
|
||||||
|
if (!useHorizontal) {
|
||||||
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary),
|
UploadPanel(
|
||||||
const SizedBox(height: 8),
|
controller: controller,
|
||||||
ElevatedButton(
|
theme: theme,
|
||||||
onPressed: () {},
|
l10n: l10n,
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: _buttonHorizontalPadding,
|
|
||||||
vertical: _buttonVerticalPadding,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
child: Text(l10n.upload),
|
SourceQuotePanel(
|
||||||
),
|
controller: controller,
|
||||||
const SizedBox(height: 8),
|
walletsController: walletsController,
|
||||||
Text(
|
theme: theme,
|
||||||
l10n.hintUpload,
|
l10n: l10n,
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 6,
|
||||||
|
child: UploadPanel(
|
||||||
|
controller: controller,
|
||||||
|
theme: theme,
|
||||||
|
l10n: l10n,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: SourceQuotePanel(
|
||||||
|
controller: controller,
|
||||||
|
walletsController: walletsController,
|
||||||
|
theme: theme,
|
||||||
|
l10n: l10n,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
class MultiplePayoutRow {
|
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 amount;
|
||||||
final String currency;
|
|
||||||
final String comment;
|
|
||||||
|
|
||||||
const MultiplePayoutRow({
|
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.amount,
|
||||||
required this.currency,
|
|
||||||
required this.comment,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:pweb/generated/i18n/app_localizations.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 {
|
class UploadHistorySection extends StatelessWidget {
|
||||||
@@ -14,18 +17,28 @@ class UploadHistorySection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<UploadHistoryProvider>();
|
final provider = context.watch<PaymentsProvider>();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l10 = AppLocalizations.of(context)!;
|
final l10 = AppLocalizations.of(context)!;
|
||||||
|
final dateFormat = DateFormat.yMMMd().add_Hm();
|
||||||
|
|
||||||
if (provider.isLoading) {
|
if (provider.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (provider.error != null) {
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -36,29 +49,68 @@ class UploadHistorySection extends StatelessWidget {
|
|||||||
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
|
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (items.isEmpty)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
l10.walletHistoryEmpty,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
DataTable(
|
DataTable(
|
||||||
columns: [
|
columns: [
|
||||||
DataColumn(label: Text(l10.fileNameColumn)),
|
DataColumn(label: Text(l10.fileNameColumn)),
|
||||||
DataColumn(label: Text(l10.colStatus)),
|
DataColumn(label: Text(l10.rowsColumn)),
|
||||||
DataColumn(label: Text(l10.dateColumn)),
|
DataColumn(label: Text(l10.dateColumn)),
|
||||||
DataColumn(label: Text(l10.details)),
|
DataColumn(label: Text(l10.amountColumn)),
|
||||||
|
DataColumn(label: Text(l10.statusColumn)),
|
||||||
],
|
],
|
||||||
rows: items.map((file) {
|
rows: items.map((payment) {
|
||||||
final isError = file.status == "Error";
|
final metadata = payment.metadata;
|
||||||
final statusColor = isError ? Colors.red : Colors.green;
|
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(
|
return DataRow(
|
||||||
cells: [
|
cells: [
|
||||||
DataCell(Text(file.name)),
|
DataCell(Text(fileNameText)),
|
||||||
DataCell(Container(
|
DataCell(Text(rowsText)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
DataCell(Text(dateText)),
|
||||||
|
DataCell(Text(amountText)),
|
||||||
|
DataCell(
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withAlpha(20),
|
color: statusColor.withAlpha(20),
|
||||||
borderRadius: BorderRadius.circular(_radius),
|
borderRadius: BorderRadius.circular(_radius),
|
||||||
),
|
),
|
||||||
child: Text(file.status, style: TextStyle(color: statusColor)),
|
child: Text(state, style: TextStyle(color: statusColor)),
|
||||||
)),
|
),
|
||||||
DataCell(Text(file.time)),
|
),
|
||||||
DataCell(TextButton(onPressed: () {}, child: Text(l10.showDetails))),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|||||||
@@ -1,18 +1,47 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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/pages/dashboard/payouts/multiple/form.dart';
|
||||||
|
import 'package:pweb/utils/download.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class FileFormatSampleSection extends StatelessWidget {
|
class FileFormatSampleSection extends StatelessWidget {
|
||||||
const FileFormatSampleSection({super.key});
|
const FileFormatSampleSection({super.key});
|
||||||
|
|
||||||
static final List<MultiplePayoutRow> sampleRows = [
|
static final List<MultiplePayoutRow> sampleRows = [
|
||||||
MultiplePayoutRow(token: "d921...161", amount: "500", currency: "RUB", comment: "cashback001"),
|
MultiplePayoutRow(
|
||||||
MultiplePayoutRow(token: "d921...162", amount: "100", currency: "USD", comment: "cashback002"),
|
pan: "9022****11",
|
||||||
MultiplePayoutRow(token: "d921...163", amount: "120", currency: "EUR", comment: "cashback003"),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -41,7 +70,7 @@ class FileFormatSampleSection extends StatelessWidget {
|
|||||||
_buildDataTable(l10n),
|
_buildDataTable(l10n),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {},
|
onPressed: _downloadSampleCsv,
|
||||||
style: TextButton.styleFrom(padding: EdgeInsets.zero),
|
style: TextButton.styleFrom(padding: EdgeInsets.zero),
|
||||||
child: Text(l10n.downloadSampleCSV, style: linkStyle),
|
child: Text(l10n.downloadSampleCSV, style: linkStyle),
|
||||||
),
|
),
|
||||||
@@ -53,19 +82,44 @@ class FileFormatSampleSection extends StatelessWidget {
|
|||||||
return DataTable(
|
return DataTable(
|
||||||
columnSpacing: 20,
|
columnSpacing: 20,
|
||||||
columns: [
|
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.amount)),
|
||||||
DataColumn(label: Text(l10n.currency)),
|
|
||||||
DataColumn(label: Text(l10n.comment)),
|
|
||||||
],
|
],
|
||||||
rows: sampleRows.map((row) {
|
rows: sampleRows.map((row) {
|
||||||
return DataRow(cells: [
|
return DataRow(
|
||||||
DataCell(Text(row.token)),
|
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)),
|
DataCell(Text(row.amount)),
|
||||||
DataCell(Text(row.currency)),
|
],
|
||||||
DataCell(Text(row.comment)),
|
);
|
||||||
]);
|
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadSampleCsv() async {
|
||||||
|
final rows = <String>[
|
||||||
|
'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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<String>(
|
||||||
|
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<String>(
|
||||||
|
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)})';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MultiplePayoutsController>()
|
||||||
|
.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<MultiplePayoutsController>()
|
||||||
|
.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<void> _handleSend(BuildContext context) async {
|
||||||
|
final paymentsProvider = context.read<PaymentsProvider>();
|
||||||
|
final result = await controller.send();
|
||||||
|
paymentsProvider.addPayments(result);
|
||||||
|
await paymentsProvider.refresh();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final isSuccess = controller.error == null && result.isNotEmpty;
|
||||||
|
await showDialog<void>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,9 +79,9 @@ class InvitationFormFields extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: _fieldWidth,
|
width: _fieldWidth,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: firstNameController,
|
controller: lastNameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: loc.firstName,
|
labelText: loc.lastName,
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -89,9 +89,9 @@ class InvitationFormFields extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: _fieldWidth,
|
width: _fieldWidth,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: lastNameController,
|
controller: firstNameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: loc.lastName,
|
labelText: loc.firstName,
|
||||||
prefixIcon: const Icon(Icons.person_outline),
|
prefixIcon: const Icon(Icons.person_outline),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import 'package:pshared/provider/recipient/pmethods.dart';
|
|||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/body.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/utils/recipient/filtering.dart';
|
||||||
import 'package:pweb/widgets/sidebar/destinations.dart';
|
import 'package:pweb/widgets/sidebar/destinations.dart';
|
||||||
import 'package:pweb/services/posthog.dart';
|
import 'package:pweb/services/posthog.dart';
|
||||||
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
|
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
|
||||||
|
import 'package:pweb/controllers/payment_page.dart';
|
||||||
|
|
||||||
|
|
||||||
class PaymentPage extends StatefulWidget {
|
class PaymentPage extends StatefulWidget {
|
||||||
@@ -92,19 +94,23 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSendPayment() {
|
Future<void> _handleSendPayment() async {
|
||||||
final flowProvider = context.read<PaymentFlowProvider>();
|
final flowProvider = context.read<PaymentFlowProvider>();
|
||||||
final paymentProvider = context.read<PaymentProvider>();
|
final paymentProvider = context.read<PaymentProvider>();
|
||||||
|
final controller = context.read<PaymentPageController>();
|
||||||
if (paymentProvider.isLoading) return;
|
if (paymentProvider.isLoading) return;
|
||||||
|
|
||||||
paymentProvider.pay().then((payment) {
|
final isSuccess = await controller.sendPayment();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final isSuccess = payment != null && paymentProvider.error == null;
|
|
||||||
showPaymentStatusDialog(context, isSuccess: isSuccess);
|
await showPaymentStatusDialog(context, isSuccess: isSuccess);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
||||||
|
controller.resetAfterSuccess();
|
||||||
|
context.goToPayout(widget.fallbackDestination);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/settings/profile/account/name/editing.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';
|
import 'package:pweb/providers/account_name.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
class AccountNameViewText extends StatelessWidget {
|
||||||
@@ -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<List<UploadHistoryItem>> {
|
|
||||||
UploadHistoryProvider({required UploadHistoryService service}) : super(loader: service.fetchHistory);
|
|
||||||
}
|
|
||||||
53
frontend/pweb/lib/services/payments/csv_input.dart
Normal file
53
frontend/pweb/lib/services/payments/csv_input.dart
Normal file
@@ -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<PickedCsvFile?> pickCsv();
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebCsvInputService implements CsvInputService {
|
||||||
|
@override
|
||||||
|
Future<PickedCsvFile?> pickCsv() async {
|
||||||
|
final input = html.FileUploadInputElement()
|
||||||
|
..accept = '.csv,text/csv'
|
||||||
|
..multiple = false;
|
||||||
|
|
||||||
|
final completer = Completer<html.File?>();
|
||||||
|
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<String>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import 'package:pshared/models/payment/upload_history_item.dart';
|
|
||||||
|
|
||||||
|
|
||||||
abstract class UploadHistoryService {
|
|
||||||
Future<List<UploadHistoryItem>> fetchHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockUploadHistoryService implements UploadHistoryService {
|
|
||||||
@override
|
|
||||||
Future<List<UploadHistoryItem>> 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"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
157
frontend/pweb/lib/utils/payment/multiple_csv_parser.dart
Normal file
157
frontend/pweb/lib/utils/payment/multiple_csv_parser.dart
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleCsvParser {
|
||||||
|
List<CsvPayoutRow> 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 = <CsvPayoutRow>[];
|
||||||
|
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<String> header, List<String> candidates) {
|
||||||
|
for (final key in candidates) {
|
||||||
|
final idx = header.indexOf(key);
|
||||||
|
if (idx >= 0) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _parseCsvLine(String line) {
|
||||||
|
final values = <String>[];
|
||||||
|
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<String> row, int index) {
|
||||||
|
if (index < 0 || index >= row.length) return '';
|
||||||
|
return row[index].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeAmount(String value) {
|
||||||
|
return value.trim().replaceAll(' ', '').replaceAll(',', '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
57
frontend/pweb/lib/utils/payment/multiple_intent_builder.dart
Normal file
57
frontend/pweb/lib/utils/payment/multiple_intent_builder.dart
Normal file
@@ -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<PaymentIntent> buildIntents(
|
||||||
|
WalletsController wallets,
|
||||||
|
List<CsvPayoutRow> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user