ledger top up functionality and few small fixes for project architechture and design
This commit is contained in:
230
frontend/pweb/lib/utils/payment/multiple/csv_parser.dart
Normal file
230
frontend/pweb/lib/utils/payment/multiple/csv_parser.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
import 'package:pweb/models/payment/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 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']);
|
||||
final firstNameIndex = _resolveHeaderIndex(header, const [
|
||||
'first_name',
|
||||
'firstname',
|
||||
]);
|
||||
final lastNameIndex = _resolveHeaderIndex(header, const [
|
||||
'last_name',
|
||||
'lastname',
|
||||
]);
|
||||
final expDateIndex = _resolveHeaderIndex(header, const [
|
||||
'exp_date',
|
||||
'expiry',
|
||||
'expiry_date',
|
||||
]);
|
||||
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 ||
|
||||
(expDateIndex < 0 &&
|
||||
(expMonthIndex < 0 || expYearIndex < 0)) ||
|
||||
amountIndex < 0) {
|
||||
throw FormatException(
|
||||
'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], 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));
|
||||
|
||||
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 = parseMoneyAmount(amount, fallback: double.nan);
|
||||
if (parsedAmount.isNaN || parsedAmount <= 0) {
|
||||
throw FormatException(
|
||||
'CSV row ${i + 1}: amount must be greater than 0',
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 == delimiter && !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(',', '.');
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
46
frontend/pweb/lib/utils/payment/multiple/intent_builder.dart
Normal file
46
frontend/pweb/lib/utils/payment/multiple/intent_builder.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:pshared/models/money.dart';
|
||||
import 'package:pshared/models/payment/fees/treatment.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/data.dart';
|
||||
import 'package:pshared/models/payment/settlement_mode.dart';
|
||||
import 'package:pshared/utils/payment/fx_helpers.dart';
|
||||
|
||||
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
|
||||
|
||||
class MultipleIntentBuilder {
|
||||
static const String _currency = 'RUB';
|
||||
|
||||
List<PaymentIntent> buildIntents({
|
||||
required PaymentMethodData sourceMethod,
|
||||
required String sourceCurrency,
|
||||
required List<CsvPayoutRow> rows,
|
||||
}) {
|
||||
final fxIntent = FxIntentHelper.buildSellBaseBuyQuote(
|
||||
baseCurrency: sourceCurrency,
|
||||
quoteCurrency: _currency,
|
||||
);
|
||||
|
||||
return rows
|
||||
.map((row) {
|
||||
final amount = Money(amount: row.amount, currency: _currency);
|
||||
return PaymentIntent(
|
||||
kind: PaymentKind.payout,
|
||||
source: sourceMethod,
|
||||
destination: CardPaymentMethod(
|
||||
pan: row.pan,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
expMonth: row.expMonth,
|
||||
expYear: row.expYear,
|
||||
),
|
||||
amount: amount,
|
||||
feeTreatment: FeeTreatment.addToSource,
|
||||
settlementMode: SettlementMode.fixReceived,
|
||||
fx: fxIntent,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user