Initial commit: Purrse personal finance app
Self-hosted, plugin-extensible personal finance manager built with ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17. Backend (8 .NET projects): - Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces - Data: EF Core DbContext, 16 entity configurations, category seeder - API: 14 controllers, 15 services, JWT auth, SignalR, middleware - Plugins: Abstractions + OFX/CSV/QIF file parsers - Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers) Frontend (Vue 3 + Vuetify 3 + TypeScript): - 13 views, Pinia stores, Axios API services with JWT interceptors - Dashboard, accounts, transactions, categories, imports, and more Deployment: - Docker Compose (PostgreSQL 17 + .NET API + nginx frontend) - Auto-migration on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
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.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
|
||||
};
|
||||
|
||||
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.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Import batch not found");
|
||||
|
||||
batch.Status = ImportStatus.Confirmed;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ImportBatchResponse(batch.Id, batch.FileName, batch.FileType, batch.TotalCount,
|
||||
batch.ImportedCount, batch.DuplicateCount, batch.SkippedCount, batch.Status.ToString(), batch.ImportedAt);
|
||||
}
|
||||
|
||||
public async Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request)
|
||||
{
|
||||
var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Import batch not found");
|
||||
|
||||
foreach (var resolution in request.Resolutions)
|
||||
{
|
||||
switch (resolution.Action)
|
||||
{
|
||||
case DuplicateAction.Skip:
|
||||
batch.SkippedCount++;
|
||||
break;
|
||||
case DuplicateAction.Import:
|
||||
batch.ImportedCount++;
|
||||
break;
|
||||
case DuplicateAction.Merge:
|
||||
batch.DuplicateCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user