diff --git a/QFXparser.Testing/ParsingTest.cs b/QFXparser.Testing/ParsingTest.cs new file mode 100644 index 0000000..3718816 --- /dev/null +++ b/QFXparser.Testing/ParsingTest.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using Xunit; + +namespace QFXparser.Testing +{ + public class ParsingTest + { + [Fact] + public void TestSimple() + { + var parser = new FileParser("test.qfx"); + var statement = parser.BuildStatement(); + var transactions = statement.Transactions.ToList(); + Assert.Equal(3, transactions.Count); + Assert.Equal(-768.33m, statement.LedgerBalance.Amount); + Assert.Equal(new DateTime(2018, 5, 25, 0, 0, 0, DateTimeKind.Utc), statement.LedgerBalance.AsOf); + Assert.Equal(-27.18m, transactions[0].Amount); + Assert.Equal("AMAZON.COM AMZN.COM/BILL AMZN.CO", transactions[0].Memo); + Assert.Equal(new DateTime(2018, 4, 27, 16, 0, 0, DateTimeKind.Utc), transactions[0].PostedOn); + Assert.Equal(0m, transactions[2].Amount); // test for default value if invalid in .qfx + } + + [Fact] + public void TestDateTimeNoTimeZoneAssumesUtc() + { + Assert.Equal(new DateTime(2018, 5, 25, 0, 0, 0, DateTimeKind.Utc), ParsingHelper.ParseDate("20180525000000")); + } + + [Fact] + public void TestDateTimeWithUtcTimeZone() + { + Assert.Equal(new DateTime(2018, 5, 25, 0, 0, 0, DateTimeKind.Utc), ParsingHelper.ParseDate("20180525000000[0:UTC]")); + } + + [Fact] + public void TestDateTimeWithMstTimeZone() + { + Assert.Equal(new DateTime(2018, 1, 19, 0, 0, 0, DateTimeKind.Utc), ParsingHelper.ParseDate("20180119000000.000[-7:MST]")); + } + } +} diff --git a/QFXparser.Testing/QFXparser.Testing.csproj b/QFXparser.Testing/QFXparser.Testing.csproj new file mode 100644 index 0000000..4bc75c2 --- /dev/null +++ b/QFXparser.Testing/QFXparser.Testing.csproj @@ -0,0 +1,25 @@ + + + + netcoreapp2.1 + + false + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/QFXparser.Testing/test.qfx b/QFXparser.Testing/test.qfx new file mode 100644 index 0000000..7c02972 --- /dev/null +++ b/QFXparser.Testing/test.qfx @@ -0,0 +1,79 @@ +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + + + + + 0 + INFO + + 20180708011545[0:UTC] + ENG + + Bank of America + 9876 + + 3456 + + + + + 20180708011545[0:UTC] + + 0 + INFO + + + USD + + 1234123409870987 + + + 20180427160000[0:UTC] + 20180525160000[0:UTC] + + PAYMENT + 20180427160000[0:UTC] + -27.18 + 555444 + 555444 + REPLACE + AMAZON.COM AMZN.COM/BILL AMZN.CO + AMAZON.COM AMZN.COM/BILL AMZN.CO + + + PAYMENT + 20180430160000[0:UTC] + -25.00 + 666876 + 666876 + REPLACE + NEW JERSEY E-ZPASS 888-288-6865 + NEW JERSEY E-ZPASS 888-288-6865 + + + CREDIT + 20180501160000[0:UTC] + 56.77invalid + 46576 + 46576 + REPLACE + PMT FROM BILL PAYER SERVICE + + + + -768.33 + 20180525000000[0:UTC] + + + + + \ No newline at end of file diff --git a/QFXparser.sln b/QFXparser.sln index ee052b0..9facaab 100644 --- a/QFXparser.sln +++ b/QFXparser.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QFXparser.TestApp", "QFXpar EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QFXparser", "QFXparser\QFXparser.csproj", "{F99BA880-DE1C-4D30-BEB3-EB0406E41955}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QFXparser.Testing", "QFXparser.Testing\QFXparser.Testing.csproj", "{AE23D849-AF0F-4231-81DD-C345C7321702}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +43,18 @@ Global {F99BA880-DE1C-4D30-BEB3-EB0406E41955}.Release|x64.Build.0 = Release|Any CPU {F99BA880-DE1C-4D30-BEB3-EB0406E41955}.Release|x86.ActiveCfg = Release|Any CPU {F99BA880-DE1C-4D30-BEB3-EB0406E41955}.Release|x86.Build.0 = Release|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Debug|x64.Build.0 = Debug|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Debug|x86.Build.0 = Debug|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Release|Any CPU.Build.0 = Release|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Release|x64.ActiveCfg = Release|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Release|x64.Build.0 = Release|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Release|x86.ActiveCfg = Release|Any CPU + {AE23D849-AF0F-4231-81DD-C345C7321702}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/QFXparser/AssemblyInfo.cs b/QFXparser/AssemblyInfo.cs new file mode 100644 index 0000000..b763456 --- /dev/null +++ b/QFXparser/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("QFXparser.Testing")] diff --git a/QFXparser/LedgerBalance.cs b/QFXparser/LedgerBalance.cs new file mode 100644 index 0000000..5876671 --- /dev/null +++ b/QFXparser/LedgerBalance.cs @@ -0,0 +1,11 @@ +using System; + +namespace QFXparser +{ + public class LedgerBalance + { + public decimal Amount { get; set; } + + public DateTime AsOf { get; set; } + } +} \ No newline at end of file diff --git a/QFXparser/NodeType.cs b/QFXparser/NodeType.cs index 0f3ff95..9cac71a 100644 --- a/QFXparser/NodeType.cs +++ b/QFXparser/NodeType.cs @@ -11,6 +11,9 @@ internal enum NodeType TransactionOpen, TransactionClose, StatementProp, - TransactionProp + TransactionProp, + LedgerBalanceOpen, + LedgerBalanceClose, + LedgerBalanceProp } } diff --git a/QFXparser/ParsingHelper.cs b/QFXparser/ParsingHelper.cs new file mode 100644 index 0000000..56bc92f --- /dev/null +++ b/QFXparser/ParsingHelper.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace QFXparser +{ + internal static class ParsingHelper + { + private static readonly Regex _dateTimeRegex = new Regex( + "^(?\\d{4})(?\\d{2})(?\\d{2})(?\\d{2})(?\\d{2})(?\\d{2})(\\[(?\\d+):(?\\w{3})\\])?"); + + public static DateTime? ParseDate(string content) + { + DateTime? result; + var match = _dateTimeRegex.Match(content); + if (!match.Success) + { + result = null; + } + else + { + var groups = match.Groups; + var year = int.Parse(groups["year"].Value); + var month = int.Parse(groups["month"].Value); + var day = int.Parse(groups["day"].Value); + var hour = int.Parse(groups["hour"].Value); + var minute = int.Parse(groups["min"].Value); + var second = int.Parse(groups["sec"].Value); + var timeZone = groups["timezone"].Success ? groups["timezone"].Value : "UTC"; + + var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone); + result = new DateTimeOffset(year, month, day, hour, minute, second, timeZoneInfo.BaseUtcOffset).ToUniversalTime().DateTime; + } + return result; + } + } +} diff --git a/QFXparser/QFXparser.cs b/QFXparser/QFXparser.cs index 168f5c8..6442898 100644 --- a/QFXparser/QFXparser.cs +++ b/QFXparser/QFXparser.cs @@ -1,12 +1,14 @@ -using System.IO; +using System; +using System.IO; using System.Linq; using System.Reflection; namespace QFXparser { public class FileParser - { + { private string _fileText; + private RawLedgerBalance _ledgerBalance; public FileParser(string fileNamePath) { @@ -30,19 +32,19 @@ public Statement BuildStatement() { RawStatement rawStatement = BuildRaw(); - Statement statement = new Statement() + Statement statement = new Statement { AccountNum = rawStatement.AccountNum }; foreach (var rawTrans in rawStatement.Transactions) { - Transaction trans = new Transaction() + Transaction trans = new Transaction { Amount = rawTrans.Amount, Memo = rawTrans.Memo, Name = rawTrans.Name, - PostedOn = rawTrans.DatePosted, + PostedOn = rawTrans.PostedOn, RefNumber = rawTrans.RefNumber, TransactionId = rawTrans.TransactionId, Type = rawTrans.Type @@ -50,6 +52,12 @@ public Statement BuildStatement() statement.Transactions.Add(trans); } + statement.LedgerBalance = new LedgerBalance + { + Amount = rawStatement.LedgerBalance.Amount, + AsOf = rawStatement.LedgerBalance.AsOf + }; + return statement; } @@ -91,6 +99,20 @@ private RawStatement BuildRaw() case NodeType.TransactionProp: currentMember = result.Member; break; + case NodeType.LedgerBalanceOpen: + _ledgerBalance = new RawLedgerBalance(); + break; + case NodeType.LedgerBalanceClose: + _statement.LedgerBalance.Amount = _ledgerBalance.Amount; + _statement.LedgerBalance.AsOf = _ledgerBalance.AsOf; + break; + case NodeType.LedgerBalanceProp: + if (_ledgerBalance == null) + { + _ledgerBalance = new RawLedgerBalance(); + } + currentMember = result.Member; + break; default: break; } @@ -108,10 +130,13 @@ private RawStatement BuildRaw() switch (property.DeclaringType.Name) { case "RawStatement": - property.SetValue(_statement, token.Content); + property.SetValue(_statement, ConvertQfxType(token.Content, property.PropertyType)); break; case "RawTransaction": - property.SetValue(_currentTransaction, token.Content); + property.SetValue(_currentTransaction, ConvertQfxType(token.Content, property.PropertyType)); + break; + case "RawLedgerBalance": + property.SetValue(_ledgerBalance, ConvertQfxType(token.Content, property.PropertyType)); break; default: break; @@ -123,6 +148,27 @@ private RawStatement BuildRaw() return _statement; } + private object ConvertQfxType(string content, Type targetType) + { + object result; + if (targetType == typeof(DateTime)) + { + result = ParsingHelper.ParseDate(content); + } + else + { + try + { + result = Convert.ChangeType(content, targetType); + } + catch (Exception) + { + result = targetType.IsValueType ? Activator.CreateInstance(targetType) : null; + } + } + return result; + } + private PropertyResult GetPropertyInfo(string token) { var propertyResult = new PropertyResult(); @@ -155,6 +201,19 @@ private PropertyResult GetPropertyInfo(string token) return propertyResult; } + if (typeof(RawLedgerBalance).GetCustomAttribute().OpenTag == token) + { + propertyResult.Member = typeof(RawLedgerBalance); + propertyResult.Type = NodeType.LedgerBalanceOpen; + return propertyResult; + } + + if (typeof(RawLedgerBalance).GetCustomAttribute().CloseTag == token) + { + propertyResult.Member = typeof(RawLedgerBalance); + propertyResult.Type = NodeType.LedgerBalanceClose; + return propertyResult; + } var statementMember = typeof(RawStatement).GetProperties().FirstOrDefault(m => m.GetCustomAttribute().OpenTag == token); @@ -175,9 +234,17 @@ private PropertyResult GetPropertyInfo(string token) return propertyResult; } - return null; + var balanceMember = typeof(RawLedgerBalance).GetProperties().Where(m => m.GetCustomAttribute() != null) + .FirstOrDefault(m => m.GetCustomAttribute().OpenTag == token); - } + if (balanceMember != null) + { + propertyResult.Member = balanceMember; + propertyResult.Type = NodeType.LedgerBalanceProp; + return propertyResult; + } + return null; + } } } diff --git a/QFXparser/RawLedgerBalance.cs b/QFXparser/RawLedgerBalance.cs new file mode 100644 index 0000000..385b90c --- /dev/null +++ b/QFXparser/RawLedgerBalance.cs @@ -0,0 +1,14 @@ +using System; + +namespace QFXparser +{ + [NodeName("LEDGERBAL", "/LEDGERBAL")] + public class RawLedgerBalance + { + [NodeName("BALAMT")] + public decimal Amount { get; set; } + + [NodeName("DTASOF")] + public DateTime AsOf { get; set; } + } +} \ No newline at end of file diff --git a/QFXparser/RawStatement.cs b/QFXparser/RawStatement.cs index c5c1ef5..b917e79 100644 --- a/QFXparser/RawStatement.cs +++ b/QFXparser/RawStatement.cs @@ -12,5 +12,8 @@ internal class RawStatement [NodeName("BANKTRANLIST")] public ICollection Transactions { get; set; } = new List(); + + [NodeName("LEDGERBAL")] + public RawLedgerBalance LedgerBalance { get; set; } = new RawLedgerBalance(); } } diff --git a/QFXparser/RawTransaction.cs b/QFXparser/RawTransaction.cs index 03e627f..68b50e8 100644 --- a/QFXparser/RawTransaction.cs +++ b/QFXparser/RawTransaction.cs @@ -11,10 +11,10 @@ internal class RawTransaction public string Type { get; set; } [NodeName("DTPOSTED")] - public String PostedOn { get; set; } //20180119000000.000[-7:MST] + public DateTime PostedOn { get; set; } [NodeName("TRNAMT")] - public String StrAmount { get; set; } + public Decimal Amount { get; set; } [NodeName("FITID")] public string TransactionId { get; set; } @@ -27,35 +27,5 @@ internal class RawTransaction [NodeName("MEMO")] public string Memo { get; set; } - - public DateTime DatePosted - { - get - { - var dateStr = PostedOn.Substring(0, 12) + "Z"; - Regex regex = new Regex(@"(?<=\[)([^)]+)(?=\])"); - var tzstr = regex.Match(PostedOn).Groups[0].Value; - TimeZoneInfo tzi = TimeZoneInfo.Utc; - if (!string.IsNullOrEmpty(tzstr)) - { - var tzstrSplit = tzstr.Split(':'); - var timeSpan = Convert.ToDouble(tzstrSplit[0]); - tzi = TimeZoneInfo.CreateCustomTimeZone(tzstrSplit[1], TimeSpan.FromHours(timeSpan), tzstrSplit[1], tzstrSplit[1]); - } - var date = DateTimeOffset.ParseExact(dateStr, "yyyyMMddHHmmZ", CultureInfo.InvariantCulture); - var newdate = TimeZoneInfo.ConvertTime(date, tzi); - return newdate.DateTime; - } - } - - public Decimal Amount - { - get - { - decimal amount = 0; - Decimal.TryParse(StrAmount, out amount); - return amount; - } - } } } diff --git a/QFXparser/Statement.cs b/QFXparser/Statement.cs index d51be55..45ff655 100644 --- a/QFXparser/Statement.cs +++ b/QFXparser/Statement.cs @@ -6,5 +6,6 @@ public class Statement { public string AccountNum { get; set; } public ICollection Transactions { get; set; } = new List(); + public LedgerBalance LedgerBalance { get; set; } = new LedgerBalance(); } }