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,159 @@
|
||||
using System.Globalization;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using Purrse.Plugins.Abstractions;
|
||||
using Purrse.Plugins.Abstractions.Models;
|
||||
|
||||
namespace Purrse.Plugins.CSV;
|
||||
|
||||
public class CSVParserPlugin : IFileParser
|
||||
{
|
||||
public string Id => "purrse-csv-parser";
|
||||
public string Name => "CSV Parser";
|
||||
public string Version => "1.0.0";
|
||||
public string Description => "Parses CSV bank statement files with configurable column mapping";
|
||||
public IReadOnlyList<string> SupportedExtensions => new[] { ".csv" };
|
||||
public string FileTypeDescription => "CSV Bank Statement Files";
|
||||
|
||||
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
|
||||
public Task ShutdownAsync() => Task.CompletedTask;
|
||||
|
||||
public bool CanParse(string fileName, Stream fileStream)
|
||||
{
|
||||
return Path.GetExtension(fileName).Equals(".csv", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task<ParseResult> ParseAsync(Stream fileStream, string fileName)
|
||||
{
|
||||
var result = new ParseResult { Success = true };
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(fileStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HasHeaderRecord = true,
|
||||
MissingFieldFound = null,
|
||||
HeaderValidated = null
|
||||
});
|
||||
|
||||
await csv.ReadAsync();
|
||||
csv.ReadHeader();
|
||||
var headers = csv.HeaderRecord?.Select(h => h.Trim().ToLowerInvariant()).ToArray() ?? Array.Empty<string>();
|
||||
|
||||
var mapping = DetectColumnMapping(headers);
|
||||
|
||||
while (await csv.ReadAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
var transaction = new ParsedTransaction();
|
||||
|
||||
if (mapping.DateColumn >= 0)
|
||||
{
|
||||
var dateStr = csv.GetField(mapping.DateColumn);
|
||||
if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
transaction.Date = date;
|
||||
}
|
||||
|
||||
if (mapping.AmountColumn >= 0)
|
||||
{
|
||||
var amountStr = csv.GetField(mapping.AmountColumn)?.Replace("$", "").Replace(",", "").Trim();
|
||||
if (decimal.TryParse(amountStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount))
|
||||
transaction.Amount = amount;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Separate debit/credit columns
|
||||
decimal amt = 0;
|
||||
if (mapping.DebitColumn >= 0)
|
||||
{
|
||||
var debitStr = csv.GetField(mapping.DebitColumn)?.Replace("$", "").Replace(",", "").Trim();
|
||||
if (!string.IsNullOrEmpty(debitStr) && decimal.TryParse(debitStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var debit))
|
||||
amt = -Math.Abs(debit);
|
||||
}
|
||||
if (mapping.CreditColumn >= 0 && amt == 0)
|
||||
{
|
||||
var creditStr = csv.GetField(mapping.CreditColumn)?.Replace("$", "").Replace(",", "").Trim();
|
||||
if (!string.IsNullOrEmpty(creditStr) && decimal.TryParse(creditStr, NumberStyles.Any, CultureInfo.InvariantCulture, out var credit))
|
||||
amt = Math.Abs(credit);
|
||||
}
|
||||
transaction.Amount = amt;
|
||||
}
|
||||
|
||||
if (mapping.DescriptionColumn >= 0)
|
||||
transaction.PayeeName = csv.GetField(mapping.DescriptionColumn)?.Trim();
|
||||
|
||||
if (mapping.MemoColumn >= 0)
|
||||
transaction.Memo = csv.GetField(mapping.MemoColumn)?.Trim();
|
||||
|
||||
if (mapping.ReferenceColumn >= 0)
|
||||
transaction.ReferenceNumber = csv.GetField(mapping.ReferenceColumn)?.Trim();
|
||||
|
||||
if (mapping.CheckColumn >= 0)
|
||||
transaction.CheckNumber = csv.GetField(mapping.CheckColumn)?.Trim();
|
||||
|
||||
if (mapping.TypeColumn >= 0)
|
||||
transaction.TransactionType = csv.GetField(mapping.TypeColumn)?.Trim();
|
||||
|
||||
if (transaction.Date != default)
|
||||
result.Transactions.Add(transaction);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip malformed rows
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ErrorMessage = $"Failed to parse CSV: {ex.Message}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ColumnMapping DetectColumnMapping(string[] headers)
|
||||
{
|
||||
var mapping = new ColumnMapping();
|
||||
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
{
|
||||
var h = headers[i];
|
||||
if (h.Contains("date") || h.Contains("posted"))
|
||||
mapping.DateColumn = i;
|
||||
else if (h == "amount" || h.Contains("transaction amount"))
|
||||
mapping.AmountColumn = i;
|
||||
else if (h.Contains("debit") || h.Contains("withdrawal"))
|
||||
mapping.DebitColumn = i;
|
||||
else if (h.Contains("credit") || h.Contains("deposit"))
|
||||
mapping.CreditColumn = i;
|
||||
else if (h.Contains("description") || h.Contains("payee") || h.Contains("name") || h.Contains("merchant"))
|
||||
mapping.DescriptionColumn = i;
|
||||
else if (h.Contains("memo") || h.Contains("note"))
|
||||
mapping.MemoColumn = i;
|
||||
else if (h.Contains("reference") || h.Contains("ref"))
|
||||
mapping.ReferenceColumn = i;
|
||||
else if (h.Contains("check") || h.Contains("cheque"))
|
||||
mapping.CheckColumn = i;
|
||||
else if (h.Contains("type") || h.Contains("category"))
|
||||
mapping.TypeColumn = i;
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private class ColumnMapping
|
||||
{
|
||||
public int DateColumn { get; set; } = -1;
|
||||
public int AmountColumn { get; set; } = -1;
|
||||
public int DebitColumn { get; set; } = -1;
|
||||
public int CreditColumn { get; set; } = -1;
|
||||
public int DescriptionColumn { get; set; } = -1;
|
||||
public int MemoColumn { get; set; } = -1;
|
||||
public int ReferenceColumn { get; set; } = -1;
|
||||
public int CheckColumn { get; set; } = -1;
|
||||
public int TypeColumn { get; set; } = -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user