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