using System.Text.Json; using Microsoft.EntityFrameworkCore; using Purrse.Core.DTOs; using Purrse.Core.Enums; using Purrse.Core.Helpers; using Purrse.Core.Interfaces.Services; using Purrse.Core.Models; using Purrse.Plugins.Abstractions; using Purrse.Plugins.Abstractions.Models; using Purrse.Data; namespace Purrse.Api.Services; public class ImportService : IImportService { private readonly PurrseDbContext _db; private readonly IDuplicateDetectionService _duplicateService; private readonly IPayeeService _payeeService; private readonly IEnumerable _parsers; public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable parsers) { _db = db; _duplicateService = duplicateService; _payeeService = payeeService; _parsers = parsers; } public async Task UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream) { var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId) ?? throw new KeyNotFoundException("Account not found"); var parser = _parsers.FirstOrDefault(p => p.CanParse(fileName, fileStream)) ?? throw new InvalidOperationException($"No parser found for file: {fileName}"); fileStream.Position = 0; var parseResult = await parser.ParseAsync(fileStream, fileName); if (!parseResult.Success) throw new InvalidOperationException(parseResult.ErrorMessage ?? "Failed to parse file"); var batch = new ImportBatch { Id = Guid.NewGuid(), UserId = userId, AccountId = accountId, FileName = fileName, FileType = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(), TotalCount = parseResult.Transactions.Count, Status = ImportStatus.Pending, ParsedDataJson = JsonSerializer.Serialize(parseResult.Transactions) }; var previews = new List(); for (int i = 0; i < parseResult.Transactions.Count; i++) { var parsed = parseResult.Transactions[i]; var duplicate = await _duplicateService.FindDuplicateAsync( accountId, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.FitId, parsed.CheckNumber); previews.Add(new ImportedTransactionPreview( i, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.Memo, parsed.FitId, duplicate)); } _db.ImportBatches.Add(batch); await _db.SaveChangesAsync(); var newCount = previews.Count(p => p.DuplicateMatch == null); var dupCount = previews.Count(p => p.DuplicateMatch != null); return new ImportUploadResponse(batch.Id, parseResult.Transactions.Count, newCount, dupCount, previews); } public async Task ConfirmBatchAsync(Guid userId, Guid batchId) { var batch = await _db.ImportBatches .Include(b => b.Account) .FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId) ?? throw new KeyNotFoundException("Import batch not found"); batch.Status = ImportStatus.Confirmed; await _db.SaveChangesAsync(); return MapBatchResponse(batch); } public async Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request) { var batch = await _db.ImportBatches .Include(b => b.Account) .FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId) ?? throw new KeyNotFoundException("Import batch not found"); if (string.IsNullOrEmpty(batch.ParsedDataJson)) throw new InvalidOperationException("No parsed data available for this batch. It may have already been processed."); var parsedTransactions = JsonSerializer.Deserialize>(batch.ParsedDataJson) ?? throw new InvalidOperationException("Failed to deserialize parsed data"); var resolutionMap = request.Resolutions.ToDictionary(r => r.Index, r => r.Action); int importedCount = 0; int skippedCount = 0; int mergedCount = 0; for (int i = 0; i < parsedTransactions.Count; i++) { var parsed = parsedTransactions[i]; var action = resolutionMap.GetValueOrDefault(i, DuplicateAction.Import); switch (action) { case DuplicateAction.Import: await CreateTransaction(userId, batch, parsed); importedCount++; break; case DuplicateAction.Skip: skippedCount++; break; case DuplicateAction.Merge: var merged = await MergeTransaction(batch.AccountId, parsed); if (merged) mergedCount++; else { // If no existing transaction found to merge with, import instead await CreateTransaction(userId, batch, parsed); importedCount++; } break; } } batch.ImportedCount = importedCount; batch.SkippedCount = skippedCount; batch.DuplicateCount = mergedCount; batch.ParsedDataJson = null; batch.Status = ImportStatus.Confirmed; await _db.SaveChangesAsync(); return MapBatchResponse(batch); } public async Task> GetBatchesAsync(Guid userId) { return await _db.ImportBatches .Include(b => b.Account) .Where(b => b.UserId == userId) .OrderByDescending(b => b.ImportedAt) .Select(b => new ImportBatchResponse( b.Id, b.AccountId, b.Account.Name, b.FileName, b.FileType, b.TotalCount, b.ImportedCount, b.DuplicateCount, b.SkippedCount, 0, b.Status.ToString(), b.ImportedAt)) .ToListAsync(); } private async Task CreateTransaction(Guid userId, ImportBatch batch, ParsedTransaction parsed) { var fingerprint = TransactionFingerprint.Generate(batch.AccountId, parsed.Date, parsed.Amount, parsed.FitId); var matchedPayee = await _payeeService.MatchPayeeAsync(userId, parsed.PayeeName ?? string.Empty); var transaction = new Transaction { Id = Guid.NewGuid(), AccountId = batch.AccountId, Date = parsed.Date, Amount = parsed.Amount, PayeeId = matchedPayee?.Id, PayeeName = parsed.PayeeName, CategoryId = matchedPayee?.DefaultCategoryId, Memo = parsed.Memo, ReferenceNumber = parsed.ReferenceNumber, CheckNumber = parsed.CheckNumber, FitId = parsed.FitId, Fingerprint = fingerprint, Type = parsed.Amount >= 0 ? TransactionType.Credit : TransactionType.Debit, Status = TransactionStatus.Uncleared, ImportBatchId = batch.Id }; _db.Transactions.Add(transaction); // Update account balance batch.Account.Balance += parsed.Amount; batch.Account.UpdatedAt = DateTime.UtcNow; } private async Task MergeTransaction(Guid accountId, ParsedTransaction parsed) { var duplicate = await _duplicateService.FindDuplicateAsync( accountId, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.FitId, parsed.CheckNumber); if (duplicate == null) return false; var existing = await _db.Transactions.FindAsync(duplicate.ExistingTransactionId); if (existing == null) return false; // Fill in blank fields without overwriting user-edited data if (string.IsNullOrEmpty(existing.Memo) && !string.IsNullOrEmpty(parsed.Memo)) existing.Memo = parsed.Memo; if (string.IsNullOrEmpty(existing.FitId) && !string.IsNullOrEmpty(parsed.FitId)) existing.FitId = parsed.FitId; if (string.IsNullOrEmpty(existing.CheckNumber) && !string.IsNullOrEmpty(parsed.CheckNumber)) existing.CheckNumber = parsed.CheckNumber; if (string.IsNullOrEmpty(existing.ReferenceNumber) && !string.IsNullOrEmpty(parsed.ReferenceNumber)) existing.ReferenceNumber = parsed.ReferenceNumber; if (string.IsNullOrEmpty(existing.Fingerprint)) existing.Fingerprint = TransactionFingerprint.Generate(accountId, parsed.Date, parsed.Amount, parsed.FitId); existing.UpdatedAt = DateTime.UtcNow; return true; } private static ImportBatchResponse MapBatchResponse(ImportBatch batch) { return new ImportBatchResponse( batch.Id, batch.AccountId, batch.Account?.Name ?? string.Empty, batch.FileName, batch.FileType, batch.TotalCount, batch.ImportedCount, batch.DuplicateCount, batch.SkippedCount, batch.DuplicateCount, batch.Status.ToString(), batch.ImportedAt); } }