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
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>
+110
View File
@@ -0,0 +1,110 @@
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models;
namespace Purrse.Plugins.QIF;
public class QIFParserPlugin : IFileParser
{
public string Id => "purrse-qif-parser";
public string Name => "QIF Parser";
public string Version => "1.0.0";
public string Description => "Parses QIF (Quicken Interchange Format) files";
public IReadOnlyList<string> SupportedExtensions => new[] { ".qif" };
public string FileTypeDescription => "QIF Financial Data 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(".qif", StringComparison.OrdinalIgnoreCase);
}
public async Task<ParseResult> ParseAsync(Stream fileStream, string fileName)
{
var result = new ParseResult { Success = true };
try
{
using var reader = new StreamReader(fileStream);
var content = await reader.ReadToEndAsync();
var lines = content.Split('\n').Select(l => l.Trim('\r', ' ')).ToArray();
ParsedTransaction? current = null;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
var code = line[0];
var value = line.Length > 1 ? line[1..] : "";
switch (code)
{
case '!':
// Header line - type declaration
break;
case 'D':
current ??= new ParsedTransaction();
if (DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var date))
current.Date = date;
else if (TryParseQIFDate(value, out var qifDate))
current.Date = qifDate;
break;
case 'T':
case 'U':
current ??= new ParsedTransaction();
var amountStr = value.Replace(",", "");
if (decimal.TryParse(amountStr, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var amount))
current.Amount = amount;
break;
case 'P':
current ??= new ParsedTransaction();
current.PayeeName = value;
break;
case 'M':
current ??= new ParsedTransaction();
current.Memo = value;
break;
case 'N':
current ??= new ParsedTransaction();
current.CheckNumber = value;
break;
case 'L':
// Category - stored in memo for now
if (current != null && string.IsNullOrEmpty(current.Memo))
current.Memo = $"[{value}]";
break;
case '^':
if (current != null && current.Date != default)
{
result.Transactions.Add(current);
}
current = null;
break;
}
}
}
catch (Exception ex)
{
result.Success = false;
result.ErrorMessage = $"Failed to parse QIF: {ex.Message}";
}
return result;
}
private static bool TryParseQIFDate(string value, out DateTime date)
{
// QIF dates can be M/D/YY, M/D'YY, M-D-YY, etc.
date = default;
var cleaned = value.Replace("'", "/").Replace("-", "/");
string[] formats = { "M/d/yyyy", "M/d/yy", "MM/dd/yyyy", "MM/dd/yy", "d/M/yyyy", "d/M/yy" };
return DateTime.TryParseExact(cleaned, formats,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out date);
}
}