multiple payout page and small fixes

This commit is contained in:
Arseni
2026-02-11 02:48:30 +03:00
parent 66989ea36c
commit edb43f9909
77 changed files with 2120 additions and 1289 deletions

View File

@@ -16,6 +16,14 @@ class ErrorHandler {
'unauthorized': locs.errorLoginUnauthorized,
'verification_token_not_found': locs.errorVerificationTokenNotFound,
'internal_error': locs.errorInternalError,
'invalid_target': locs.errorInvalidTarget,
'pending_token_required': locs.errorPendingTokenRequired,
'missing_destination': locs.errorMissingDestination,
'missing_code': locs.errorMissingCode,
'missing_session': locs.errorMissingSession,
'token_expired': locs.errorTokenExpired,
'code_attempts_exceeded': locs.errorCodeAttemptsExceeded,
'too_many_requests': locs.errorTooManyRequests,
'data_conflict': locs.errorDataConflict,
'access_denied': locs.errorAccessDenied,

View File

@@ -15,18 +15,23 @@ Future<void> notifyUserOfErrorX({
int delaySeconds = 3,
}) async {
if (!context.mounted) return;
if (!_shouldShowError(errorSituation, exception)) {
return;
}
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString();
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
if (scaffoldMessenger != null) {
final durationSeconds = _normalizeDelaySeconds(delaySeconds);
scaffoldMessenger.clearSnackBars();
final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation,
localizedError: localizedError,
technicalDetails: technicalDetails,
loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds,
delaySeconds: durationSeconds,
);
scaffoldMessenger.showSnackBar(snackBar);
return;
@@ -46,15 +51,20 @@ void showErrorSnackBar({
required AppLocalizations appLocalizations,
int delaySeconds = 3,
}) {
if (!_shouldShowError(errorSituation, exception)) {
return;
}
final localizedError = ErrorHandler.handleErrorLocs(appLocalizations, exception);
final technicalDetails = exception.toString();
final durationSeconds = _normalizeDelaySeconds(delaySeconds);
scaffoldMessenger.clearSnackBars();
final snackBar = _buildMainErrorSnackBar(
errorSituation: errorSituation,
localizedError: localizedError,
technicalDetails: technicalDetails,
loc: appLocalizations,
scaffoldMessenger: scaffoldMessenger,
delaySeconds: delaySeconds,
delaySeconds: durationSeconds,
);
scaffoldMessenger.showSnackBar(snackBar);
}
@@ -139,19 +149,22 @@ SnackBar _buildMainErrorSnackBar({
int delaySeconds = 3,
}) =>
SnackBar(
duration: Duration(seconds: delaySeconds),
duration: Duration(seconds: _normalizeDelaySeconds(delaySeconds)),
content: ErrorSnackBarContent(
situation: errorSituation,
localizedError: localizedError,
),
action: SnackBarAction(
label: loc.showDetailsAction,
onPressed: () => scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(technicalDetails),
duration: const Duration(seconds: 6),
),
),
onPressed: () {
scaffoldMessenger.hideCurrentSnackBar();
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(technicalDetails),
duration: const Duration(seconds: 6),
),
);
},
),
);
@@ -176,3 +189,27 @@ Future<void> _showErrorDialog(
),
);
}
int _normalizeDelaySeconds(int delaySeconds) =>
delaySeconds <= 0 ? 3 : delaySeconds;
String? _lastErrorSignature;
DateTime? _lastErrorShownAt;
const int _errorCooldownSeconds = 60;
bool _shouldShowError(String errorSituation, Object exception) {
final signature = '$errorSituation|${exception.runtimeType}|${exception.toString()}';
final now = DateTime.now();
if (_lastErrorSignature == signature) {
final lastShownAt = _lastErrorShownAt;
if (lastShownAt != null &&
now.difference(lastShownAt).inSeconds < _errorCooldownSeconds) {
return false;
}
}
_lastErrorSignature = signature;
_lastErrorShownAt = now;
return true;
}

View File

@@ -1,69 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/api/responses/error/connectivity.dart';
import 'package:pshared/api/responses/error/server.dart';
import 'package:pshared/config/constants.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/services/accounts.dart';
class ErrorHandler {
/// A mapping of server-side error codes to localized user-friendly messages.
/// Update these keys to match the 'ErrorResponse.Error' field in your Go code.
static Map<String, String> getErrorMessagesLocs(AppLocalizations locs) {
return {
'account_not_verified': locs.errorAccountNotVerified,
'unauthorized': locs.errorLoginUnauthorized,
'verification_token_not_found': locs.errorVerificationTokenNotFound,
'internal_error': locs.errorInternalError,
'data_conflict': locs.errorDataConflict,
'access_denied': locs.errorAccessDenied,
'broken_payload': locs.errorBrokenPayload,
'invalid_argument': locs.errorInvalidArgument,
'broken_reference': locs.errorBrokenReference,
'invalid_query_parameter': locs.errorInvalidQueryParameter,
'not_implemented': locs.errorNotImplemented,
'license_required': locs.errorLicenseRequired,
'not_found': locs.errorNotFound,
'name_missing': locs.errorNameMissing,
'email_missing': locs.errorEmailMissing,
'password_missing': locs.errorPasswordMissing,
'email_not_registered': locs.errorEmailNotRegistered,
'duplicate_email': locs.errorDuplicateEmail,
};
}
static Map<String, String> getErrorMessages(BuildContext context) {
return getErrorMessagesLocs(AppLocalizations.of(context)!);
}
/// Determine which handler to use based on the runtime type of [e].
/// If no match is found, just return the errors string representation.
static String handleError(BuildContext context, Object e) {
return handleErrorLocs(AppLocalizations.of(context)!, e);
}
static String handleErrorLocs(AppLocalizations locs, Object e) {
final errorHandlers = <Type, String Function(Object)>{
ErrorResponse: (ex) => _handleErrorResponseLocs(locs, ex as ErrorResponse),
ConnectivityError: (ex) => _handleConnectivityErrorLocs(locs, ex as ConnectivityError),
InvalidCredentialsException: (_) => locs.errorLoginUnauthorized,
DuplicateAccountException: (_) => locs.errorAccountExists,
};
return errorHandlers[e.runtimeType]?.call(e) ?? e.toString();
}
static String _handleErrorResponseLocs(AppLocalizations locs, ErrorResponse e) {
final errorMessages = getErrorMessagesLocs(locs);
// Return the localized message if we recognize the error key, else use the raw details
return errorMessages[e.error] ?? e.details;
}
/// Handler for connectivity issues.
static String _handleConnectivityErrorLocs(AppLocalizations locs, ConnectivityError e) {
return locs.connectivityError(Constants.serviceUrl);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/utils/error/snackbar.dart';
Future<void> invokeAndNotify<T>(

View File

@@ -14,8 +14,10 @@ class MultipleCsvParser {
throw FormatException('CSV is empty');
}
final delimiter = _detectDelimiter(lines.first);
final header = _parseCsvLine(
lines.first,
delimiter,
).map((value) => value.trim().toLowerCase()).toList(growable: false);
final panIndex = _resolveHeaderIndex(header, const ['pan', 'card_pan']);
@@ -27,6 +29,11 @@ class MultipleCsvParser {
'last_name',
'lastname',
]);
final expDateIndex = _resolveHeaderIndex(header, const [
'exp_date',
'expiry',
'expiry_date',
]);
final expMonthIndex = _resolveHeaderIndex(header, const [
'exp_month',
'expiry_month',
@@ -40,20 +47,21 @@ class MultipleCsvParser {
if (panIndex < 0 ||
firstNameIndex < 0 ||
lastNameIndex < 0 ||
expMonthIndex < 0 ||
expYearIndex < 0 ||
(expDateIndex < 0 &&
(expMonthIndex < 0 || expYearIndex < 0)) ||
amountIndex < 0) {
throw FormatException(
'CSV header must contain pan, first_name, last_name, exp_month, exp_year, amount columns',
'CSV header must contain pan, first_name, last_name, amount columns and either exp_date/expiry or exp_month and exp_year',
);
}
final rows = <CsvPayoutRow>[];
for (var i = 1; i < lines.length; i++) {
final raw = _parseCsvLine(lines[i]);
final raw = _parseCsvLine(lines[i], delimiter);
final pan = _cell(raw, panIndex);
final firstName = _cell(raw, firstNameIndex);
final lastName = _cell(raw, lastNameIndex);
final expDateRaw = expDateIndex >= 0 ? _cell(raw, expDateIndex) : '';
final expMonthRaw = _cell(raw, expMonthIndex);
final expYearRaw = _cell(raw, expYearIndex);
final amount = _normalizeAmount(_cell(raw, amountIndex));
@@ -78,13 +86,25 @@ class MultipleCsvParser {
);
}
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');
int expMonth;
int expYear;
if (expDateIndex >= 0 && expDateRaw.isNotEmpty) {
final parsed = _parseExpiryDate(expDateRaw, i + 1);
expMonth = parsed.month;
expYear = parsed.year;
} else if (expMonthIndex >= 0 && expYearIndex >= 0) {
final parsedMonth = int.tryParse(expMonthRaw);
if (parsedMonth == null || parsedMonth < 1 || parsedMonth > 12) {
throw FormatException('CSV row ${i + 1}: exp_month must be 1-12');
}
final parsedYear = int.tryParse(expYearRaw);
if (parsedYear == null || parsedYear < 0) {
throw FormatException('CSV row ${i + 1}: exp_year is invalid');
}
expMonth = parsedMonth;
expYear = parsedYear;
} else {
throw FormatException('CSV row ${i + 1}: exp_date is required');
}
rows.add(
@@ -114,7 +134,36 @@ class MultipleCsvParser {
return -1;
}
List<String> _parseCsvLine(String line) {
String _detectDelimiter(String line) {
final commaCount = _countUnquoted(line, ',');
final semicolonCount = _countUnquoted(line, ';');
if (semicolonCount > commaCount) return ';';
return ',';
}
int _countUnquoted(String line, String needle) {
var count = 0;
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) {
i++;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (char == needle && !inQuotes) {
count++;
}
}
return count;
}
List<String> _parseCsvLine(String line, String delimiter) {
final values = <String>[];
final buffer = StringBuffer();
var inQuotes = false;
@@ -133,7 +182,7 @@ class MultipleCsvParser {
continue;
}
if (char == ',' && !inQuotes) {
if (char == delimiter && !inQuotes) {
values.add(buffer.toString());
buffer.clear();
continue;
@@ -154,4 +203,26 @@ class MultipleCsvParser {
String _normalizeAmount(String value) {
return value.trim().replaceAll(' ', '').replaceAll(',', '.');
}
_ExpiryDate _parseExpiryDate(String value, int rowNumber) {
final match = RegExp(r'^\s*(\d{1,2})\s*/\s*(\d{2})\s*$').firstMatch(value);
if (match == null) {
throw FormatException(
'CSV row $rowNumber: exp_date must be in MM/YY format',
);
}
final month = int.parse(match.group(1)!);
final year = int.parse(match.group(2)!);
if (month < 1 || month > 12) {
throw FormatException('CSV row $rowNumber: exp_date month must be 1-12');
}
return _ExpiryDate(month, year);
}
}
class _ExpiryDate {
final int month;
final int year;
const _ExpiryDate(this.month, this.year);
}

View File

@@ -1,15 +1,14 @@
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/currency_pair.dart';
import 'package:pshared/models/payment/fx/intent.dart';
import 'package:pshared/models/payment/fx/side.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:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/payment/fx_helpers.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart';
@@ -18,14 +17,10 @@ class MultipleIntentBuilder {
static const String _currency = 'RUB';
List<PaymentIntent> buildIntents(
WalletsController wallets,
Wallet sourceWallet,
List<CsvPayoutRow> rows,
) {
final sourceWallet = wallets.selectedWallet;
if (sourceWallet == null) {
throw StateError('Select source wallet first');
}
final sourceCurrency = currencyCodeToString(sourceWallet.currency);
final hasAsset = (sourceWallet.tokenSymbol ?? '').isNotEmpty;
final sourceAsset = hasAsset
? PaymentAsset(
@@ -34,32 +29,37 @@ class MultipleIntentBuilder {
contractAddress: sourceWallet.contractAddress,
)
: null;
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
baseCurrency: sourceCurrency,
quoteCurrency: _currency,
);
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.fixReceived,
fx : FxIntent(
pair: CurrencyPair(
base: 'USDT', // TODO: fix currencies picking
quote: 'RUB',
(row) {
final amount = Money(amount: row.amount, currency: _currency);
return PaymentIntent(
kind: PaymentKind.payout,
source: ManagedWalletPaymentMethod(
managedWalletRef: sourceWallet.id,
asset: sourceAsset,
),
side: FxSide.sellBaseBuyQuote,
),
),
destination: CardPaymentMethod(
pan: row.pan,
firstName: row.firstName,
lastName: row.lastName,
expMonth: row.expMonth,
expYear: row.expYear,
),
amount: amount,
settlementMode: SettlementMode.fixReceived,
settlementCurrency: FxIntentHelper.resolveSettlementCurrency(
amount: amount,
fx: fxIntent,
),
fx: fxIntent,
);
},
)
.toList(growable: false);
}