small fixes for single payout and big chunck for multiple payouts

This commit is contained in:
Arseni
2026-02-05 21:58:37 +03:00
parent 8034847e46
commit b9748b8ab2
37 changed files with 1708 additions and 224 deletions

View File

@@ -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,
};
}
}

View File

@@ -13,6 +13,8 @@ class PaymentDTO {
final String? failureCode;
final String? failureReason;
final PaymentQuoteDTO? lastQuote;
final Map<String, String>? metadata;
final String? createdAt;
const PaymentDTO({
this.paymentRef,
@@ -21,6 +23,8 @@ class PaymentDTO {
this.failureCode,
this.failureReason,
this.lastQuote,
this.metadata,
this.createdAt,
});
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json);

View File

@@ -5,19 +5,21 @@ import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'quotes.g.dart';
@JsonSerializable()
class PaymentQuotesDTO {
final String quoteRef;
final String? idempotencyKey;
final PaymentQuoteAggregateDTO? aggregate;
final List<PaymentQuoteDTO>? quotes;
const PaymentQuotesDTO({
required this.quoteRef,
this.idempotencyKey,
this.aggregate,
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);
}

View File

@@ -11,6 +11,8 @@ extension PaymentDTOMapper on PaymentDTO {
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDomain(),
metadata: metadata,
createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!),
);
}
@@ -22,5 +24,7 @@ extension PaymentMapper on Payment {
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDTO(),
metadata: metadata,
createdAt: createdAt?.toUtc().toIso8601String(),
);
}

View File

@@ -3,11 +3,10 @@ import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/data/mapper/payment/quote/aggregate.dart';
import 'package:pshared/models/payment/quote/quotes.dart';
extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
PaymentQuotes toDomain({String? idempotencyKey}) => PaymentQuotes(
quoteRef: quoteRef,
idempotencyKey: idempotencyKey,
idempotencyKey: idempotencyKey ?? this.idempotencyKey,
aggregate: aggregate?.toDomain(),
quotes: quotes?.map((quote) => quote.toDomain()).toList(),
);
@@ -16,6 +15,7 @@ extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
extension PaymentQuotesMapper on PaymentQuotes {
PaymentQuotesDTO toDTO() => PaymentQuotesDTO(
quoteRef: quoteRef,
idempotencyKey: idempotencyKey,
aggregate: aggregate?.toDTO(),
quotes: quotes?.map((quote) => quote.toDTO()).toList(),
);

View File

@@ -8,6 +8,8 @@ class Payment {
final String? failureCode;
final String? failureReason;
final PaymentQuote? lastQuote;
final Map<String, String>? metadata;
final DateTime? createdAt;
const Payment({
required this.paymentRef,
@@ -16,5 +18,13 @@ class Payment {
required this.failureCode,
required this.failureReason,
required this.lastQuote,
required this.metadata,
required this.createdAt,
});
bool get isFailure {
if ((failureCode ?? '').trim().isNotEmpty) return true;
final normalized = (state ?? '').trim().toLowerCase();
return normalized.contains('fail') || normalized.contains('cancel');
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -158,6 +158,28 @@ class PaymentsProvider with ChangeNotifier {
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}) {
_resource = newResource;
if (notify) notifyListeners();

View File

@@ -7,6 +7,7 @@ import 'package:pshared/models/payment/fx/intent.dart';
import 'package:pshared/models/payment/fx/side.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
@@ -44,13 +45,17 @@ class QuotationIntentBuilder {
// TODO: adapt to possible other sources
currency: currencyCodeToString(selectedWallet.currency),
);
final fxIntent = FxIntent(
pair: CurrencyPair(
base: currencyCodeToString(selectedWallet.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
);
final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod &&
(paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency;
final fxIntent = isCryptoToCrypto
? null
: FxIntent(
pair: CurrencyPair(
base: currencyCodeToString(selectedWallet.currency),
quote: 'RUB', // TODO: exentd target currencies
),
side: FxSide.sellBaseBuyQuote,
);
return PaymentIntent(
kind: PaymentKind.payout,
amount: amount,

View 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();
}
}

View File

@@ -16,18 +16,24 @@ class QuotationService {
static final _logger = Logger('service.payment.quotation');
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');
final response = await AuthorizationService.getPOSTResponse(
_objectType,
'/quote/$organizationRef',
_objectType,
'/quote/$organizationRef',
request.toJson(),
);
final parsed = PaymentQuoteResponse.fromJson(response);
return parsed.quote.toDomain(idempotencyKey: parsed.idempotencyKey);
}
static Future<PaymentQuotes> getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async {
static Future<PaymentQuotes> getMultiQuotation(
String organizationRef,
QuotePaymentsRequest request,
) async {
_logger.fine('Quoting payments for organization $organizationRef');
final response = await AuthorizationService.getPOSTResponse(
_objectType,
@@ -35,7 +41,6 @@ class QuotationService {
request.toJson(),
);
final parsed = PaymentQuotesResponse.fromJson(response);
final idempotencyKey = response['idempotencyKey'] as String?;
return parsed.quote.toDomain(idempotencyKey: idempotencyKey);
return parsed.quote.toDomain();
}
}

View File

@@ -10,6 +10,8 @@ import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/multiple/provider.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart';
@@ -18,6 +20,8 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payment_page.dart';
import 'package:pweb/providers/quotation/quotation.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/address_book/form/page.dart';
@@ -34,52 +38,126 @@ import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/page.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/services/payments/csv_input.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
RouteBase payoutShellRoute() => ShellRoute(
builder: (context, state, child) => MultiProvider(
providers: [
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>(
ChangeNotifierProxyProvider2<
OrganizationsProvider,
RecipientsProvider,
PaymentMethodsProvider
>(
create: (_) => PaymentMethodsProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
update: (context, organizations, recipients, provider) =>
provider!..updateProviders(organizations, recipients),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, RecipientMethodsCacheProvider>(
ChangeNotifierProxyProvider2<
OrganizationsProvider,
RecipientsProvider,
RecipientMethodsCacheProvider
>(
create: (_) => RecipientMethodsCacheProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
update: (context, organizations, recipients, provider) =>
provider!..updateProviders(organizations, recipients),
),
ChangeNotifierProxyProvider2<RecipientsProvider, PaymentMethodsProvider, PaymentFlowProvider>(
create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first),
update: (context, recipients, methods, provider) => provider!..update(
recipients,
methods,
),
ChangeNotifierProxyProvider2<
RecipientsProvider,
PaymentMethodsProvider,
PaymentFlowProvider
>(
create: (_) =>
PaymentFlowProvider(initialType: enabledPaymentTypes.first),
update: (context, recipients, methods, provider) =>
provider!..update(recipients, methods),
),
ChangeNotifierProvider(
create: (_) => PaymentAmountProvider(),
),
ChangeNotifierProxyProvider6<OrganizationsProvider, PaymentAmountProvider, WalletsController, PaymentFlowProvider, RecipientsProvider, PaymentMethodsProvider, QuotationProvider>(
ChangeNotifierProvider(create: (_) => PaymentAmountProvider()),
ChangeNotifierProxyProvider6<
OrganizationsProvider,
PaymentAmountProvider,
WalletsController,
PaymentFlowProvider,
RecipientsProvider,
PaymentMethodsProvider,
QuotationProvider
>(
create: (_) => QuotationProvider(),
update: (_, organization, payment, wallet, flow, recipients, methods, provider) =>
provider!..update(organization, payment, wallet, flow, recipients, methods),
update:
(
_,
organization,
payment,
wallet,
flow,
recipients,
methods,
provider,
) => provider!
..update(
organization,
payment,
wallet,
flow,
recipients,
methods,
),
),
ChangeNotifierProxyProvider<QuotationProvider, QuotationController>(
create: (_) => QuotationController(),
update: (_, quotation, controller) => controller!..update(quotation),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, QuotationProvider, PaymentProvider>(
ChangeNotifierProxyProvider2<
OrganizationsProvider,
QuotationProvider,
PaymentProvider
>(
create: (_) => PaymentProvider(),
update: (context, organization, quotation, provider) => provider!..update(
organization,
quotation,
),
update: (context, organization, quotation, provider) =>
provider!..update(organization, quotation),
),
ChangeNotifierProxyProvider4<
PaymentProvider,
QuotationProvider,
PaymentFlowProvider,
RecipientsProvider,
PaymentPageController
>(
create: (_) => PaymentPageController(),
update: (context, payment, quotation, flow, recipients, controller) =>
controller!..update(payment, quotation, flow, recipients),
),
ChangeNotifierProxyProvider<
OrganizationsProvider,
MultiQuotationProvider
>(
create: (_) => MultiQuotationProvider(),
update: (context, organization, provider) =>
provider!..update(organization),
),
ChangeNotifierProxyProvider2<
OrganizationsProvider,
MultiQuotationProvider,
MultiPaymentProvider
>(
create: (_) => MultiPaymentProvider(),
update: (context, organization, quotation, provider) =>
provider!..update(organization, quotation),
),
ChangeNotifierProxyProvider3<
WalletsController,
MultiQuotationProvider,
MultiPaymentProvider,
MultiplePayoutsController
>(
create: (_) =>
MultiplePayoutsController(csvInput: WebCsvInputService()),
update: (context, wallets, quotation, payment, provider) =>
provider!..update(wallets, quotation, payment),
),
],
child: PageSelector(
child: child,
routerState: state,
),
child: PageSelector(child: child, routerState: state),
),
routes: [
GoRoute(
@@ -135,11 +213,11 @@ RouteBase payoutShellRoute() => ShellRoute(
);
if (!confirmed) return;
await executeActionWithNotification(
context: context,
action: () async =>
context.read<RecipientsProvider>().delete(recipient.id),
successMessage: loc.recipientDeletedSuccessfully,
errorMessage: loc.errorDeleteRecipient,
context: context,
action: () async =>
context.read<RecipientsProvider>().delete(recipient.id),
successMessage: loc.recipientDeletedSuccessfully,
errorMessage: loc.errorDeleteRecipient,
);
},
),
@@ -149,9 +227,7 @@ RouteBase payoutShellRoute() => ShellRoute(
GoRoute(
name: PayoutRoutes.invitations,
path: PayoutRoutes.invitationsPath,
pageBuilder: (_, _) => const NoTransitionPage(
child: InvitationsPage(),
),
pageBuilder: (_, _) => const NoTransitionPage(child: InvitationsPage()),
),
GoRoute(
name: PayoutRoutes.addRecipient,
@@ -187,9 +263,8 @@ RouteBase payoutShellRoute() => ShellRoute(
GoRoute(
name: PayoutRoutes.settings,
path: PayoutRoutes.settingsPath,
pageBuilder: (_, _) => const NoTransitionPage(
child: ProfileSettingsPage(),
),
pageBuilder: (_, _) =>
const NoTransitionPage(child: ProfileSettingsPage()),
),
GoRoute(
name: PayoutRoutes.reports,
@@ -249,16 +324,10 @@ void _startPayment(
required PayoutDestination returnTo,
}) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushToPayment(
paymentType: paymentType,
returnTo: returnTo,
);
context.pushToPayment(paymentType: paymentType, returnTo: returnTo);
}
void _openAddRecipient(
BuildContext context, {
Recipient? recipient,
}) {
void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushNamed(PayoutRoutes.addRecipient);
}

View 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;
}
}

View 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();
}
}

View File

@@ -335,6 +335,11 @@
"description": "Table column header for file name"
},
"rowsColumn": "Rows",
"@rowsColumn": {
"description": "Table column header for row count"
},
"amountColumn": "Amount",
"@amountColumn": {
"description": "Table column header for the original amount"
@@ -562,6 +567,15 @@
}
}
},
"recipientsWillReceive": "Recipients will receive: {amount}",
"@recipientsWillReceive": {
"description": "Label showing how much the recipients will receive",
"placeholders": {
"amount": {
"type": "String"
}
}
},
"total": "Total: {total}",
"@total": {

View File

@@ -335,6 +335,11 @@
"description": "Заголовок столбца таблицы для имени файла"
},
"rowsColumn": "Строки",
"@rowsColumn": {
"description": "Заголовок столбца таблицы для количества строк"
},
"amountColumn": "Сумма",
"@amountColumn": {
"description": "Заголовок столбца таблицы для исходной суммы"
@@ -563,6 +568,16 @@
}
},
"recipientsWillReceive": "Получатели получат: {amount}",
"@recipientsWillReceive": {
"description": "Метка, показывающая, сколько получат получатели",
"placeholders": {
"amount": {
"type": "String"
}
}
},
"total": "Итого: {total}",
"@total": {
"description": "Метка, показывающая общую сумму транзакции",

View File

@@ -29,19 +29,18 @@ import 'package:pweb/app/app.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:pweb/providers/wallet_transactions.dart';
import 'package:pweb/services/payments/history.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/services/wallet_transactions.dart';
import 'package:pweb/providers/account.dart';
void _setupLogging() {
Logger.root.level = Level.ALL;
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
// ignore: avoid_print
print('${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}');
print(
'${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}',
);
});
}
@@ -50,7 +49,6 @@ void main() async {
await Constants.initialize();
await PosthogService.initialize();
_setupLogging();
setUrlStrategy(PathUrlStrategy());
@@ -62,54 +60,68 @@ void main() async {
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
create: (_) => PwebAccountProvider(),
update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider),
update: (context, localeProvider, provider) =>
provider!..updateProvider(localeProvider),
),
ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>(
create: (_) => TwoFactorProvider(),
update: (context, accountProvider, provider) => provider!..update(accountProvider),
update: (context, accountProvider, provider) =>
provider!..update(accountProvider),
),
ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>(
create: (_) => PermissionsProvider(),
update: (context, orgnization, provider) => provider!..update(orgnization),
update: (context, orgnization, provider) =>
provider!..update(orgnization),
),
ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>(
create: (_) => EmployeesProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
update: (context, organizations, provider) =>
provider!..updateProviders(organizations),
),
ChangeNotifierProxyProvider<OrganizationsProvider, PaymentsProvider>(
create: (_) => PaymentsProvider(),
update: (context, organizations, provider) => provider!..update(organizations),
update: (context, organizations, provider) =>
provider!..update(organizations),
),
ChangeNotifierProvider(create: (_) => EmailVerificationProvider()),
ChangeNotifierProvider(
create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(),
),
ChangeNotifierProxyProvider<OrganizationsProvider, RecipientsProvider>(
create: (_) => RecipientsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
update: (context, organizations, provider) =>
provider!..updateProviders(organizations),
),
ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>(
create: (_) => InvitationsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
update: (context, organizations, provider) =>
provider!..updateProviders(organizations),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>(
create: (_) => PaymentMethodsProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),
),
ChangeNotifierProvider(
create: (_) => InvitationListViewModel(),
ChangeNotifierProxyProvider2<
OrganizationsProvider,
RecipientsProvider,
PaymentMethodsProvider
>(
create: (_) => PaymentMethodsProvider(),
update: (context, organizations, recipients, provider) =>
provider!..updateProviders(organizations, recipients),
),
ChangeNotifierProvider(create: (_) => InvitationListViewModel()),
ChangeNotifierProxyProvider<OrganizationsProvider, WalletsProvider>(
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()),
update: (context, organizations, provider) => provider!..update(organizations),
update: (context, organizations, provider) =>
provider!..update(organizations),
),
ChangeNotifierProxyProvider<LedgerAccountsProvider, LedgerBalanceMaskController>(
ChangeNotifierProxyProvider<
LedgerAccountsProvider,
LedgerBalanceMaskController
>(
create: (_) => LedgerBalanceMaskController(),
update: (context, ledger, controller) => controller!..update(ledger),
),
@@ -118,11 +130,12 @@ void main() async {
update: (_, wallets, controller) => controller!..update(wallets),
),
ChangeNotifierProvider(
create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(),
create: (_) =>
WalletTransactionsProvider(MockWalletTransactionsService())
..load(),
),
],
child: const PayApp(),
),
);
}

View 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,
});
}

View File

@@ -0,0 +1 @@
enum MultiplePayoutsState { idle, quoting, sending }

View File

@@ -72,17 +72,16 @@ class _DashboardPageState extends State<DashboardPage> {
icon: Icons.person_add,
),
),
//TODO bring back multiple payouts
// const SizedBox(width: AppSpacing.small),
// Expanded(
// flex: 0,
// child: TransactionRefButton(
// onTap: () => _setActive(false),
// isActive: _showContainerMultiple,
// label: l10n.sendMultiple,
// icon: Icons.group_add,
// ),
// ),
const SizedBox(width: AppSpacing.small),
Expanded(
flex: 0,
child: TransactionRefButton(
onTap: () => _setActive(false),
isActive: _showContainerMultiple,
label: l10n.sendMultiple,
icon: Icons.group_add,
),
),
],
),
const SizedBox(height: AppSpacing.medium),

View File

@@ -1,5 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/source_quote_panel.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/upload_panel.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -8,11 +16,11 @@ class UploadCSVSection extends StatelessWidget {
static const double _verticalSpacing = 10;
static const double _iconTextSpacing = 5;
static const double _buttonVerticalPadding = 12;
static const double _buttonHorizontalPadding = 24;
@override
Widget build(BuildContext context) {
final controller = context.watch<MultiplePayoutsController>();
final walletsController = context.watch<WalletsController>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
@@ -34,34 +42,57 @@ class UploadCSVSection extends StatelessWidget {
),
const SizedBox(height: _verticalSpacing),
Container(
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
child: LayoutBuilder(
builder: (context, constraints) {
final useHorizontal = constraints.maxWidth >= 760;
if (!useHorizontal) {
return Column(
children: [
UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
const SizedBox(height: 12),
SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
),
child: Text(l10n.upload),
),
const SizedBox(height: 8),
Text(
l10n.hintUpload,
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(width: 12),
Expanded(
flex: 5,
child: SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
),
],
);
},
),
),
],

View File

@@ -1,13 +1,17 @@
class MultiplePayoutRow {
final String token;
final String pan;
final String firstName;
final String lastName;
final int expMonth;
final int expYear;
final String amount;
final String currency;
final String comment;
const MultiplePayoutRow({
required this.token,
required this.pan,
required this.firstName,
required this.lastName,
required this.expMonth,
required this.expYear,
required this.amount,
required this.currency,
required this.comment,
});
}

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistorySection extends StatelessWidget {
@@ -14,18 +17,28 @@ class UploadHistorySection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<UploadHistoryProvider>();
final provider = context.watch<PaymentsProvider>();
final theme = Theme.of(context);
final l10 = AppLocalizations.of(context)!;
final dateFormat = DateFormat.yMMMd().add_Hm();
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Text(l10.notificationError(provider.error ?? l10.noErrorInformation));
return Text(
l10.notificationError(provider.error ?? l10.noErrorInformation),
);
}
final items = provider.data ?? [];
final items = List.of(provider.payments);
items.sort((a, b) {
final left = a.createdAt;
final right = b.createdAt;
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
});
return Column(
children: [
@@ -36,33 +49,72 @@ class UploadHistorySection extends StatelessWidget {
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
],
),
DataTable(
columns: [
DataColumn(label: Text(l10.fileNameColumn)),
DataColumn(label: Text(l10.colStatus)),
DataColumn(label: Text(l10.dateColumn)),
DataColumn(label: Text(l10.details)),
],
rows: items.map((file) {
final isError = file.status == "Error";
final statusColor = isError ? Colors.red : Colors.green;
return DataRow(
cells: [
DataCell(Text(file.name)),
DataCell(Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withAlpha(20),
borderRadius: BorderRadius.circular(_radius),
const SizedBox(height: 8),
if (items.isEmpty)
Align(
alignment: Alignment.centerLeft,
child: Text(
l10.walletHistoryEmpty,
style: theme.textTheme.bodyMedium,
),
)
else
DataTable(
columns: [
DataColumn(label: Text(l10.fileNameColumn)),
DataColumn(label: Text(l10.rowsColumn)),
DataColumn(label: Text(l10.dateColumn)),
DataColumn(label: Text(l10.amountColumn)),
DataColumn(label: Text(l10.statusColumn)),
],
rows: items.map((payment) {
final metadata = payment.metadata;
final state = payment.state ?? '-';
final statusColor =
payment.isFailure ? Colors.red : Colors.green;
final fileName = metadata?['upload_filename'];
final fileNameText =
(fileName == null || fileName.isEmpty) ? '-' : fileName;
final rows = metadata?['upload_rows'];
final rowsText = (rows == null || rows.isEmpty) ? '-' : rows;
final createdAt = payment.createdAt;
final dateText = createdAt == null
? '-'
: dateFormat.format(createdAt.toLocal());
final amountValue = metadata?['upload_amount'];
final amountCurrency = metadata?['upload_currency'];
final fallbackAmount = payment.lastQuote?.debitAmount;
final amountText = (amountValue == null || amountValue.isEmpty)
? (fallbackAmount == null
? '-'
: '${fallbackAmount.amount} ${fallbackAmount.currency}')
: (amountCurrency == null || amountCurrency.isEmpty
? amountValue
: '$amountValue $amountCurrency');
return DataRow(
cells: [
DataCell(Text(fileNameText)),
DataCell(Text(rowsText)),
DataCell(Text(dateText)),
DataCell(Text(amountText)),
DataCell(
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withAlpha(20),
borderRadius: BorderRadius.circular(_radius),
),
child: Text(state, style: TextStyle(color: statusColor)),
),
),
child: Text(file.status, style: TextStyle(color: statusColor)),
)),
DataCell(Text(file.time)),
DataCell(TextButton(onPressed: () {}, child: Text(l10.showDetails))),
],
);
}).toList(),
),
],
);
}).toList(),
),
],
);
}

View File

@@ -1,18 +1,47 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pshared/models/file/downloaded_file.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/form.dart';
import 'package:pweb/utils/download.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleSection extends StatelessWidget {
const FileFormatSampleSection({super.key});
static final List<MultiplePayoutRow> sampleRows = [
MultiplePayoutRow(token: "d921...161", amount: "500", currency: "RUB", comment: "cashback001"),
MultiplePayoutRow(token: "d921...162", amount: "100", currency: "USD", comment: "cashback002"),
MultiplePayoutRow(token: "d921...163", amount: "120", currency: "EUR", comment: "cashback003"),
MultiplePayoutRow(
pan: "9022****11",
firstName: "Alex",
lastName: "Ivanov",
expMonth: 12,
expYear: 27,
amount: "500",
),
MultiplePayoutRow(
pan: "9022****12",
firstName: "Maria",
lastName: "Sokolova",
expMonth: 7,
expYear: 26,
amount: "100",
),
MultiplePayoutRow(
pan: "9022****13",
firstName: "Dmitry",
lastName: "Smirnov",
expMonth: 3,
expYear: 28,
amount: "120",
),
];
static const String _sampleFileName = 'sample.csv';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -41,7 +70,7 @@ class FileFormatSampleSection extends StatelessWidget {
_buildDataTable(l10n),
const SizedBox(height: 10),
TextButton(
onPressed: () {},
onPressed: _downloadSampleCsv,
style: TextButton.styleFrom(padding: EdgeInsets.zero),
child: Text(l10n.downloadSampleCSV, style: linkStyle),
),
@@ -53,19 +82,44 @@ class FileFormatSampleSection extends StatelessWidget {
return DataTable(
columnSpacing: 20,
columns: [
DataColumn(label: Text(l10n.tokenColumn)),
DataColumn(label: Text(l10n.cardNumberColumn)),
DataColumn(label: Text(l10n.firstName)),
DataColumn(label: Text(l10n.lastName)),
DataColumn(label: Text(l10n.expiryDate)),
DataColumn(label: Text(l10n.amount)),
DataColumn(label: Text(l10n.currency)),
DataColumn(label: Text(l10n.comment)),
],
rows: sampleRows.map((row) {
return DataRow(cells: [
DataCell(Text(row.token)),
DataCell(Text(row.amount)),
DataCell(Text(row.currency)),
DataCell(Text(row.comment)),
]);
return DataRow(
cells: [
DataCell(Text(row.pan)),
DataCell(Text(row.firstName)),
DataCell(Text(row.lastName)),
DataCell(
Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'),
),
DataCell(Text(row.amount)),
],
);
}).toList(),
);
}
}
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',
),
);
}
}

View File

@@ -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)})';
}
}

View File

@@ -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();
}
}

View File

@@ -79,9 +79,9 @@ class InvitationFormFields extends StatelessWidget {
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: firstNameController,
controller: lastNameController,
decoration: InputDecoration(
labelText: loc.firstName,
labelText: loc.lastName,
prefixIcon: const Icon(Icons.person_outline),
),
),
@@ -89,9 +89,9 @@ class InvitationFormFields extends StatelessWidget {
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: lastNameController,
controller: firstNameController,
decoration: InputDecoration(
labelText: loc.lastName,
labelText: loc.firstName,
prefixIcon: const Icon(Icons.person_outline),
),
),

View File

@@ -11,10 +11,12 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/utils/recipient/filtering.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
import 'package:pweb/controllers/payment_page.dart';
class PaymentPage extends StatefulWidget {
@@ -92,19 +94,23 @@ class _PaymentPageState extends State<PaymentPage> {
});
}
void _handleSendPayment() {
Future<void> _handleSendPayment() async {
final flowProvider = context.read<PaymentFlowProvider>();
final paymentProvider = context.read<PaymentProvider>();
final controller = context.read<PaymentPageController>();
if (paymentProvider.isLoading) return;
paymentProvider.pay().then((payment) {
if (!mounted) return;
final isSuccess = payment != null && paymentProvider.error == null;
showPaymentStatusDialog(context, isSuccess: isSuccess);
if (isSuccess) {
PosthogService.paymentInitiated(method: flowProvider.selectedType);
}
});
final isSuccess = await controller.sendPayment();
if (!mounted) return;
await showPaymentStatusDialog(context, isSuccess: isSuccess);
if (!mounted) return;
if (isSuccess) {
PosthogService.paymentInitiated(method: flowProvider.selectedType);
controller.resetAfterSuccess();
context.goToPayout(widget.fallbackDestination);
}
}
@override

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/pages/settings/profile/account/name/editing.dart';
import 'package:pweb/pages/settings/profile/account/name/view.dart';
import 'package:pweb/pages/settings/profile/account/name/text_view.dart';
import 'package:pweb/providers/account_name.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/settings/profile/account/name/line.dart';
import 'package:pweb/pages/settings/profile/account/name/text_line.dart';
class AccountNameViewText extends StatelessWidget {

View File

@@ -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);
}

View 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);
}
}

View File

@@ -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"),
];
}
}

View 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(',', '.');
}
}

View 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);
}
}