ledger top up functionality and few small fixes for project architechture and design

This commit is contained in:
Arseni
2026-03-05 21:49:23 +03:00
parent 39c04beb21
commit 97b16542c2
41 changed files with 764 additions and 455 deletions

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

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