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,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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user