Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Services/ImportService.cs
T
Catherine Renelle 2ace39c544 Implement Phase 2: end-to-end import system
Complete the import flow from file upload through duplicate resolution
to transaction creation. Previously, ResolveDuplicatesAsync was a stub
that only incremented counters, FileWatcherService detected files but
didn't process them, and ImportsView.vue ignored the API response.

Backend:
- Add ParsedDataJson column to ImportBatch to persist parsed data
  between upload and confirm steps
- Rewrite ImportService.ResolveDuplicatesAsync to deserialize parsed
  transactions and create/merge/skip based on user decisions, with
  payee matching, fingerprinting, and account balance updates
- Add GetBatchesAsync for import history
- Complete FileWatcherService: wait for file ready, resolve account by
  filename, call UploadAsync, send SignalR notification, archive file
- Add GET /api/imports/batches endpoint

Frontend:
- Create imports API service (frontend/src/services/imports.ts)
- Add ImportBatch, DuplicateResolution types
- Rewrite ImportsView.vue with 3-step Vuetify stepper wizard:
  upload, review with duplicate handling, completion summary,
  plus import history table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 11:16:39 -05:00

253 lines
9.3 KiB
C#

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<IFileParser> _parsers;
public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable<IFileParser> parsers)
{
_db = db;
_duplicateService = duplicateService;
_payeeService = payeeService;
_parsers = parsers;
}
public async Task<ImportUploadResponse> 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<ImportedTransactionPreview>();
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<ImportBatchResponse> 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<ImportBatchResponse> 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<List<ParsedTransaction>>(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<List<ImportBatchResponse>> 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<bool> 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);
}
}