using BudgetApp.Enums; using BudgetApp.PublicModels; using BudgetApp.Storage; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using System.Globalization; using System.Text; namespace BudgetApp.Services { public class UploadService(BudgetContext context) : IUploadService { private readonly BudgetContext _context = context ?? throw new ArgumentNullException(nameof(context)); public async Task UploadCsv(IFormFile file, CancellationToken cancellationToken = default) { await SetUpdateStatusState(UpdateStatusState.Processing); var response = new BaseResponse(); var errors = new List(); var transactionsToInsert = new List(); var existingIds = await _context.Transactions .AsNoTracking() .Select(t => t.TransactionId) .ToListAsync(cancellationToken) .ConfigureAwait(false); var knownTransactionIds = new HashSet(existingIds, StringComparer.OrdinalIgnoreCase); using (var stream = file.OpenReadStream()) using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) { while (!reader.EndOfStream) { cancellationToken.ThrowIfCancellationRequested(); var rawLine = await reader.ReadLineAsync().ConfigureAwait(false); if (string.IsNullOrWhiteSpace(rawLine)) { continue; } if (!rawLine.StartsWith("2168", StringComparison.Ordinal)) { continue; // Non-transaction rows (e.g., headers) are ignored. } var cols = rawLine.Split(';'); if (cols.Length <= 23) { errors.Add("Skipping row with insufficient number of columns."); continue; } var transactionId = cols[23].Trim('\"', ' '); if (string.IsNullOrWhiteSpace(transactionId)) { errors.Add("Skipping row without transaction identifier."); continue; } if (!knownTransactionIds.Add(transactionId)) { continue; // Already processed or exists in database. } if (!DateOnly.TryParseExact(cols[1].Trim('\"', ' '), "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { errors.Add($"Skipping row with invalid date value: '{cols[1]}'."); knownTransactionIds.Remove(transactionId); continue; } if (!TryParseDecimal(cols[2], out var amount)) { errors.Add($"Skipping row with invalid amount value: '{cols[2]}'."); knownTransactionIds.Remove(transactionId); continue; } if (!TryParseDecimal(cols[4], out var balance)) { errors.Add($"Skipping row with invalid balance value: '{cols[4]}'."); knownTransactionIds.Remove(transactionId); continue; } var transaction = new Transaction { Amount = amount, Balance = balance, Category = cols[16].Trim('\"', ' '), Date = date, Note = cols[15].Trim('\"', ' '), OperationDescription = string.Join(" | ", SafeGetColumn(cols, 12), SafeGetColumn(cols, 13), SafeGetColumn(cols, 14)).Trim(' ', '|'), TransactionId = transactionId }; transactionsToInsert.Add(transaction); } } if (transactionsToInsert.Count > 0) { await _context.Transactions.AddRangeAsync(transactionsToInsert, cancellationToken).ConfigureAwait(false); await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } if (errors.Count > 0) { response.Success = false; response.Error = string.Join(" ", errors.Distinct()); } await SetUpdateStatusState(UpdateStatusState.Done); return response; } private static string SafeGetColumn(string[] columns, int index) { return index >= 0 && index < columns.Length ? columns[index].Trim('\"', ' ') : string.Empty; } private static bool TryParseDecimal(string input, out decimal value) { value = 0m; if (string.IsNullOrWhiteSpace(input)) { return false; } var sanitized = NormalizeDecimalValue(input); return decimal.TryParse(sanitized, NumberStyles.Number | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value); } private static string NormalizeDecimalValue(string input) { var normalized = input .Replace("\u00A0", string.Empty) // non-breaking space .Trim(); var builder = new StringBuilder(normalized.Length); foreach (var ch in normalized) { if (char.IsDigit(ch) || ch is '-' or '+' || ch == ',' || ch == '.') { builder.Append(ch); } } var cleaned = builder.ToString(); var lastComma = cleaned.LastIndexOf(','); var lastDot = cleaned.LastIndexOf('.'); if (lastComma >= 0 && lastDot >= 0) { if (lastComma > lastDot) { cleaned = cleaned.Replace(".", string.Empty); cleaned = cleaned.Replace(',', '.'); } else { cleaned = cleaned.Replace(",", string.Empty); } } else if (lastComma >= 0) { cleaned = cleaned.Replace(".", string.Empty); cleaned = cleaned.Replace(',', '.'); } else { cleaned = cleaned.Replace(",", string.Empty); } return cleaned; } private async Task SetUpdateStatusState(UpdateStatusState state) { var updateStatus = await _context.UpdateStatuses .SingleAsync(); updateStatus.State = state; await _context.SaveChangesAsync(); } } public interface IUploadService { Task UploadCsv(IFormFile file, CancellationToken cancellationToken = default); } }