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

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