Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 08:56:46 -05:00
commit 6520ebf221
169 changed files with 7180 additions and 0 deletions
+159
View File
@@ -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>