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
+234
View File
@@ -0,0 +1,234 @@
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models;
namespace Purrse.Plugins.OFX;
public class OFXParserPlugin : IFileParser
{
public string Id => "purrse-ofx-parser";
public string Name => "OFX/QFX Parser";
public string Version => "1.0.0";
public string Description => "Parses OFX and QFX (Quicken) financial data files";
public IReadOnlyList<string> SupportedExtensions => new[] { ".ofx", ".qfx" };
public string FileTypeDescription => "OFX/QFX Financial Data Files";
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
public Task ShutdownAsync() => Task.CompletedTask;
public bool CanParse(string fileName, Stream fileStream)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext == ".ofx" || ext == ".qfx";
}
public async Task<ParseResult> ParseAsync(Stream fileStream, string fileName)
{
try
{
using var reader = new StreamReader(fileStream);
var content = await reader.ReadToEndAsync();
return ParseOFXContent(content);
}
catch (Exception ex)
{
return new ParseResult { Success = false, ErrorMessage = $"Failed to parse OFX file: {ex.Message}" };
}
}
private ParseResult ParseOFXContent(string content)
{
var result = new ParseResult { Success = true };
// Handle SGML-style OFX (v1.x) by converting to XML-like format
if (!content.TrimStart().StartsWith("<?xml", StringComparison.OrdinalIgnoreCase))
{
content = ConvertSgmlToXml(content);
}
// Parse XML content
try
{
using var stringReader = new StringReader(content);
var settings = new System.Xml.XmlReaderSettings
{
DtdProcessing = System.Xml.DtdProcessing.Ignore,
IgnoreWhitespace = true
};
using var xmlReader = System.Xml.XmlReader.Create(stringReader, settings);
string? currentElement = null;
ParsedTransaction? currentTransaction = null;
bool inTransaction = false;
while (xmlReader.Read())
{
switch (xmlReader.NodeType)
{
case System.Xml.XmlNodeType.Element:
currentElement = xmlReader.Name.ToUpperInvariant();
if (currentElement == "STMTTRN")
{
currentTransaction = new ParsedTransaction();
inTransaction = true;
}
break;
case System.Xml.XmlNodeType.Text:
var value = xmlReader.Value.Trim();
if (inTransaction && currentTransaction != null)
{
switch (currentElement)
{
case "TRNTYPE":
currentTransaction.TransactionType = value;
break;
case "DTPOSTED":
currentTransaction.Date = ParseOFXDate(value);
break;
case "TRNAMT":
if (decimal.TryParse(value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var amount))
currentTransaction.Amount = amount;
break;
case "FITID":
currentTransaction.FitId = value;
break;
case "NAME":
currentTransaction.PayeeName = value;
break;
case "MEMO":
currentTransaction.Memo = value;
break;
case "CHECKNUM":
currentTransaction.CheckNumber = value;
break;
case "REFNUM":
currentTransaction.ReferenceNumber = value;
break;
}
}
else
{
switch (currentElement)
{
case "ACCTID":
result.AccountIdentifier = value;
break;
case "ORG":
result.InstitutionName = value;
break;
case "BALAMT":
if (decimal.TryParse(value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var bal))
result.LedgerBalance = bal;
break;
case "DTSTART":
result.StatementStartDate = ParseOFXDate(value);
break;
case "DTEND":
result.StatementEndDate = ParseOFXDate(value);
break;
}
}
break;
case System.Xml.XmlNodeType.EndElement:
if (xmlReader.Name.Equals("STMTTRN", StringComparison.OrdinalIgnoreCase) && currentTransaction != null)
{
result.Transactions.Add(currentTransaction);
currentTransaction = null;
inTransaction = false;
}
break;
}
}
}
catch (Exception ex)
{
result.Success = false;
result.ErrorMessage = $"XML parsing error: {ex.Message}";
}
return result;
}
private static string ConvertSgmlToXml(string sgml)
{
var lines = sgml.Split('\n');
var xmlLines = new List<string>();
var openTags = new Stack<string>();
bool inHeader = true;
xmlLines.Add("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line)) continue;
// Skip OFX headers (before the first <)
if (inHeader)
{
if (line.StartsWith('<'))
inHeader = false;
else
continue;
}
if (line.StartsWith("</"))
{
// Closing tag
xmlLines.Add(line);
if (openTags.Count > 0) openTags.Pop();
}
else if (line.StartsWith('<'))
{
// Check if it's a tag with value on same line: <TAG>value
var tagEnd = line.IndexOf('>');
if (tagEnd > 0 && tagEnd < line.Length - 1)
{
var tag = line.Substring(1, tagEnd - 1);
var value = line.Substring(tagEnd + 1).Trim();
if (!string.IsNullOrEmpty(value))
{
// Self-contained: <TAG>value -> <TAG>value</TAG>
xmlLines.Add($"<{tag}>{System.Security.SecurityElement.Escape(value)}</{tag}>");
}
else
{
// Opening tag with no value
xmlLines.Add(line);
openTags.Push(tag);
}
}
else
{
xmlLines.Add(line);
if (tagEnd == line.Length - 1)
{
var tag = line.Substring(1, tagEnd - 1);
if (!tag.StartsWith('/'))
openTags.Push(tag);
}
}
}
}
return string.Join("\n", xmlLines);
}
private static DateTime ParseOFXDate(string ofxDate)
{
// OFX dates: YYYYMMDDHHMMSS[.XXX:tz] or YYYYMMDD
if (ofxDate.Length >= 8)
{
var datePart = ofxDate.Substring(0, 8);
if (DateTime.TryParseExact(datePart, "yyyyMMdd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var date))
return date;
}
return DateTime.Parse(ofxDate);
}
}
@@ -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>