diff --git a/.editorconfig b/.editorconfig index 111416e..7c9ca24 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,6 +18,7 @@ indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +dotnet_diagnostic.CS1591.severity = none # Xml project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,msbuildproj}] @@ -66,7 +67,7 @@ dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case # Constants are PascalCase dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants -dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style +dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_symbols.constants.applicable_kinds = field, local dotnet_naming_symbols.constants.required_modifiers = const @@ -76,7 +77,7 @@ dotnet_naming_style.constant_style.capitalization = pascal_case # Static fields are camelCase dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields -dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style +dotnet_naming_rule.static_fields_should_be_camel_case.style = camel_case_style dotnet_naming_symbols.static_fields.applicable_kinds = field dotnet_naming_symbols.static_fields.required_modifiers = static @@ -86,7 +87,7 @@ dotnet_naming_style.static_field_style.capitalization = camel_case # Instance fields are camelCase dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields -dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style +dotnet_naming_rule.instance_fields_should_be_camel_case.style = camel_case_style dotnet_naming_symbols.instance_fields.applicable_kinds = field @@ -104,7 +105,7 @@ dotnet_naming_style.camel_case_style.capitalization = camel_case # Local functions are PascalCase dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions -dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style +dotnet_naming_rule.local_functions_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_symbols.local_functions.applicable_kinds = local_function @@ -113,11 +114,26 @@ dotnet_naming_style.local_function_style.capitalization = pascal_case # By default, name items with PascalCase dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members -dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.members_should_be_pascal_case.style = non_private_static_field_style dotnet_naming_symbols.all_members.applicable_kinds = * dotnet_naming_style.pascal_case_style.capitalization = pascal_case +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +end_of_line = crlf +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_diagnostic.CA1001.severity = warning # CSharp code style settings: [*.cs] @@ -166,6 +182,18 @@ dotnet_diagnostic.SA1130.severity = silent # IDE1006: Naming Styles - StyleCop handles these for us dotnet_diagnostic.IDE1006.severity = none +csharp_using_directive_placement = inside_namespace:warning +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = false:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion [*.sln] indent_style = tab diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml deleted file mode 100644 index dcec561..0000000 --- a/.github/workflows/dotnet-core.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: .NET Core - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 5.0.x - - name: Install dependencies - run: dotnet restore - - name: Build - run: dotnet build --configuration Release --no-restore - - name: Test - run: dotnet test --no-restore --verbosity normal - - name: Publish - uses: brandedoutcast/publish-nuget@v2.5.5 - with: - PROJECT_FILE_PATH: "OfxNet/OfxNet.csproj" - NUGET_KEY: ${{secrets.NUGET_APIKEY}} diff --git a/.github/workflows/ofxnet-pr.yml b/.github/workflows/ofxnet-pr.yml new file mode 100644 index 0000000..79176d1 --- /dev/null +++ b/.github/workflows/ofxnet-pr.yml @@ -0,0 +1,26 @@ +name: OFX.NET PR + +on: + pull_request: + branches: [ "main" ] + +env: + TreatWarningsAsErrors: true + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.x' + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --configuration Release --no-build diff --git a/BankingTools.sln b/BankingTools.sln index d84d1c5..33d48be 100644 --- a/BankingTools.sln +++ b/BankingTools.sln @@ -3,18 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.32014.148 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OfxNet", "OfxNet\OfxNet.csproj", "{FB4B0B05-D087-464A-B03C-7B512AE0735D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OfxNet", "src\OfxNet\OfxNet.csproj", "{FB4B0B05-D087-464A-B03C-7B512AE0735D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OfxNet.UnitTests", "OfxNet.UnitTests\OfxNet.UnitTests.csproj", "{4DBA4695-02D6-49C2-810A-DE8DFE7B70EB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OfxNet.UnitTests", "test\OfxNet.UnitTests\OfxNet.UnitTests.csproj", "{4DBA4695-02D6-49C2-810A-DE8DFE7B70EB}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OfxNet.IntegrationTests", "OfxNet.IntegrationTests\OfxNet.IntegrationTests.csproj", "{1A1F0950-9156-44A0-BFE8-4E5B001F9A84}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OfxNet.IntegrationTests", "test\OfxNet.IntegrationTests\OfxNet.IntegrationTests.csproj", "{1A1F0950-9156-44A0-BFE8-4E5B001F9A84}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9820D1F4-882C-4CF1-8E02-71939CE4B3A7}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - .github\workflows\dotnet-core.yml = .github\workflows\dotnet-core.yml + Directory.Build.props = Directory.Build.props .github\workflows\dotnet.yml = .github\workflows\dotnet.yml LICENSE = LICENSE + .github\workflows\ofxnet-pr.yml = .github\workflows\ofxnet-pr.yml README.md = README.md EndProjectSection EndProject diff --git a/Directory.Build.props b/Directory.Build.props index e25422c..f0b3690 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,18 @@ + net7.0 + enable + enable + 1.0.0.0 + 1.0.0.0 + 1.0.1.0 + latest + true true + true + x64 + en-GB + Jim Dale + true - + \ No newline at end of file diff --git a/OfxNet.IntegrationTests/OfxDocumentTests.cs b/OfxNet.IntegrationTests/OfxDocumentTests.cs deleted file mode 100644 index 51b08ef..0000000 --- a/OfxNet.IntegrationTests/OfxDocumentTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace OfxNet.IntegrationTests -{ - [TestClass] - public class OfxDocumentTests - { - public static IEnumerable SampleOfxFiles - { - get - { - yield return new object[] { "SampleBankStatement-1.ofx", 1, 3 }; - yield return new object[] { "SampleBankStatement-2.ofx", 1, 2 }; - yield return new object[] { "SampleCreditCardStatement.ofx", 1, 1 }; - yield return new object[] { "SampleMultiStatement.ofx", 2, 3 }; - yield return new object[] { "SampleSignOnResponse.ofx", 0, 0 }; - yield return new object[] { "Sample-itau.ofx", 1, 3 }; - yield return new object[] { "Sample-santander.ofx", 1, 3 }; - yield return new object[] { "Sample-Banco do Brasil.ofx", 1, 3 }; - } - } - - [TestInitialize] - public void Setup() - { - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - } - - [DataTestMethod] - [DynamicData(nameof(SampleOfxFiles), DynamicDataSourceType.Property)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Required for testing.")] - public void OfxDocumentLoad_Succeeds(string path, int statementCount, int txCount) - { - var actual = OfxDocument.Load(path); - Assert.IsNotNull(actual); - } - - [DataTestMethod] - [DynamicData(nameof(SampleOfxFiles), DynamicDataSourceType.Property)] - public void OfxDocumentLoad_GetStatements_ReturnsCorrectNumberOfStatementsAndTransactions(string path, int statementCount, int txCount) - { - var actual = OfxDocument.Load(path); - Assert.IsNotNull(actual); - - var allStatements = actual.GetStatements(); - Assert.AreEqual(statementCount, allStatements.Count()); - - var allTransactions = allStatements.SelectMany(s => s.TransactionList.Transactions); - Assert.AreEqual(txCount, allTransactions.Count()); - } - - [TestMethod] - public void CanParseItau() - { - var actual = OfxDocument.Load(@"Sample-itau.ofx") - .GetStatements(); - - var statement = actual.First(); - Assert.IsInstanceOfType(statement, typeof(OfxBankStatement)); - var bankStatement = statement as OfxBankStatement; - - Assert.AreEqual("9999 99999-9", bankStatement.Account.AccountNumber); - Assert.AreEqual("0341", bankStatement.Account.BankId); - - Assert.AreEqual(3, statement.TransactionList.Transactions.Count()); - CollectionAssert.AreEqual( - statement.TransactionList.Transactions.Select(x => x.Memo).ToArray(), - new string[] { "RSHOP", "REND PAGO APLIC AUT MAIS", "SISDEB" }); - } - - [TestMethod] - public void CanParseBancoDoBrasil() - { - var actual = OfxDocument.Load("Sample-Banco do Brasil.ofx") - .GetStatements(); - - var statement = actual.First(); - Assert.IsInstanceOfType(statement, typeof(OfxBankStatement)); - var bankStatement = statement as OfxBankStatement; - - Assert.AreEqual(bankStatement.Account.AccountNumber, "99999-9"); - Assert.AreEqual(bankStatement.Account.BranchId, "9999-9"); - Assert.AreEqual(bankStatement.Account.BankId, "1"); - - Assert.AreEqual(3, statement.TransactionList.Transactions.Count()); - - CollectionAssert.AreEqual( - statement.TransactionList.Transactions.Select(x => x.Memo).ToArray(), - new string[] { "Transferência Agendada", "Compra com Cartão", "Saque" }); - } - } -} diff --git a/OfxNet.IntegrationTests/OfxNet.IntegrationTests.csproj b/OfxNet.IntegrationTests/OfxNet.IntegrationTests.csproj deleted file mode 100644 index b43fe18..0000000 --- a/OfxNet.IntegrationTests/OfxNet.IntegrationTests.csproj +++ /dev/null @@ -1,49 +0,0 @@ - - - - net6.0 - false - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - - diff --git a/OfxNet.UnitTests/OfxNet.UnitTests.csproj b/OfxNet.UnitTests/OfxNet.UnitTests.csproj deleted file mode 100644 index ad30b82..0000000 --- a/OfxNet.UnitTests/OfxNet.UnitTests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net6.0 - false - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/OfxNet.UnitTests/OfxParserTests.cs b/OfxNet.UnitTests/OfxParserTests.cs deleted file mode 100644 index ee20217..0000000 --- a/OfxNet.UnitTests/OfxParserTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace OfxNet.UnitTests -{ - [TestClass] - public class OfxParserTests - { - [TestMethod] - public void ParseInteger_WithNull_ReturnsExpectedValue() - { - var actual = OfxParser.ParseInteger(null); - - Assert.AreEqual((true, false, default(int)), actual); - } - - [DataTestMethod] - [DynamicData(nameof(IntegerParserTestData), DynamicDataSourceType.Property)] - public void ParseInteger_WithString_ReturnsExpectedValue(string str, (bool NullOrEmpty, bool NotInteger, int Value) expected) - { - var actual = OfxParser.ParseInteger(str); - - Assert.AreEqual(expected, actual); - } - - [TestMethod] - public void ParseDecimal_WithNull_ReturnsExpectedValue() - { - var actual = OfxParser.ParseDecimal(null); - - Assert.AreEqual((true, false, default(decimal)), actual); - } - - [DataTestMethod] - [DynamicData(nameof(DecimalParserTestData), DynamicDataSourceType.Property)] - public void ParseDecimal_WithString_ReturnsExpectedValue(string str, (bool NullOrEmpty, bool NotDecimal, decimal Value) expected) - { - var actual = OfxParser.ParseDecimal(str); - - Assert.AreEqual(expected, actual); - } - - [DataTestMethod] - [DynamicData(nameof(OfxDateTimeTestData), DynamicDataSourceType.Property)] - public void ParseDateTime_WithValidString_ReturnsCorrectDateTime(string str, DateTimeOffset expected) - { - var actual = OfxParser.ParseDateTime(str); - - Assert.AreEqual(expected, actual); - } - - [DataTestMethod] - [DynamicData(nameof(OfxAccountTypeTestData), DynamicDataSourceType.Property)] - public void ParseOfxAccountType_WithValidString_ReturnsExpectedValue(string str, OfxAccountType expected) - { - var actual = OfxParser.ParseAccountType(str); - - Assert.AreEqual(expected, actual); - } - - private static IEnumerable IntegerParserTestData - { - get - { - yield return new object[] { string.Empty, (true, false, default(int)) }; - yield return new object[] { " ", (true, false, default(int)) }; - yield return new object[] { "Forty One", (false, true, default(int)) }; - yield return new object[] { "41.98", (false, true, default(int)) }; - yield return new object[] { "48", (false, false, 48) }; - } - } - - private static IEnumerable DecimalParserTestData - { - get - { - yield return new object[] { string.Empty, (true, false, default(decimal)) }; - yield return new object[] { " ", (true, false, default(decimal)) }; - yield return new object[] { "Forty One", (false, true, default(decimal)) }; - yield return new object[] { "41.98", (false, false, 41.98M) }; - } - } - - private static IEnumerable OfxAccountTypeTestData - { - get - { - yield return new object[] { "MONEYMRKT", OfxAccountType.MONEYMRKT }; - yield return new object[] { null, OfxAccountType.NotSet }; - yield return new object[] { "NotValid", OfxAccountType.NotSet }; - } - } - - private static IEnumerable OfxDateTimeTestData - { - get - { - yield return new object[] { "20150602100000.000[-4:EDT]", new DateTimeOffset(2015, 6, 2, 10, 0, 0, new TimeSpan(-4, 0, 0)) }; - yield return new object[] { "199610291120", new DateTimeOffset(1996, 10, 29, 11, 20, 0, new TimeSpan(0, 0, 0)) }; - yield return new object[] { "19961005132200.124[-5:EST]", new DateTimeOffset(1996, 10, 5, 13, 22, 0, 124, new TimeSpan(-5, 0, 0)) }; - yield return new object[] { "20131205100000[-03:EST]", new DateTimeOffset(2013, 12, 5, 10, 0, 0, new TimeSpan(-3, 0, 0)) }; - } - } - } -} diff --git a/OfxNet.UnitTests/SgmlOfxElementTests.cs b/OfxNet.UnitTests/SgmlOfxElementTests.cs deleted file mode 100644 index 882af07..0000000 --- a/OfxNet.UnitTests/SgmlOfxElementTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace OfxNet.UnitTests -{ - [TestClass] - public class SgmlOfxElementTests - { - [TestMethod] - public void GetRequiredChildElement_AndChildExists_Succeeds() - { - var sut = new SgmlElement("OFX", ""); - var expected = sut.AddChild(new SgmlElement("Exists", string.Empty, sut)); - - var actual = sut.Element("Exists", StringComparer.OrdinalIgnoreCase); - - Assert.AreEqual(expected, actual); - } - - [TestMethod] - public void GetRequiredChildElement_AndChildDoesNotExist_ReturnsNull() - { - var sut = new SgmlElement("OFX", ""); - _ = sut.AddChild(new SgmlElement("Exists", string.Empty, sut)); - - var actual = sut.Element("NotExists", StringComparer.OrdinalIgnoreCase); - - Assert.IsNull(actual); - } - } -} diff --git a/OfxNet/IOfxElement.cs b/OfxNet/IOfxElement.cs deleted file mode 100644 index 8641aac..0000000 --- a/OfxNet/IOfxElement.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace OfxNet -{ - public interface IOfxElement - { - public string? Value { get; } - public IOfxElement? Element(string name, StringComparer comparer); - public IEnumerable Elements(string name, StringComparer comparer); - } -} diff --git a/OfxNet/Models/OfxAccount.cs b/OfxNet/Models/OfxAccount.cs deleted file mode 100644 index a655cb0..0000000 --- a/OfxNet/Models/OfxAccount.cs +++ /dev/null @@ -1,9 +0,0 @@ - -namespace OfxNet -{ - public class OfxAccount - { - public string? AccountNumber { get; set; } - public string? Checksum { get; set; } - } -} diff --git a/OfxNet/Models/OfxAccountBalance.cs b/OfxNet/Models/OfxAccountBalance.cs deleted file mode 100644 index 3a5225b..0000000 --- a/OfxNet/Models/OfxAccountBalance.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace OfxNet -{ - public class OfxAccountBalance - { - public decimal Balance { get; set; } - public DateTimeOffset DateAsOf { get; set; } - } -} diff --git a/OfxNet/Models/OfxAccountType.cs b/OfxNet/Models/OfxAccountType.cs deleted file mode 100644 index 49f3858..0000000 --- a/OfxNet/Models/OfxAccountType.cs +++ /dev/null @@ -1,20 +0,0 @@ - -using System.ComponentModel; - -namespace OfxNet -{ - public enum OfxAccountType - { - NotSet, - [Description("Checking")] - CHECKING, - [Description("Savings")] - SAVINGS, - [Description("Money Market")] - MONEYMRKT, - [Description("Line of credit")] - CREDITLINE, - [Description("Certificate of Deposit")] - CD - } -} diff --git a/OfxNet/Models/OfxBankAccount.cs b/OfxNet/Models/OfxBankAccount.cs deleted file mode 100644 index c6c181c..0000000 --- a/OfxNet/Models/OfxBankAccount.cs +++ /dev/null @@ -1,10 +0,0 @@ - -namespace OfxNet -{ - public class OfxBankAccount : OfxAccount - { - public string? BankId { get; set; } - public string? BranchId { get; set; } - public OfxAccountType AccountType { get; set; } - } -} diff --git a/OfxNet/Models/OfxBankStatement.cs b/OfxNet/Models/OfxBankStatement.cs deleted file mode 100644 index eb587ef..0000000 --- a/OfxNet/Models/OfxBankStatement.cs +++ /dev/null @@ -1,7 +0,0 @@ - -namespace OfxNet -{ - public class OfxBankStatement : OfxStatement - { - } -} diff --git a/OfxNet/Models/OfxCorrectiveAction.cs b/OfxNet/Models/OfxCorrectiveAction.cs deleted file mode 100644 index 763f64a..0000000 --- a/OfxNet/Models/OfxCorrectiveAction.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel; - -namespace OfxNet -{ - public enum OfxCorrectiveAction - { - NotSet, - [Description("Replace this transaction with one referenced by CORRECTFITID")] - REPLACE, - [Description("Delete transaction")] - DELETE, - } -} diff --git a/OfxNet/Models/OfxCreditCardAccount.cs b/OfxNet/Models/OfxCreditCardAccount.cs deleted file mode 100644 index 81b5f2d..0000000 --- a/OfxNet/Models/OfxCreditCardAccount.cs +++ /dev/null @@ -1,7 +0,0 @@ - -namespace OfxNet -{ - public class OfxCreditCardAccount : OfxAccount - { - } -} diff --git a/OfxNet/Models/OfxCreditCardStatement.cs b/OfxNet/Models/OfxCreditCardStatement.cs deleted file mode 100644 index 426c302..0000000 --- a/OfxNet/Models/OfxCreditCardStatement.cs +++ /dev/null @@ -1,7 +0,0 @@ - -namespace OfxNet -{ - public class OfxCreditCardStatement : OfxStatement - { - } -} diff --git a/OfxNet/Models/OfxCurrency.cs b/OfxNet/Models/OfxCurrency.cs deleted file mode 100644 index 0415e92..0000000 --- a/OfxNet/Models/OfxCurrency.cs +++ /dev/null @@ -1,15 +0,0 @@ - -namespace OfxNet -{ - public class OfxCurrency - { - public decimal Rate { get; init; } - public string Symbol { get; init; } - - public OfxCurrency(decimal rate, string symbol) - { - Rate = rate; - Symbol = symbol; - } - } -} diff --git a/OfxNet/Models/OfxPayee.cs b/OfxNet/Models/OfxPayee.cs deleted file mode 100644 index 41cc261..0000000 --- a/OfxNet/Models/OfxPayee.cs +++ /dev/null @@ -1,16 +0,0 @@ - -namespace OfxNet -{ - public class OfxPayee - { - public string? Name { get; set; } - public string? AddressLine1 { get; set; } - public string? AddressLine2 { get; set; } - public string? AddressLine3 { get; set; } - public string? City { get; set; } - public string? State { get; set; } - public string? PostalCode { get; set; } - public string? Country { get; set; } - public string? PhoneNumber { get; set; } - } -} diff --git a/OfxNet/Models/OfxSeverity.cs b/OfxNet/Models/OfxSeverity.cs deleted file mode 100644 index 6b2f169..0000000 --- a/OfxNet/Models/OfxSeverity.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel; - -namespace OfxNet -{ - public enum OfxSeverity - { - NotSet, - [Description("Informational only")] - INFO, - [Description("Some problem with the request occurred but a valid response still present")] - WARN, - [Description("A problem severe enough that response could not be made")] - ERROR - } -} diff --git a/OfxNet/Models/OfxSignOn.cs b/OfxNet/Models/OfxSignOn.cs deleted file mode 100644 index 4bc9746..0000000 --- a/OfxNet/Models/OfxSignOn.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace OfxNet -{ - public class OfxSignOn - { - public OfxStatus? Status { get; set; } - public DateTimeOffset ServerDate { get; set; } - public string? Language { get; set; } - public string? IntuBid { get; set; } - } -} diff --git a/OfxNet/Models/OfxStatement.cs b/OfxNet/Models/OfxStatement.cs deleted file mode 100644 index f0f8384..0000000 --- a/OfxNet/Models/OfxStatement.cs +++ /dev/null @@ -1,16 +0,0 @@ - -namespace OfxNet -{ - public class OfxStatement - { - public string? DefaultCurrency { get; set; } - public OfxAccountBalance? LedgerBalance { get; set; } - public OfxAccountBalance? AvailableBalance { get; set; } - public OfxTransactionList? TransactionList { get; set; } - } - - public class OfxStatement : OfxStatement - { - public TAccount? Account { get; set; } - } -} diff --git a/OfxNet/Models/OfxStatementTransaction.cs b/OfxNet/Models/OfxStatementTransaction.cs deleted file mode 100644 index 74e434a..0000000 --- a/OfxNet/Models/OfxStatementTransaction.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace OfxNet -{ - public class OfxStatementTransaction - { - public OfxTransactionType TxType { get; set; } - public DateTimeOffset DatePosted { get; set; } - public DateTimeOffset? DateUser { get; set; } - public DateTimeOffset? DateAvailable { get; set; } - public decimal Amount { get; set; } - public string? FitId { get; set; } - public string? Name { get; set; } - public string? Memo { get; set; } - public string? Memo2 { get; set; } - public string? ChequeNumber { get; set; } - public string? ReferenceNumber { get; set; } - public string? CorrectFitId { get; set; } - public OfxCorrectiveAction CorrectAction { get; set; } - public string? ServiceProviderName { get; set; } - public string? ServerTxId { get; set; } - public int? StandardIndustrialCode { get; set; } - public string? PayeeId { get; set; } - public OfxPayee? Payee { get; set; } - public OfxCurrency? Currency { get; set; } - public OfxCurrency? OriginalCurrency { get; set; } - public OfxBankAccount? BankAccountTo { get; set; } - public OfxCreditCardAccount? CreditCardAccountTo { get; set; } - } -} diff --git a/OfxNet/Models/OfxStatus.cs b/OfxNet/Models/OfxStatus.cs deleted file mode 100644 index 5f980f7..0000000 --- a/OfxNet/Models/OfxStatus.cs +++ /dev/null @@ -1,10 +0,0 @@ - -namespace OfxNet -{ - public class OfxStatus - { - public int Code { get; set; } - public OfxSeverity Severity { get; set; } - public string? Message { get; set; } - } -} diff --git a/OfxNet/Models/OfxTransactionList.cs b/OfxNet/Models/OfxTransactionList.cs deleted file mode 100644 index 3bf7aae..0000000 --- a/OfxNet/Models/OfxTransactionList.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace OfxNet -{ - public class OfxTransactionList - { - public DateTimeOffset StartDate { get; set; } - public DateTimeOffset EndDate { get; set; } - public List Transactions { get; set; } = new(); - } -} diff --git a/OfxNet/Models/OfxTransactionType.cs b/OfxNet/Models/OfxTransactionType.cs deleted file mode 100644 index 95438fe..0000000 --- a/OfxNet/Models/OfxTransactionType.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.ComponentModel; - -namespace OfxNet -{ - public enum OfxTransactionType - { - NotSet, - [Description("Generic credit")] - CREDIT, - [Description("Generic debit")] - DEBIT, - [Description("Interest earned or paid. Note: Depends on signage of amount")] - INT, - [Description("Dividend")] - DIV, - [Description("FI fee")] - FEE, - [Description("Service charge")] - SRVCHG, - [Description("Deposit")] - DEP, - [Description("ATM debit or credit. Note: Depends on signage of amount")] - ATM, - [Description("Point of sale debit or credit. Note: Depends on signage of amount")] - POS, - [Description("Transfer")] - XFER, - [Description("Check")] - CHECK, - [Description("Electronic payment")] - PAYMENT, - [Description("Cash withdrawal")] - CASH, - [Description("Direct Deposit")] - DIRECTDEP, - [Description("Merchant Initiated Debit")] - DIRECTDEBIT, - [Description("Repeating payment/standing order")] - REPEATPMT, - [Description("Only valid in ; indicates the amount is under a hold. Note: Depends on signage of amount and account type")] - HOLD, - [Description("Other")] - OTHER, - } -} diff --git a/OfxNet/Models/OfxVersion.cs b/OfxNet/Models/OfxVersion.cs deleted file mode 100644 index 4fe9dc3..0000000 --- a/OfxNet/Models/OfxVersion.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; - -namespace OfxNet -{ - public struct OfxVersion : IEquatable - { - public static readonly OfxVersion InvalidHeader = new OfxVersion(-1, -1, -1); - public static readonly OfxVersion HeaderV1 = new OfxVersion(1, 0, 0); - - public int Major { get; } - public int Minor { get; } - public int Revision { get; } - - public OfxVersion(int major, int minor, int revision) - { - Major = major; - Minor = minor; - Revision = revision; - } - public override bool Equals(object? obj) => (obj is OfxVersion other) && Equals(other); - - public bool Equals(OfxVersion other) - { - return (Major == other.Major) - && (Minor == other.Minor) - && (Revision == other.Revision); - } - - public static bool operator ==(OfxVersion left, OfxVersion right) - { - return left.Equals(right); - } - - public static bool operator !=(OfxVersion left, OfxVersion right) - { - return !(left == right); - } - - public override int GetHashCode() - { - return HashCode.Combine(Major, Minor, Revision); - } - } -} diff --git a/OfxNet/OfxConstants.cs b/OfxNet/OfxConstants.cs deleted file mode 100644 index ef59f65..0000000 --- a/OfxNet/OfxConstants.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Globalization; - -namespace OfxNet -{ - public static class OfxConstants - { - #region OFX Data string constants - public const string OfxTag = "OFX"; - public const string SignonMessageSetResponseV1 = "SIGNONMSGSRSV1"; - public const string SignonResponse = "SONRS"; - - public const string BankMessageSetResponseV1 = "BANKMSGSRSV1"; - public const string StatementTxResponse = "STMTTRNRS"; - public const string StatementResponse = "STMTRS"; - public const string BankAccountFrom = "BANKACCTFROM"; - public const string BankAccountTo = "BANKACCTTO"; - public const string BankTransactionList = "BANKTRANLIST"; - - public const string CreditCardMessageSetResponseV1 = "CREDITCARDMSGSRSV1"; - public const string CreditCardMessageSetResponseV2 = "CREDITCARDMSGSRSV2"; - public const string CreditCardStatementTxResponse = "CCSTMTTRNRS"; - public const string CreditCardStatementResponse = "CCSTMTRS"; - public const string CreditCardAccountFrom = "CCACCTFROM"; - public const string CreditCardAccountTo = "CCACCTTO"; - - public const string IntuBId = "INTU.BID"; - public const string Language = "LANGUAGE"; - public const string ServerDate = "DTSERVER"; - public const string TransactionUid = "TRNUID"; - public const string Status = "STATUS"; - public const string Code = "CODE"; - public const string Severity = "SEVERITY"; - public const string DefaultCurrency = "CURDEF"; - public const string Currency = "CURRENCY"; - public const string OriginalCurrency = "ORIGCURRENCY"; - public const string CurrencyRate = "CURRATE"; - public const string CurrencySymbol = "CURSYM"; - public const string StartDate = "DTSTART"; - public const string EndDate = "DTEND"; - public const string BankId = "BANKID"; - public const string BranchId = "BRANCHID"; - public const string AccountId = "ACCTID"; - public const string AccountType = "ACCTTYPE"; - public const string AccountType2 = "ACCTTYPE2"; - public const string AccountKey = "ACCTKEY"; - public const string StatementTransaction = "STMTTRN"; - public const string TransactionType = "TRNTYPE"; - public const string DatePosted = "DTPOSTED"; - public const string UserDate = "DTUSER"; - public const string DateAvailable = "DTAVAIL"; - public const string TransactionAmount = "TRNAMT"; - public const string FitId = "FITID"; - public const string Name = "NAME"; - public const string Memo = "MEMO"; - public const string Memo2 = "MEMO2"; - public const string ChequeNumber = "CHECKNUM"; - public const string ReferenceNumber = "REFNUM"; - public const string CorrectFitId = "CORRECTFITID"; - public const string CorrectAction = "CORRECTACTION"; - public const string ServiceProviderName = "SPNAME"; - public const string ServerTxId = "SRVRTID"; - public const string ServerTxId2 = "SRVRTID2"; - public const string StandardIndustrialCode = "SIC"; - public const string PayeeId = "PAYEEID"; - public const string PayeeId2 = "PAYEEID2"; - public const string Payee = "PAYEE"; - public const string Payee2 = "PAYEE2"; - public const string Address1 = "ADDR1"; - public const string Address2 = "ADDR2"; - public const string Address3 = "ADDR3"; - public const string City = "CITY"; - public const string State = "STATE"; - public const string PostalCode = "POSTALCODE"; - public const string Country = "COUNTRY"; - public const string Phone = "PHONE"; - public const string LedgerBalance = "LEDGERBAL"; - public const string AvailableBalance = "AVAILBAL"; - public const string BalanceAmount = "BALAMT"; - public const string DateAsOf = "DTASOF"; - #endregion - - #region Valid Date & Time formats - public static DateTimeStyles DefaultDateTimeStyles = DateTimeStyles.AssumeUniversal; - - public static readonly string[] DateTimeFormats = new string[] - { - "yyyyMMdd", - "yyyyMMddHHmm", - "yyyyMMddHHmmss", - "yyyyMMddHHmmss[z]", - "yyyyMMddHHmmss.fff", - "yyyyMMddHHmmss.fff[z]" - }; - #endregion - - #region Regular Expression Patterns to remove time zone name from OFX Date/time string - public const string TimeZoneRegexPattern = @":(\w+)\]\s*$"; - public const string TimeZoneReplacement = "]"; - #endregion - } -} diff --git a/OfxNet/OfxDocument.cs b/OfxNet/OfxDocument.cs deleted file mode 100644 index 41aefda..0000000 --- a/OfxNet/OfxDocument.cs +++ /dev/null @@ -1,438 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Xml.Linq; - -namespace OfxNet -{ - public class OfxDocument - { - private readonly object _document; - - public OfxDocumentSettings Settings { get; } - - public OfxDocument(object document) - : this(document, OfxDocumentSettings.Default) - { - } - - public OfxDocument(object document, OfxDocumentSettings settings) - { - _document = document; - Settings = settings; - } - - public static OfxDocument Load(string path) - { - return Load(path, OfxDocumentSettings.Default); - } - - public static OfxDocument Load(string path, OfxDocumentSettings settings) - { - OfxDocument result; - - if (SgmlDocument.TryLoad(path, out SgmlDocument? sgmlDocument)) - { - result = new OfxDocument(sgmlDocument, settings); - } - else - { - result = new OfxDocument(XDocument.Load(path), settings); - } - - return result; - } - - public IOfxElement? GetRoot() - { - IOfxElement? result = default; - if (_document is SgmlDocument sgmlDocument) - { - result = sgmlDocument.Root; - } - else if (_document is XDocument { Root: not null } xmlDocument) - { - result = new XElementAdapter(xmlDocument.Root); - } - - return result; - } - - public IEnumerable GetStatements() - { - var element = GetRoot(); - - return GetStatements(element); - } - - public IEnumerable GetStatements(IOfxElement? element) - { - IEnumerable result = (element is null) - ? Enumerable.Empty() - : GetBankStatements(element).Concat(GetCreditCardStatements(element)); - - return result; - } - - public IEnumerable GetBankStatements(IOfxElement element) - { - var set = GetElement(element, OfxConstants.BankMessageSetResponseV1); - var elements = GetElements(set, OfxConstants.StatementTxResponse); - var statements = GetBankStatements(elements); - - if (statements != null) - { - foreach (var statement in statements) - { - yield return statement; - } - } - } - - public IEnumerable GetCreditCardStatements(IOfxElement element) - { - var set = GetElement(element, OfxConstants.CreditCardMessageSetResponseV1, OfxConstants.CreditCardMessageSetResponseV2); - var elements = GetElements(set, OfxConstants.CreditCardStatementTxResponse); - var creditCardStatements = GetCreditCardStatements(elements); - - if (creditCardStatements != null) - { - foreach (var statement in creditCardStatements) - { - yield return statement; - } - } - } - - public IEnumerable GetBankStatements(IEnumerable elements) - { - foreach (var element in elements) - { - var response = GetElement(element, OfxConstants.StatementResponse); - if (response != null) - { - yield return GetBankStatement(response); - } - } - } - - public IEnumerable GetCreditCardStatements(IEnumerable elements) - { - foreach (var element in elements) - { - var response = GetElement(element, OfxConstants.CreditCardStatementResponse); - if (response != null) - { - yield return GetCreditCardStatement(response); - } - } - } - - [return: NotNullIfNotNull("element")] - public OfxSignOn? GetSignon(IOfxElement element) - { - return (element is null) - ? null - : new OfxSignOn - { - Status = GetStatus(GetElement(element, OfxConstants.Status)), - ServerDate = GetAsDateTimeOffset(element, OfxConstants.ServerDate), - Language = GetAsString(element, OfxConstants.Language), - IntuBid = GetAsString(element, OfxConstants.IntuBId) - }; - } - - [return: NotNullIfNotNull("element")] - public OfxBankStatement? GetBankStatement(IOfxElement? element) - { - return (element is null) - ? null - : new OfxBankStatement - { - DefaultCurrency = GetAsString(element, OfxConstants.DefaultCurrency), - Account = GetBankAccount(GetElement(element, OfxConstants.BankAccountFrom)), - TransactionList = GetStatementTransactionList(GetElement(element, OfxConstants.BankTransactionList)), - LedgerBalance = GetAccountBalance(GetElement(element, OfxConstants.LedgerBalance)), - AvailableBalance = GetAccountBalance(GetElement(element, OfxConstants.AvailableBalance)) - }; - } - - public OfxCreditCardStatement GetCreditCardStatement(IOfxElement element) - { - return new OfxCreditCardStatement - { - DefaultCurrency = GetAsString(element, OfxConstants.DefaultCurrency), - Account = GetCreditCardAccount(GetElement(element, OfxConstants.CreditCardAccountFrom)), - TransactionList = GetStatementTransactionList(GetElement(element, OfxConstants.BankTransactionList)), - LedgerBalance = GetAccountBalance(GetElement(element, OfxConstants.LedgerBalance)), - AvailableBalance = GetAccountBalance(GetElement(element, OfxConstants.AvailableBalance)) - }; - } - - public OfxTransactionList? GetStatementTransactionList(IOfxElement? element) - { - OfxTransactionList? result = null; - - if (element != null) - { - var query = from t in GetElements(element, OfxConstants.StatementTransaction) - select GetStatementTransaction(t); - - result = new OfxTransactionList - { - StartDate = GetAsDateTimeOffset(element, OfxConstants.StartDate), - EndDate = GetAsDateTimeOffset(element, OfxConstants.EndDate), - Transactions = query.ToList() - }; - } - - return result; - } - - [return: NotNullIfNotNull("element")] - public OfxStatementTransaction? GetStatementTransaction(IOfxElement? element) - { - return (element is null) - ? null - : new OfxStatementTransaction - { - TxType = GetTransactionType(element), - DatePosted = GetAsDateTimeOffset(element, OfxConstants.DatePosted), - DateUser = GetAsNullableDateTimeOffset(element, OfxConstants.UserDate), - DateAvailable = GetAsNullableDateTimeOffset(element, OfxConstants.DateAvailable), - Amount = GetAsRequiredDecimal(element, OfxConstants.TransactionAmount, "Missing or invalid transaction amount from transaction element"), - FitId = GetAsString(element, OfxConstants.FitId), - Name = GetAsString(element, OfxConstants.Name), - Memo = GetAsString(element, OfxConstants.Memo), - Memo2 = GetAsString(element, OfxConstants.Memo2), - ChequeNumber = GetAsString(element, OfxConstants.ChequeNumber), - ReferenceNumber = GetAsString(element, OfxConstants.ReferenceNumber), - CorrectFitId = GetAsString(element, OfxConstants.CorrectFitId), - CorrectAction = GetCorrectiveAction(element), - ServiceProviderName = GetAsString(element, OfxConstants.ServiceProviderName), - ServerTxId = GetAsString(element, OfxConstants.ServerTxId2, OfxConstants.ServerTxId), - StandardIndustrialCode = GetAsNullableInt(element, OfxConstants.StandardIndustrialCode), - PayeeId = GetAsString(element, OfxConstants.PayeeId2, OfxConstants.PayeeId), - Payee = GetPayee(GetElement(element, OfxConstants.Payee2, OfxConstants.Payee)), - Currency = GetCurrency(GetElement(element, OfxConstants.Currency)), - OriginalCurrency = GetCurrency(GetElement(element, OfxConstants.OriginalCurrency)), - BankAccountTo = GetBankAccount(GetElement(element, OfxConstants.BankAccountTo)), - CreditCardAccountTo = GetCreditCardAccount(GetElement(element, OfxConstants.CreditCardAccountTo)) - }; - } - - [return: NotNullIfNotNull("element")] - public OfxCurrency? GetCurrency(IOfxElement? element) - { - return (element is null) - ? null - : new OfxCurrency( - GetAsRequiredDecimal(element, OfxConstants.CurrencyRate, "Missing required currency rate in currency element."), - GetAsRequiredString(element, OfxConstants.CurrencySymbol, "Missing required currency symbol in currency element.") - ); - } - - [return: NotNullIfNotNull("element")] - public OfxPayee? GetPayee(IOfxElement? element) - { - return (element is null) - ? null - : new OfxPayee - { - Name = GetAsString(element, OfxConstants.Name), - AddressLine1 = GetAsString(element, OfxConstants.Address1), - AddressLine2 = GetAsString(element, OfxConstants.Address2), - AddressLine3 = GetAsString(element, OfxConstants.Address3), - City = GetAsString(element, OfxConstants.City), - State = GetAsString(element, OfxConstants.State), - PostalCode = GetAsString(element, OfxConstants.PostalCode), - Country = GetAsString(element, OfxConstants.Country), - PhoneNumber = GetAsString(element, OfxConstants.Phone), - }; - } - - [return: NotNullIfNotNull("element")] - public OfxAccountBalance? GetAccountBalance(IOfxElement? element) - { - return (element is null) - ? null - : new OfxAccountBalance - { - Balance = GetAsRequiredDecimal(element, OfxConstants.BalanceAmount, "Missing or invalid balance from balance element."), - DateAsOf = GetAsDateTimeOffset(element, OfxConstants.DateAsOf) - }; - } - - public OfxStatus? GetStatus(IOfxElement? element) - { - return (element is null) - ? null - : new OfxStatus - { - Code = GetAsRequiredInteger(element, OfxConstants.Code, "Missing required Code from status element."), - Severity = GetSeverity(element) - }; - } - - [return: NotNullIfNotNull("element")] - public OfxBankAccount? GetBankAccount(IOfxElement? element) - { - return (element is null) - ? null - : new OfxBankAccount - { - BankId = GetAsString(element, OfxConstants.BankId), - BranchId = GetAsString(element, OfxConstants.BranchId), - AccountNumber = GetAsString(element, OfxConstants.AccountId), - AccountType = GetAccountType(element), - Checksum = GetAsString(element, OfxConstants.AccountKey) - }; - } - - [return: NotNullIfNotNull("element")] - public OfxCreditCardAccount? GetCreditCardAccount(IOfxElement? element) - { - return (element is null) - ? null - : new OfxCreditCardAccount - { - AccountNumber = GetAsString(element, OfxConstants.AccountId), - Checksum = GetAsString(element, OfxConstants.AccountKey) - }; - } - - private int GetAsRequiredInteger(IOfxElement parent, string name, string errorString) - { - string? value = GetAsString(parent, name); - - (bool NullOrWhiteSpace, bool NotInteger, int Value) = OfxParser.ParseInteger(value); - if (NullOrWhiteSpace || NotInteger) - { - throw new OfxException(errorString); - } - - return Value; - } - - private int? GetAsNullableInt(IOfxElement parent, string name) - { - string? value = GetAsString(parent, name); - return int.TryParse(value, out var result) ? result : default(int?); - } - - private decimal GetAsRequiredDecimal(IOfxElement parent, string name, string errorString) - { - string? value = GetAsString(parent, name); - - (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) = OfxParser.ParseDecimal(value); - if (NullOrWhiteSpace || NotDecimal) - { - throw new OfxException(errorString); - } - - return Value; - } - - private decimal? GetAsNullableDecimal(IOfxElement parent, string name) - { - string? value = GetAsString(parent, name); - (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) = OfxParser.ParseDecimal(value); - - return (NullOrWhiteSpace || NotDecimal) ? default(decimal?) : Value; - } - - private DateTimeOffset GetAsDateTimeOffset(IOfxElement parent, string name) - { - string? value = GetAsString(parent, name); - return OfxParser.ParseDateTime(value); - } - - private DateTimeOffset? GetAsNullableDateTimeOffset(IOfxElement parent, string name) - { - string? value = GetAsString(parent, name); - return OfxParser.ParseNullableDateTime(value); - } - - private OfxAccountType GetAccountType(IOfxElement parent) - { - return OfxParser.ParseAccountType( - GetAsString(parent, OfxConstants.AccountType2, OfxConstants.AccountType)); - } - - private OfxSeverity GetSeverity(IOfxElement parent) - { - return OfxParser.ParseSeverity( - GetAsString(parent, OfxConstants.Severity)); - } - - private OfxTransactionType GetTransactionType(IOfxElement parent) - { - return OfxParser.ParseTransactionType( - GetAsString(parent, OfxConstants.TransactionType)); - } - - private OfxCorrectiveAction GetCorrectiveAction(IOfxElement parent) - { - return OfxParser.ParseCorrectiveAction( - GetAsString(parent, OfxConstants.CorrectAction)); - } - - public string? GetAsString(IOfxElement element, string first, string second) - { - var result = GetAsString(element, first); - if (string.IsNullOrWhiteSpace(result)) - { - result = GetAsString(element, second); - } - - return result; - } - - private string? GetAsString(IOfxElement parent, string name) - { - var result = GetElement(parent, name)?.Value; - if (Settings.TrimValues && string.IsNullOrEmpty(result) == false) - { - result = result.Trim(); - } - return result; - } - - private string GetAsRequiredString(IOfxElement parent, string name, string errorString) - { - var result = GetElement(parent, name)?.Value; - if (string.IsNullOrWhiteSpace(result)) - { - throw new OfxException(errorString); - } - if (Settings.TrimValues && string.IsNullOrEmpty(result) == false) - { - result = result.Trim(); - } - return result; - } - - private IEnumerable GetElements(IOfxElement? parent, string name) - { - if (parent != null) - { - var items = parent.Elements(name, Settings.TagComparer); - foreach (var item in items) - { - yield return item; - } - } - } - - private IOfxElement? GetElement(IOfxElement parent, string name) - { - return parent.Element(name, Settings.TagComparer); - } - - public IOfxElement? GetElement(IOfxElement parent, string first, string second) - { - return GetElement(parent, first) ?? GetElement(parent, second); - } - } -} diff --git a/OfxNet/OfxDocumentSettings.cs b/OfxNet/OfxDocumentSettings.cs deleted file mode 100644 index 8ff0fe5..0000000 --- a/OfxNet/OfxDocumentSettings.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; - -namespace OfxNet -{ - [SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Not currently required.")] - public struct OfxDocumentSettings - { - public readonly static OfxDocumentSettings Default = new OfxDocumentSettings - { - TrimValues = true, - TagComparer = StringComparer.CurrentCultureIgnoreCase - }; - - public bool TrimValues { get; set; } - public StringComparer TagComparer { get; set; } - } -} diff --git a/OfxNet/OfxException.cs b/OfxNet/OfxException.cs deleted file mode 100644 index 3aea1fc..0000000 --- a/OfxNet/OfxException.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Runtime.Serialization; - -namespace OfxNet -{ - [Serializable] - internal class OfxException : Exception - { - public OfxException() - { - } - - public OfxException(string? message) : base(message) - { - } - - public OfxException(string? message, Exception? innerException) : base(message, innerException) - { - } - - protected OfxException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } - } -} \ No newline at end of file diff --git a/OfxNet/OfxParser.cs b/OfxNet/OfxParser.cs deleted file mode 100644 index 0069ac2..0000000 --- a/OfxNet/OfxParser.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.Globalization; -using System.Text.RegularExpressions; - -namespace OfxNet -{ - public static class OfxParser - { - /// - /// Parses an OFX version string. The expected format is 3 digits - [Major][Minor][Revision] e.g. 100. - /// - /// A string containing the version number to convert. - /// - public static OfxVersion ParseVersion(string str) - { - var result = TryParseVersion(str); - if (result == OfxVersion.InvalidHeader) - { - throw new SgmlParseException("Invalid OFX version number."); - } - - return result; - } - - /// - /// Try to parse an OFX version string. The expected format is 3 digits - [Major][Minor][Revision] e.g. 100. - /// - /// A string containing the version number to convert. - /// - public static OfxVersion TryParseVersion(string str) - { - var result = OfxVersion.InvalidHeader; - - if (string.IsNullOrWhiteSpace(str) == false && str.Length == 3) - { - if (TryGetDigitValue(str[0], out int major) - && TryGetDigitValue(str[1], out int minor) - && TryGetDigitValue(str[2], out int revision)) - { - result = new OfxVersion(major, minor, revision); - } - } - - return result; - } - - public static (bool NullOrWhiteSpace, bool NotInteger, int Value) ParseInteger(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return (true, false, default); - } - if (int.TryParse(value, out int temp)) - { - return (false, false, temp); - } - else - { - return (false, true, default); - } - } - - public static (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) ParseDecimal(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return (true, false, default); - } - if (decimal.TryParse(value, out decimal temp)) - { - return (false, false, temp); - } - else - { - return (false, true, default); - } - } - - public static DateTimeOffset? ParseNullableDateTime(string? value) - { - DateTimeOffset? result = default; - - if (string.IsNullOrWhiteSpace(value) == false) - { - result = ParseDateTime(value); - } - - return result; - } - - public static DateTimeOffset ParseDateTime(string? value) - { - DateTimeOffset result = default; - - if (string.IsNullOrWhiteSpace(value) == false) - { - if (TryParseDateTimeOffset(value, OfxConstants.DefaultDateTimeStyles, out result) == false) - { - throw new FormatException("String was not recognized as a valid DateTimeOffset."); - } - } - - return result; - } - - public static OfxAccountType ParseAccountType(string? value) - { - return ParseEnumString(value); - } - - public static OfxSeverity ParseSeverity(string? value) - { - return ParseEnumString(value); - } - - public static OfxTransactionType ParseTransactionType(string? value) - { - return ParseEnumString(value); - } - - public static OfxCorrectiveAction ParseCorrectiveAction(string? value) - { - return ParseEnumString(value); - } - - #region Private methods - private static bool TryGetDigitValue(char ch, out int result) - { - var success = char.IsDigit(ch); - result = success ? (ch - '0') : default; - - return success; - } - - private static bool TryParseDateTimeOffset(string str, DateTimeStyles style, out DateTimeOffset result) - { - // Remove possible time zone name from string to be parsed - var noTimeZoneName = Regex.Replace(str, OfxConstants.TimeZoneRegexPattern, OfxConstants.TimeZoneReplacement); - - return DateTimeOffset.TryParseExact(noTimeZoneName, - OfxConstants.DateTimeFormats, - CultureInfo.InvariantCulture, - style, - out result); - } - - private static EnumType ParseEnumString(string? value) where EnumType : Enum - { - EnumType result = default!; - - if (string.IsNullOrWhiteSpace(value) == false - && Enum.IsDefined(typeof(EnumType), value)) - { - result = (EnumType)Enum.Parse(typeof(EnumType), value, true); - } - - return result; - } - #endregion - } -} diff --git a/OfxNet/Sgml/SgmlConstants.cs b/OfxNet/Sgml/SgmlConstants.cs deleted file mode 100644 index 9645dee..0000000 --- a/OfxNet/Sgml/SgmlConstants.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.RegularExpressions; - -namespace OfxNet -{ - public static class SgmlConstants - { - #region OFX SGML Header string constants - public const string Header = "OFXHEADER"; - public const string DataHeader = "DATA"; - public const string VersionHeader = "VERSION"; - public const string SecurityHeader = "SECURITY"; - public const string EncodingHeader = "ENCODING"; - public const string CharsetHeader = "CHARSET"; - public const string CompressionHeader = "COMPRESSION"; - public const string OldFileUIDHeader = "OLDFILEUID"; - public const string NewFileUIDHeader = "NEWFILEUID"; - #endregion - - #region Regular Expression Patterns - public const string HeaderRegexPrefix = "^"; - public const string HeaderRegexSeparator = @"\s*:\s*"; - public const string HeaderVersionRegexPattern = HeaderRegexPrefix + Header + HeaderRegexSeparator + @"(\d{3})" + @"\s*$"; - public const string HeaderRegexPattern = HeaderRegexPrefix + @"(\w+)" + HeaderRegexSeparator + @"(.+)" + @"$"; - - public const string OpeningTagRegexPattern = @"^\s*<([\w\.]+)>\s*$"; - public const string ClosingTagRegexPattern = @"^\s*\s*$"; - public const string ValueFullTagRegexPattern = @"^\s*<([\w\.]+)>(.+)\s*$"; - public const string ValuePartialTagRegexPattern = @"^\s*<([\w\.]+)>(.+)$"; - #endregion - - #region Regular Expression objects - public static readonly Regex HeaderVersionRegex = new Regex(HeaderVersionRegexPattern, RegexOptions.IgnoreCase); - public static readonly Regex HeaderRegex = new Regex(HeaderRegexPattern, RegexOptions.IgnoreCase); - public static readonly Regex OpeningTagRegex = new Regex(OpeningTagRegexPattern, RegexOptions.IgnoreCase); - public static readonly Regex ClosingTagRegex = new Regex(ClosingTagRegexPattern, RegexOptions.IgnoreCase); - public static readonly Regex ValueFullTagRegex = new Regex(ValueFullTagRegexPattern, RegexOptions.IgnoreCase); - public static readonly Regex ValuePartialTagRegex = new Regex(ValuePartialTagRegexPattern, RegexOptions.IgnoreCase); - #endregion - } -} diff --git a/OfxNet/Sgml/SgmlDocument.cs b/OfxNet/Sgml/SgmlDocument.cs deleted file mode 100644 index d808bf0..0000000 --- a/OfxNet/Sgml/SgmlDocument.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace OfxNet -{ - public class SgmlDocument - { - private SgmlDocument(SgmlHeader header, SgmlElement root) - { - Header = header; - Root = root; - } - - public SgmlHeader Header { get; set; } - public SgmlElement Root { get; set; } - - public static bool TryLoad(string path, [NotNullWhen(true)] out SgmlDocument? result) - { - result = null; - - var header = new SgmlHeaderParser().TryGetHeader(path); - if (header != default) - { - var encoding = header.GetEncoding(); - - var root = new SgmlParser().Parse(path, encoding); - if (root != SgmlElement.Empty) - { - result = new SgmlDocument(header, root); - } - } - - return (result != null); - } - } -} diff --git a/OfxNet/Sgml/SgmlElement.cs b/OfxNet/Sgml/SgmlElement.cs deleted file mode 100644 index fa06f28..0000000 --- a/OfxNet/Sgml/SgmlElement.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace OfxNet -{ - public class SgmlElement : IOfxElement - { - public static readonly SgmlElement Empty = new SgmlElement(string.Empty, string.Empty); - - public string Name { get; } - public string? Value { get; } - public string Text { get; } - public SgmlElement? Parent { get; } - public IList? Children { get; private set; } - - public SgmlElement(string name, string text) - : this(name, null, text, null) - { - } - - public SgmlElement(string name, string text, SgmlElement parent) - : this(name, null, text, parent) - { - } - - public SgmlElement(string name, string? value, string text, SgmlElement? parent) - { - Name = name; - Value = value; - Text = text; - Parent = parent; - } - - public SgmlElement AddChild(SgmlElement item) - { - if (Children is null) - { - Children = new List(); - } - Children.Add(item); - - return item; - } - - public IOfxElement? Element(string name, StringComparer comparer) - { - return Children?.SingleOrDefault(e => comparer.Equals(name, e.Name)); - } - - public IEnumerable Elements(string name, StringComparer comparer) - { - if (Children != null) - { - foreach (var child in Children) - { - if (comparer.Equals(name, child.Name)) - { - yield return child; - } - } - } - } - } -} diff --git a/OfxNet/Sgml/SgmlHeader.cs b/OfxNet/Sgml/SgmlHeader.cs deleted file mode 100644 index c766508..0000000 --- a/OfxNet/Sgml/SgmlHeader.cs +++ /dev/null @@ -1,16 +0,0 @@ - -namespace OfxNet -{ - public class SgmlHeader - { - public OfxVersion HeaderVersion { get; set; } - public string? Data { get; set; } - public OfxVersion Version { get; set; } - public string? Security { get; set; } - public string? Encoding { get; set; } - public string? Charset { get; set; } - public string? Compression { get; set; } - public string? OldFileUid { get; set; } - public string? NewFileUid { get; set; } - } -} diff --git a/OfxNet/Sgml/SgmlHeaderExtensions.cs b/OfxNet/Sgml/SgmlHeaderExtensions.cs deleted file mode 100644 index fabdc4c..0000000 --- a/OfxNet/Sgml/SgmlHeaderExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Text; - -namespace OfxNet -{ - public static partial class SgmlHeaderExtensions - { - public static Encoding GetEncoding(this SgmlHeader item) - { - var result = Encoding.Default; - - if (string.Equals("USASCII", item.Encoding, StringComparison.OrdinalIgnoreCase)) - { - if (string.Equals("1252", item.Charset, StringComparison.OrdinalIgnoreCase)) - { - result = Encoding.GetEncoding(1252); - } - else if (string.Equals("ISO-8859-1", item.Charset, StringComparison.OrdinalIgnoreCase)) - { - result = Encoding.GetEncoding("iso-8859-1"); - } - else if (string.Equals("NONE", item.Charset, StringComparison.OrdinalIgnoreCase)) - { - result = Encoding.GetEncoding("us-ascii"); - } - else if (item.Charset is not null) - { - try - { - result = Encoding.GetEncoding(item.Charset); - } -#pragma warning disable CA1031 // Justification - this is the exact exception thrown - catch (ArgumentException) - { - } -#pragma warning restore CA1031 - } - } - else if (string.Equals("UTF-8", item.Encoding, StringComparison.OrdinalIgnoreCase)) - { - result = Encoding.UTF8; - } - - return result; - } - } -} diff --git a/OfxNet/Sgml/SgmlHeaderParser.cs b/OfxNet/Sgml/SgmlHeaderParser.cs deleted file mode 100644 index 17e3d4b..0000000 --- a/OfxNet/Sgml/SgmlHeaderParser.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.IO; -using System.Text; - -namespace OfxNet -{ - public class SgmlHeaderParser - { - private int _lineNumber; - - public SgmlHeader? TryGetHeader(string path) - { - using var stream = new StreamReader(path, Encoding.ASCII); - - var headerVersion = TryGetOfxHeaderVersion(stream); - - return (headerVersion == OfxVersion.HeaderV1) ? GetHeader(stream, headerVersion) : default; - } - - public int SkipToContent(TextReader reader) - { - SkipNoneContentLines(reader); - - ReadHeaders(reader, (line) => false); - - return _lineNumber; - } - - #region Private methods - private OfxVersion TryGetOfxHeaderVersion(TextReader reader) - { - var result = OfxVersion.InvalidHeader; - - SkipNoneContentLines(reader); - - ReadHeaders(reader, (line) => - { - // First header must be the OFX version header e.g. 'OFXHEADER:100' - var match = SgmlConstants.HeaderVersionRegex.Match(line); - if (match.Success) - { - result = OfxParser.TryParseVersion(match.Groups[1].Value); - } - - return true; - }); - - return result; - } - - private SgmlHeader GetHeader(TextReader reader, OfxVersion headerVersion) - { - var result = new SgmlHeader - { - HeaderVersion = headerVersion - }; - - ReadHeaders(reader, (line) => - { - var match = SgmlConstants.HeaderRegex.Match(line); - if (match.Success) - { - var name = match.Groups[1].Value.ToUpperInvariant(); - var value = match.Groups[2].Value.Trim(); - - _ = TrySetHeaderValue(result, name, value); - } - else - { - throw new SgmlParseException("Invalid format while parsing OFX headers, line number " + _lineNumber + "."); - } - - return false; - }); - - return result; - } - - private void SkipNoneContentLines(TextReader reader) - { - int nextChar; // When Peek returns -1 it is the end of the stream - while ((nextChar = reader.Peek()) != -1) - { - // OFX headers all start with a normal ASCII letter. Anything else - // stop processing. - if (char.IsWhiteSpace((char)nextChar) == false) - { - break; - } - - _ = reader.ReadLine(); - ++_lineNumber; - } - } - - private void ReadHeaders(TextReader reader, Func processLine) - { - int nextChar; // When Peek returns -1 it is the end of the stream - while ((nextChar = reader.Peek()) != -1) - { - // OFX headers all start with a normal ASCII letter. - // Anything else - stop processing. - if (char.IsLetter((char)nextChar) == false) - { - break; - } - - string? line = reader.ReadLine(); - if (line is null) - { - // EOF - break; - } - - ++_lineNumber; - - if (processLine.Invoke(line)) - { - break; - } - } - } - - private bool TrySetHeaderValue(SgmlHeader item, string name, string value) - { - bool result = true; - - switch (name) - { - case SgmlConstants.DataHeader: - item.Data = value; - break; - case SgmlConstants.VersionHeader: - item.Version = OfxParser.ParseVersion(value); - break; - case SgmlConstants.SecurityHeader: - item.Security = value; - break; - case SgmlConstants.EncodingHeader: - item.Encoding = value; - break; - case SgmlConstants.CharsetHeader: - item.Charset = value; - break; - case SgmlConstants.CompressionHeader: - item.Compression = value; - break; - case SgmlConstants.OldFileUIDHeader: - item.OldFileUid = value; - break; - case SgmlConstants.NewFileUIDHeader: - item.NewFileUid = value; - break; - default: - result = false; - break; - } - - return result; - } - #endregion - } -} diff --git a/OfxNet/Sgml/SgmlParseException.cs b/OfxNet/Sgml/SgmlParseException.cs deleted file mode 100644 index 8e31809..0000000 --- a/OfxNet/Sgml/SgmlParseException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Runtime.Serialization; - -namespace OfxNet -{ - [Serializable] - public class SgmlParseException : Exception - { - public SgmlParseException() - { - } - - public SgmlParseException(string message) : base(message) - { - } - - public SgmlParseException(string message, Exception inner) : base(message, inner) - { - } - - protected SgmlParseException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } - } -} diff --git a/OfxNet/Sgml/SgmlParseResult.cs b/OfxNet/Sgml/SgmlParseResult.cs deleted file mode 100644 index 916bc26..0000000 --- a/OfxNet/Sgml/SgmlParseResult.cs +++ /dev/null @@ -1,22 +0,0 @@ - -namespace OfxNet -{ - internal class SgmlParseResult - { - internal SgmlTagType TagType { get; set; } - internal string Tag { get; set; } - internal string? Value { get; set; } - - internal SgmlParseResult(SgmlTagType tagType, string tag) - : this(tagType, tag, null) - { - } - - internal SgmlParseResult(SgmlTagType tagType, string tag, string? value) - { - TagType = tagType; - Tag = tag; - Value = value; - } - } -} diff --git a/OfxNet/Sgml/SgmlParser.cs b/OfxNet/Sgml/SgmlParser.cs deleted file mode 100644 index a648922..0000000 --- a/OfxNet/Sgml/SgmlParser.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using System.IO; -using System.Net; -using System.Text; - -namespace OfxNet -{ - public class SgmlParser - { - private int _lineNumber; - private SgmlElement _root = SgmlElement.Empty; - private SgmlElement _currentNode = SgmlElement.Empty; - - public SgmlElement Parse(string path, Encoding encoding) - { - using var reader = new StreamReader(path, encoding); - - _lineNumber = new SgmlHeaderParser().SkipToContent(reader); - - while (!reader.EndOfStream) - { - var line = reader.ReadLine(); - ++_lineNumber; - - if (string.IsNullOrWhiteSpace(line) == false) - { - ProcessLine(line); - } - } - - return _root; - } - - #region Private methods - private void ProcessLine(string text) - { - var parseResult = TryParseLine(text); - if (parseResult == null) - { - throw new SgmlParseException("Invalid OFX SGML, line " + _lineNumber + "."); - } - else - { - switch (parseResult.TagType) - { - case SgmlTagType.OpeningTag: - ProcessOpeningTag(parseResult.Tag, text); - break; - case SgmlTagType.ValueTag: - ProcessValueTag(parseResult.Tag, parseResult.Value, text); - break; - case SgmlTagType.ClosingTag: - ProcessClosingTag(parseResult.Tag); - break; - default: - break; - } - } - } - - private void ProcessOpeningTag(string tag, string text) - { - if (_root == SgmlElement.Empty) - { - _root = new SgmlElement(tag, text); - _currentNode = _root; - } - else - { - _currentNode = _currentNode.AddChild(new SgmlElement(tag, text, _currentNode)); - } - } - - private void ProcessValueTag(string tag, string? value, string text) - { - value = GetValue(value); - - _currentNode.AddChild(new SgmlElement(tag, value, text, _currentNode)); - } - - private void ProcessClosingTag(string tag) - { - var expectedTag = _currentNode.Name; - if (string.Equals(expectedTag, tag, StringComparison.CurrentCultureIgnoreCase) == false) - { - throw new SgmlParseException($"Closing tag '{tag}' does not match opening tag '{expectedTag}', line {_lineNumber}."); - } - - _currentNode = _currentNode.Parent ?? _root; - } - - private SgmlParseResult? TryParseLine(string line) - { - var result = TryParseOpeningTag(line); - if (result == default) - { - result = TryParseClosingTag(line); - } - if (result == default) - { - result = TryParseValueFullTag(line); - } - if (result == default) - { - result = TryParseValuePartialTag(line); - } - return result; - } - - private SgmlParseResult? TryParseOpeningTag(string line) - { - SgmlParseResult? result = default; - - var match = SgmlConstants.OpeningTagRegex.Match(line); - if (match.Success && match.Groups.Count == 2) - { - result = new SgmlParseResult(SgmlTagType.OpeningTag, match.Groups[1].Value); - } - return result; - } - - private SgmlParseResult? TryParseClosingTag(string line) - { - SgmlParseResult? result = default; - - var match = SgmlConstants.ClosingTagRegex.Match(line); - if (match.Success && match.Groups.Count == 2) - { - result = new SgmlParseResult(SgmlTagType.ClosingTag, match.Groups[1].Value); - } - return result; - } - - private SgmlParseResult? TryParseValueFullTag(string line) - { - SgmlParseResult? result = default; - - var match = SgmlConstants.ValueFullTagRegex.Match(line); - if (match.Success && match.Groups.Count == 4) - { - result = new SgmlParseResult(SgmlTagType.ValueTag, match.Groups[1].Value, match.Groups[2].Value); - } - return result; - } - - private SgmlParseResult? TryParseValuePartialTag(string line) - { - SgmlParseResult? result = default; - - var match = SgmlConstants.ValuePartialTagRegex.Match(line); - if (match.Success && match.Groups.Count == 3) - { - result = new SgmlParseResult(SgmlTagType.ValueTag, match.Groups[1].Value, match.Groups[2].Value); - } - return result; - } - - private string? GetValue(string? value) - { - return WebUtility.HtmlDecode(value); - } - - #endregion - } -} diff --git a/OfxNet/Sgml/SgmlTagType.cs b/OfxNet/Sgml/SgmlTagType.cs deleted file mode 100644 index c32b5dd..0000000 --- a/OfxNet/Sgml/SgmlTagType.cs +++ /dev/null @@ -1,10 +0,0 @@ - -namespace OfxNet -{ - internal enum SgmlTagType - { - OpeningTag, - ValueTag, - ClosingTag - } -} diff --git a/OfxNet/Xml/XElementAdapter.cs b/OfxNet/Xml/XElementAdapter.cs deleted file mode 100644 index 4f0ccd7..0000000 --- a/OfxNet/Xml/XElementAdapter.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Xml.Linq; - -namespace OfxNet -{ - /// - /// The XElementAdapter provides a IOfxElement interface for built-in XElement objects. - /// - [SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Not currently required.")] - - public struct XElementAdapter : IOfxElement - { - private readonly XElement _element; - - public XElementAdapter(XElement element) - { - _element = element; - } - - public string Value => _element.Value; - - IOfxElement? IOfxElement.Element(string name, StringComparer comparer) - { - var element = (from e in _element.Elements() - where comparer.Equals(name, e.Name.LocalName) - select e) - .FirstOrDefault(); - - return (element is null) ? null : new XElementAdapter(element); - } - - public IEnumerable Elements(string name, StringComparer comparer) - { - return from element in _element.Elements() - where comparer.Equals(name, element.Name.LocalName) - select new XElementAdapter(element) as IOfxElement; - } - } -} diff --git a/README.md b/README.md index 0a7d3b3..5beaa07 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Banking tools v1.2.0 [![.NET](https://github.com/jim-dale/BankingTools/actions/workflows/dotnet.yml/badge.svg)](https://github.com/jim-dale/BankingTools/actions/workflows/dotnet.yml) [![Nuget](https://img.shields.io/nuget/v/OfxNet)](https://www.nuget.org/packages/OfxNet/) -This .NET 6 Library supports reading Open Financial Exchange (OFX) files that contain bank and credit card statements. +This .NET 8 Library supports reading Open Financial Exchange (OFX) files that contain bank and credit card statements. diff --git a/src/OfxNet/IOfxElement.cs b/src/OfxNet/IOfxElement.cs new file mode 100644 index 0000000..44999ef --- /dev/null +++ b/src/OfxNet/IOfxElement.cs @@ -0,0 +1,11 @@ +namespace OfxNet; + +using System; +using System.Collections.Generic; + +public interface IOfxElement +{ + public string? Value { get; } + public IOfxElement? Element(string name, StringComparer comparer); + public IEnumerable Elements(string name, StringComparer comparer); +} diff --git a/src/OfxNet/Models/OfxAccount.cs b/src/OfxNet/Models/OfxAccount.cs new file mode 100644 index 0000000..3931556 --- /dev/null +++ b/src/OfxNet/Models/OfxAccount.cs @@ -0,0 +1,7 @@ +namespace OfxNet; + +public class OfxAccount +{ + public string? AccountNumber { get; set; } + public string? Checksum { get; set; } +} diff --git a/src/OfxNet/Models/OfxAccountBalance.cs b/src/OfxNet/Models/OfxAccountBalance.cs new file mode 100644 index 0000000..fa2a68f --- /dev/null +++ b/src/OfxNet/Models/OfxAccountBalance.cs @@ -0,0 +1,9 @@ +namespace OfxNet; + +using System; + +public class OfxAccountBalance +{ + public decimal Balance { get; set; } + public DateTimeOffset DateAsOf { get; set; } +} diff --git a/src/OfxNet/Models/OfxAccountType.cs b/src/OfxNet/Models/OfxAccountType.cs new file mode 100644 index 0000000..764e993 --- /dev/null +++ b/src/OfxNet/Models/OfxAccountType.cs @@ -0,0 +1,18 @@ +namespace OfxNet; + +using System.ComponentModel; + +public enum OfxAccountType +{ + NotSet, + [Description("Checking")] + CHECKING, + [Description("Savings")] + SAVINGS, + [Description("Money Market")] + MONEYMRKT, + [Description("Line of credit")] + CREDITLINE, + [Description("Certificate of Deposit")] + CD +} diff --git a/src/OfxNet/Models/OfxBankAccount.cs b/src/OfxNet/Models/OfxBankAccount.cs new file mode 100644 index 0000000..7237c97 --- /dev/null +++ b/src/OfxNet/Models/OfxBankAccount.cs @@ -0,0 +1,8 @@ +namespace OfxNet; + +public class OfxBankAccount : OfxAccount +{ + public string? BankId { get; set; } + public string? BranchId { get; set; } + public OfxAccountType AccountType { get; set; } +} diff --git a/src/OfxNet/Models/OfxBankStatement.cs b/src/OfxNet/Models/OfxBankStatement.cs new file mode 100644 index 0000000..50740cd --- /dev/null +++ b/src/OfxNet/Models/OfxBankStatement.cs @@ -0,0 +1,5 @@ +namespace OfxNet; + +public class OfxBankStatement : OfxStatement +{ +} diff --git a/src/OfxNet/Models/OfxCorrectiveAction.cs b/src/OfxNet/Models/OfxCorrectiveAction.cs new file mode 100644 index 0000000..ccd9dc6 --- /dev/null +++ b/src/OfxNet/Models/OfxCorrectiveAction.cs @@ -0,0 +1,12 @@ +namespace OfxNet; + +using System.ComponentModel; + +public enum OfxCorrectiveAction +{ + NotSet, + [Description("Replace this transaction with one referenced by CORRECTFITID")] + REPLACE, + [Description("Delete transaction")] + DELETE, +} diff --git a/src/OfxNet/Models/OfxCreditCardAccount.cs b/src/OfxNet/Models/OfxCreditCardAccount.cs new file mode 100644 index 0000000..3a85d99 --- /dev/null +++ b/src/OfxNet/Models/OfxCreditCardAccount.cs @@ -0,0 +1,5 @@ +namespace OfxNet; + +public class OfxCreditCardAccount : OfxAccount +{ +} diff --git a/src/OfxNet/Models/OfxCreditCardStatement.cs b/src/OfxNet/Models/OfxCreditCardStatement.cs new file mode 100644 index 0000000..de16756 --- /dev/null +++ b/src/OfxNet/Models/OfxCreditCardStatement.cs @@ -0,0 +1,5 @@ +namespace OfxNet; + +public class OfxCreditCardStatement : OfxStatement +{ +} diff --git a/src/OfxNet/Models/OfxCurrency.cs b/src/OfxNet/Models/OfxCurrency.cs new file mode 100644 index 0000000..08e9306 --- /dev/null +++ b/src/OfxNet/Models/OfxCurrency.cs @@ -0,0 +1,7 @@ +namespace OfxNet; + +public class OfxCurrency(decimal rate, string symbol) +{ + public decimal Rate { get; init; } = rate; + public string Symbol { get; init; } = symbol; +} diff --git a/src/OfxNet/Models/OfxPayee.cs b/src/OfxNet/Models/OfxPayee.cs new file mode 100644 index 0000000..87ad3b0 --- /dev/null +++ b/src/OfxNet/Models/OfxPayee.cs @@ -0,0 +1,14 @@ +namespace OfxNet; + +public class OfxPayee +{ + public string? Name { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? AddressLine3 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? PostalCode { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } +} diff --git a/src/OfxNet/Models/OfxSeverity.cs b/src/OfxNet/Models/OfxSeverity.cs new file mode 100644 index 0000000..53ee39c --- /dev/null +++ b/src/OfxNet/Models/OfxSeverity.cs @@ -0,0 +1,14 @@ +namespace OfxNet; + +using System.ComponentModel; + +public enum OfxSeverity +{ + NotSet, + [Description("Informational only")] + INFO, + [Description("Some problem with the request occurred but a valid response still present")] + WARN, + [Description("A problem severe enough that response could not be made")] + ERROR +} diff --git a/src/OfxNet/Models/OfxSignOn.cs b/src/OfxNet/Models/OfxSignOn.cs new file mode 100644 index 0000000..5714ebb --- /dev/null +++ b/src/OfxNet/Models/OfxSignOn.cs @@ -0,0 +1,11 @@ +namespace OfxNet; + +using System; + +public class OfxSignOn +{ + public OfxStatus? Status { get; set; } + public DateTimeOffset ServerDate { get; set; } + public string? Language { get; set; } + public string? IntuBid { get; set; } +} diff --git a/src/OfxNet/Models/OfxStatement.cs b/src/OfxNet/Models/OfxStatement.cs new file mode 100644 index 0000000..93b6bbc --- /dev/null +++ b/src/OfxNet/Models/OfxStatement.cs @@ -0,0 +1,14 @@ +namespace OfxNet; + +public class OfxStatement +{ + public string? DefaultCurrency { get; set; } + public OfxAccountBalance? LedgerBalance { get; set; } + public OfxAccountBalance? AvailableBalance { get; set; } + public OfxTransactionList? TransactionList { get; set; } +} + +public class OfxStatement : OfxStatement +{ + public TAccount? Account { get; set; } +} diff --git a/src/OfxNet/Models/OfxStatementTransaction.cs b/src/OfxNet/Models/OfxStatementTransaction.cs new file mode 100644 index 0000000..5af4e42 --- /dev/null +++ b/src/OfxNet/Models/OfxStatementTransaction.cs @@ -0,0 +1,29 @@ +namespace OfxNet; + +using System; + +public class OfxStatementTransaction +{ + public OfxTransactionType TxType { get; set; } + public DateTimeOffset DatePosted { get; set; } + public DateTimeOffset? DateUser { get; set; } + public DateTimeOffset? DateAvailable { get; set; } + public decimal Amount { get; set; } + public string? FitId { get; set; } + public string? Name { get; set; } + public string? Memo { get; set; } + public string? Memo2 { get; set; } + public string? ChequeNumber { get; set; } + public string? ReferenceNumber { get; set; } + public string? CorrectFitId { get; set; } + public OfxCorrectiveAction CorrectAction { get; set; } + public string? ServiceProviderName { get; set; } + public string? ServerTxId { get; set; } + public int? StandardIndustrialCode { get; set; } + public string? PayeeId { get; set; } + public OfxPayee? Payee { get; set; } + public OfxCurrency? Currency { get; set; } + public OfxCurrency? OriginalCurrency { get; set; } + public OfxBankAccount? BankAccountTo { get; set; } + public OfxCreditCardAccount? CreditCardAccountTo { get; set; } +} diff --git a/src/OfxNet/Models/OfxStatus.cs b/src/OfxNet/Models/OfxStatus.cs new file mode 100644 index 0000000..c1ae0ec --- /dev/null +++ b/src/OfxNet/Models/OfxStatus.cs @@ -0,0 +1,8 @@ +namespace OfxNet; + +public class OfxStatus +{ + public int Code { get; set; } + public OfxSeverity Severity { get; set; } + public string? Message { get; set; } +} diff --git a/src/OfxNet/Models/OfxTransactionList.cs b/src/OfxNet/Models/OfxTransactionList.cs new file mode 100644 index 0000000..41bf1e4 --- /dev/null +++ b/src/OfxNet/Models/OfxTransactionList.cs @@ -0,0 +1,11 @@ +namespace OfxNet; + +using System; +using System.Collections.Generic; + +public class OfxTransactionList +{ + public DateTimeOffset StartDate { get; set; } + public DateTimeOffset EndDate { get; set; } + public List Transactions { get; set; } = []; +} diff --git a/src/OfxNet/Models/OfxTransactionType.cs b/src/OfxNet/Models/OfxTransactionType.cs new file mode 100644 index 0000000..a148e13 --- /dev/null +++ b/src/OfxNet/Models/OfxTransactionType.cs @@ -0,0 +1,44 @@ +namespace OfxNet; + +using System.ComponentModel; + +public enum OfxTransactionType +{ + NotSet, + [Description("Generic credit")] + CREDIT, + [Description("Generic debit")] + DEBIT, + [Description("Interest earned or paid. Note: Depends on signage of amount")] + INT, + [Description("Dividend")] + DIV, + [Description("FI fee")] + FEE, + [Description("Service charge")] + SRVCHG, + [Description("Deposit")] + DEP, + [Description("ATM debit or credit. Note: Depends on signage of amount")] + ATM, + [Description("Point of sale debit or credit. Note: Depends on signage of amount")] + POS, + [Description("Transfer")] + XFER, + [Description("Check")] + CHECK, + [Description("Electronic payment")] + PAYMENT, + [Description("Cash withdrawal")] + CASH, + [Description("Direct Deposit")] + DIRECTDEP, + [Description("Merchant Initiated Debit")] + DIRECTDEBIT, + [Description("Repeating payment/standing order")] + REPEATPMT, + [Description("Only valid in ; indicates the amount is under a hold. Note: Depends on signage of amount and account type")] + HOLD, + [Description("Other")] + OTHER, +} diff --git a/src/OfxNet/Models/OfxVersion.cs b/src/OfxNet/Models/OfxVersion.cs new file mode 100644 index 0000000..24dccbb --- /dev/null +++ b/src/OfxNet/Models/OfxVersion.cs @@ -0,0 +1,37 @@ +namespace OfxNet; + +using System; + +public readonly struct OfxVersion(int major, int minor, int revision) : IEquatable +{ + public static readonly OfxVersion InvalidHeader = new OfxVersion(-1, -1, -1); + public static readonly OfxVersion HeaderV1 = new OfxVersion(1, 0, 0); + + public int Major { get; } = major; + public int Minor { get; } = minor; + public int Revision { get; } = revision; + + public override bool Equals(object? obj) => (obj is OfxVersion other) && Equals(other); + + public bool Equals(OfxVersion other) + { + return (Major == other.Major) + && (Minor == other.Minor) + && (Revision == other.Revision); + } + + public static bool operator ==(OfxVersion left, OfxVersion right) + { + return left.Equals(right); + } + + public static bool operator !=(OfxVersion left, OfxVersion right) + { + return !(left == right); + } + + public override int GetHashCode() + { + return HashCode.Combine(Major, Minor, Revision); + } +} diff --git a/src/OfxNet/OfxConstants.cs b/src/OfxNet/OfxConstants.cs new file mode 100644 index 0000000..3078a1b --- /dev/null +++ b/src/OfxNet/OfxConstants.cs @@ -0,0 +1,94 @@ +namespace OfxNet; + +using System.Globalization; + +public static class OfxConstants +{ + public const string OfxTag = "OFX"; + public const string SignonMessageSetResponseV1 = "SIGNONMSGSRSV1"; + public const string SignonResponse = "SONRS"; + + public const string BankMessageSetResponseV1 = "BANKMSGSRSV1"; + public const string StatementTxResponse = "STMTTRNRS"; + public const string StatementResponse = "STMTRS"; + public const string BankAccountFrom = "BANKACCTFROM"; + public const string BankAccountTo = "BANKACCTTO"; + public const string BankTransactionList = "BANKTRANLIST"; + + public const string CreditCardMessageSetResponseV1 = "CREDITCARDMSGSRSV1"; + public const string CreditCardMessageSetResponseV2 = "CREDITCARDMSGSRSV2"; + public const string CreditCardStatementTxResponse = "CCSTMTTRNRS"; + public const string CreditCardStatementResponse = "CCSTMTRS"; + public const string CreditCardAccountFrom = "CCACCTFROM"; + public const string CreditCardAccountTo = "CCACCTTO"; + + public const string IntuBId = "INTU.BID"; + public const string Language = "LANGUAGE"; + public const string ServerDate = "DTSERVER"; + public const string TransactionUid = "TRNUID"; + public const string Status = "STATUS"; + public const string Code = "CODE"; + public const string Severity = "SEVERITY"; + public const string DefaultCurrency = "CURDEF"; + public const string Currency = "CURRENCY"; + public const string OriginalCurrency = "ORIGCURRENCY"; + public const string CurrencyRate = "CURRATE"; + public const string CurrencySymbol = "CURSYM"; + public const string StartDate = "DTSTART"; + public const string EndDate = "DTEND"; + public const string BankId = "BANKID"; + public const string BranchId = "BRANCHID"; + public const string AccountId = "ACCTID"; + public const string AccountType = "ACCTTYPE"; + public const string AccountType2 = "ACCTTYPE2"; + public const string AccountKey = "ACCTKEY"; + public const string StatementTransaction = "STMTTRN"; + public const string TransactionType = "TRNTYPE"; + public const string DatePosted = "DTPOSTED"; + public const string UserDate = "DTUSER"; + public const string DateAvailable = "DTAVAIL"; + public const string TransactionAmount = "TRNAMT"; + public const string FitId = "FITID"; + public const string Name = "NAME"; + public const string Memo = "MEMO"; + public const string Memo2 = "MEMO2"; + public const string ChequeNumber = "CHECKNUM"; + public const string ReferenceNumber = "REFNUM"; + public const string CorrectFitId = "CORRECTFITID"; + public const string CorrectAction = "CORRECTACTION"; + public const string ServiceProviderName = "SPNAME"; + public const string ServerTxId = "SRVRTID"; + public const string ServerTxId2 = "SRVRTID2"; + public const string StandardIndustrialCode = "SIC"; + public const string PayeeId = "PAYEEID"; + public const string PayeeId2 = "PAYEEID2"; + public const string Payee = "PAYEE"; + public const string Payee2 = "PAYEE2"; + public const string Address1 = "ADDR1"; + public const string Address2 = "ADDR2"; + public const string Address3 = "ADDR3"; + public const string City = "CITY"; + public const string State = "STATE"; + public const string PostalCode = "POSTALCODE"; + public const string Country = "COUNTRY"; + public const string Phone = "PHONE"; + public const string LedgerBalance = "LEDGERBAL"; + public const string AvailableBalance = "AVAILBAL"; + public const string BalanceAmount = "BALAMT"; + public const string DateAsOf = "DTASOF"; + + public const DateTimeStyles DefaultDateTimeStyles = DateTimeStyles.AssumeUniversal; + + public static readonly string[] DateTimeFormats = + [ + "yyyyMMdd", + "yyyyMMddHHmm", + "yyyyMMddHHmmss", + "yyyyMMddHHmmss[z]", + "yyyyMMddHHmmss.fff", + "yyyyMMddHHmmss.fff[z]" + ]; + + public const string TimeZoneRegexPattern = @":(\w+)\]\s*$"; + public const string TimeZoneReplacement = "]"; +} diff --git a/src/OfxNet/OfxDocument.cs b/src/OfxNet/OfxDocument.cs new file mode 100644 index 0000000..6039fd1 --- /dev/null +++ b/src/OfxNet/OfxDocument.cs @@ -0,0 +1,429 @@ +namespace OfxNet; + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Xml.Linq; + +public class OfxDocument +{ + private readonly object document; + + public OfxDocumentSettings Settings { get; } + + public OfxDocument(object document) + : this(document, OfxDocumentSettings.Default) + { + } + + public OfxDocument(object document, OfxDocumentSettings settings) + { + this.document = document; + Settings = settings; + } + + public static OfxDocument Load(string path) + { + return Load(path, OfxDocumentSettings.Default); + } + + public static OfxDocument Load(string path, OfxDocumentSettings settings) + { + OfxDocument result; + + if (SgmlDocument.TryLoad(path, out SgmlDocument? sgmlDocument)) + { + result = new OfxDocument(sgmlDocument, settings); + } + else + { + result = new OfxDocument(XDocument.Load(path), settings); + } + + return result; + } + + public IOfxElement? GetRoot() + { + IOfxElement? result = default; + if (document is SgmlDocument sgmlDocument) + { + result = sgmlDocument.Root; + } + else if (document is XDocument { Root: not null } xmlDocument) + { + result = new XElementAdapter(xmlDocument.Root); + } + + return result; + } + + public IEnumerable GetStatements() + { + IOfxElement? element = GetRoot(); + + return GetStatements(element); + } + + public IEnumerable GetStatements(IOfxElement? element) + { + IEnumerable result = (element is null) + ? Enumerable.Empty() + : GetBankStatements(element).Concat(GetCreditCardStatements(element)); + + return result; + } + + public IEnumerable GetBankStatements(IOfxElement element) + { + IOfxElement? set = GetElement(element, OfxConstants.BankMessageSetResponseV1); + IEnumerable elements = GetElements(set, OfxConstants.StatementTxResponse); + IEnumerable statements = GetBankStatements(elements); + + if (statements != null) + { + foreach (var statement in statements) + { + yield return statement; + } + } + } + + public IEnumerable GetCreditCardStatements(IOfxElement element) + { + IOfxElement? set = GetElement(element, OfxConstants.CreditCardMessageSetResponseV1, OfxConstants.CreditCardMessageSetResponseV2); + IEnumerable elements = GetElements(set, OfxConstants.CreditCardStatementTxResponse); + IEnumerable creditCardStatements = GetCreditCardStatements(elements); + + if (creditCardStatements != null) + { + foreach (var statement in creditCardStatements) + { + yield return statement; + } + } + } + + public IEnumerable GetBankStatements(IEnumerable elements) + { + foreach (IOfxElement element in elements) + { + IOfxElement? response = GetElement(element, OfxConstants.StatementResponse); + if (response != null) + { + yield return GetBankStatement(response); + } + } + } + + public IEnumerable GetCreditCardStatements(IEnumerable elements) + { + foreach (IOfxElement element in elements) + { + IOfxElement? response = GetElement(element, OfxConstants.CreditCardStatementResponse); + if (response != null) + { + yield return GetCreditCardStatement(response); + } + } + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxSignOn? GetSignon(IOfxElement element) + { + return (element is null) + ? null + : new OfxSignOn + { + Status = GetStatus(GetElement(element, OfxConstants.Status)), + ServerDate = GetAsDateTimeOffset(element, OfxConstants.ServerDate), + Language = GetAsString(element, OfxConstants.Language), + IntuBid = GetAsString(element, OfxConstants.IntuBId) + }; + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxBankStatement? GetBankStatement(IOfxElement? element) + { + return (element is null) + ? null + : new OfxBankStatement + { + DefaultCurrency = GetAsString(element, OfxConstants.DefaultCurrency), + Account = GetBankAccount(GetElement(element, OfxConstants.BankAccountFrom)), + TransactionList = GetStatementTransactionList(GetElement(element, OfxConstants.BankTransactionList)), + LedgerBalance = GetAccountBalance(GetElement(element, OfxConstants.LedgerBalance)), + AvailableBalance = GetAccountBalance(GetElement(element, OfxConstants.AvailableBalance)) + }; + } + + public OfxCreditCardStatement GetCreditCardStatement(IOfxElement element) + { + return new OfxCreditCardStatement + { + DefaultCurrency = GetAsString(element, OfxConstants.DefaultCurrency), + Account = GetCreditCardAccount(GetElement(element, OfxConstants.CreditCardAccountFrom)), + TransactionList = GetStatementTransactionList(GetElement(element, OfxConstants.BankTransactionList)), + LedgerBalance = GetAccountBalance(GetElement(element, OfxConstants.LedgerBalance)), + AvailableBalance = GetAccountBalance(GetElement(element, OfxConstants.AvailableBalance)) + }; + } + + public OfxTransactionList? GetStatementTransactionList(IOfxElement? element) + { + OfxTransactionList? result = null; + + if (element != null) + { + IEnumerable query = from t in GetElements(element, OfxConstants.StatementTransaction) + select GetStatementTransaction(t); + + result = new OfxTransactionList + { + StartDate = GetAsDateTimeOffset(element, OfxConstants.StartDate), + EndDate = GetAsDateTimeOffset(element, OfxConstants.EndDate), + Transactions = query.ToList() + }; + } + + return result; + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxStatementTransaction? GetStatementTransaction(IOfxElement? element) + { + return (element is null) + ? null + : new OfxStatementTransaction + { + TxType = GetTransactionType(element), + DatePosted = GetAsDateTimeOffset(element, OfxConstants.DatePosted), + DateUser = GetAsNullableDateTimeOffset(element, OfxConstants.UserDate), + DateAvailable = GetAsNullableDateTimeOffset(element, OfxConstants.DateAvailable), + Amount = GetAsRequiredDecimal(element, OfxConstants.TransactionAmount, "Missing or invalid transaction amount from transaction element"), + FitId = GetAsString(element, OfxConstants.FitId), + Name = GetAsString(element, OfxConstants.Name), + Memo = GetAsString(element, OfxConstants.Memo), + Memo2 = GetAsString(element, OfxConstants.Memo2), + ChequeNumber = GetAsString(element, OfxConstants.ChequeNumber), + ReferenceNumber = GetAsString(element, OfxConstants.ReferenceNumber), + CorrectFitId = GetAsString(element, OfxConstants.CorrectFitId), + CorrectAction = GetCorrectiveAction(element), + ServiceProviderName = GetAsString(element, OfxConstants.ServiceProviderName), + ServerTxId = GetAsString(element, OfxConstants.ServerTxId2, OfxConstants.ServerTxId), + StandardIndustrialCode = GetAsNullableInt(element, OfxConstants.StandardIndustrialCode), + PayeeId = GetAsString(element, OfxConstants.PayeeId2, OfxConstants.PayeeId), + Payee = GetPayee(GetElement(element, OfxConstants.Payee2, OfxConstants.Payee)), + Currency = GetCurrency(GetElement(element, OfxConstants.Currency)), + OriginalCurrency = GetCurrency(GetElement(element, OfxConstants.OriginalCurrency)), + BankAccountTo = GetBankAccount(GetElement(element, OfxConstants.BankAccountTo)), + CreditCardAccountTo = GetCreditCardAccount(GetElement(element, OfxConstants.CreditCardAccountTo)) + }; + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxCurrency? GetCurrency(IOfxElement? element) + { + return (element is null) + ? null + : new OfxCurrency( + GetAsRequiredDecimal(element, OfxConstants.CurrencyRate, "Missing required currency rate in currency element."), + GetAsRequiredString(element, OfxConstants.CurrencySymbol, "Missing required currency symbol in currency element.") + ); + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxPayee? GetPayee(IOfxElement? element) + { + return (element is null) + ? null + : new OfxPayee + { + Name = GetAsString(element, OfxConstants.Name), + AddressLine1 = GetAsString(element, OfxConstants.Address1), + AddressLine2 = GetAsString(element, OfxConstants.Address2), + AddressLine3 = GetAsString(element, OfxConstants.Address3), + City = GetAsString(element, OfxConstants.City), + State = GetAsString(element, OfxConstants.State), + PostalCode = GetAsString(element, OfxConstants.PostalCode), + Country = GetAsString(element, OfxConstants.Country), + PhoneNumber = GetAsString(element, OfxConstants.Phone), + }; + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxAccountBalance? GetAccountBalance(IOfxElement? element) + { + return (element is null) + ? null + : new OfxAccountBalance + { + Balance = GetAsRequiredDecimal(element, OfxConstants.BalanceAmount, "Missing or invalid balance from balance element."), + DateAsOf = GetAsDateTimeOffset(element, OfxConstants.DateAsOf) + }; + } + + public OfxStatus? GetStatus(IOfxElement? element) + { + return (element is null) + ? null + : new OfxStatus + { + Code = GetAsRequiredInteger(element, OfxConstants.Code, "Missing required Code from status element."), + Severity = GetSeverity(element) + }; + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxBankAccount? GetBankAccount(IOfxElement? element) + { + return (element is null) + ? null + : new OfxBankAccount + { + BankId = GetAsString(element, OfxConstants.BankId), + BranchId = GetAsString(element, OfxConstants.BranchId), + AccountNumber = GetAsString(element, OfxConstants.AccountId), + AccountType = GetAccountType(element), + Checksum = GetAsString(element, OfxConstants.AccountKey) + }; + } + + [return: NotNullIfNotNull(nameof(element))] + public OfxCreditCardAccount? GetCreditCardAccount(IOfxElement? element) + { + return (element is null) + ? null + : new OfxCreditCardAccount + { + AccountNumber = GetAsString(element, OfxConstants.AccountId), + Checksum = GetAsString(element, OfxConstants.AccountKey) + }; + } + + private int GetAsRequiredInteger(IOfxElement parent, string name, string errorString) + { + string? value = GetAsString(parent, name); + + (bool NullOrWhiteSpace, bool NotInteger, int Value) = OfxParser.ParseInteger(value); + if (NullOrWhiteSpace || NotInteger) + { + throw new OfxException(errorString); + } + + return Value; + } + + private int? GetAsNullableInt(IOfxElement parent, string name) + { + string? value = GetAsString(parent, name); + return int.TryParse(value, out var result) ? result : default(int?); + } + + private decimal GetAsRequiredDecimal(IOfxElement parent, string name, string errorString) + { + string? value = GetAsString(parent, name); + + (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) = OfxParser.ParseDecimal(value); + if (NullOrWhiteSpace || NotDecimal) + { + throw new OfxException(errorString); + } + + return Value; + } + + private DateTimeOffset GetAsDateTimeOffset(IOfxElement parent, string name) + { + string? value = GetAsString(parent, name); + return OfxParser.ParseDateTime(value); + } + + private DateTimeOffset? GetAsNullableDateTimeOffset(IOfxElement parent, string name) + { + string? value = GetAsString(parent, name); + return OfxParser.ParseNullableDateTime(value); + } + + private OfxAccountType GetAccountType(IOfxElement parent) + { + return OfxParser.ParseAccountType( + GetAsString(parent, OfxConstants.AccountType2, OfxConstants.AccountType)); + } + + private OfxSeverity GetSeverity(IOfxElement parent) + { + return OfxParser.ParseSeverity( + GetAsString(parent, OfxConstants.Severity)); + } + + private OfxTransactionType GetTransactionType(IOfxElement parent) + { + return OfxParser.ParseTransactionType( + GetAsString(parent, OfxConstants.TransactionType)); + } + + private OfxCorrectiveAction GetCorrectiveAction(IOfxElement parent) + { + return OfxParser.ParseCorrectiveAction( + GetAsString(parent, OfxConstants.CorrectAction)); + } + + public string? GetAsString(IOfxElement element, string first, string second) + { + var result = GetAsString(element, first); + if (string.IsNullOrWhiteSpace(result)) + { + result = GetAsString(element, second); + } + + return result; + } + + private string? GetAsString(IOfxElement parent, string name) + { + var result = GetElement(parent, name)?.Value; + if (Settings.TrimValues && string.IsNullOrEmpty(result) == false) + { + result = result.Trim(); + } + return result; + } + + private string GetAsRequiredString(IOfxElement parent, string name, string errorString) + { + var result = GetElement(parent, name)?.Value; + if (string.IsNullOrWhiteSpace(result)) + { + throw new OfxException(errorString); + } + if (Settings.TrimValues && string.IsNullOrEmpty(result) == false) + { + result = result.Trim(); + } + return result; + } + + private IEnumerable GetElements(IOfxElement? parent, string name) + { + if (parent != null) + { + var items = parent.Elements(name, Settings.TagComparer); + foreach (var item in items) + { + yield return item; + } + } + } + + private IOfxElement? GetElement(IOfxElement parent, string name) + { + return parent.Element(name, Settings.TagComparer); + } + + public IOfxElement? GetElement(IOfxElement parent, string first, string second) + { + return GetElement(parent, first) ?? GetElement(parent, second); + } +} diff --git a/src/OfxNet/OfxDocumentSettings.cs b/src/OfxNet/OfxDocumentSettings.cs new file mode 100644 index 0000000..3c62a6f --- /dev/null +++ b/src/OfxNet/OfxDocumentSettings.cs @@ -0,0 +1,17 @@ +namespace OfxNet; + +using System; +using System.Diagnostics.CodeAnalysis; + +[SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Not currently required.")] +public struct OfxDocumentSettings +{ + public readonly static OfxDocumentSettings Default = new() + { + TrimValues = true, + TagComparer = StringComparer.CurrentCultureIgnoreCase + }; + + public bool TrimValues { get; set; } + public StringComparer TagComparer { get; set; } +} diff --git a/src/OfxNet/OfxException.cs b/src/OfxNet/OfxException.cs new file mode 100644 index 0000000..fcd2b5c --- /dev/null +++ b/src/OfxNet/OfxException.cs @@ -0,0 +1,24 @@ +namespace OfxNet; + +using System; +using System.Runtime.Serialization; + +[Serializable] +internal class OfxException : Exception +{ + public OfxException() + { + } + + public OfxException(string? message) : base(message) + { + } + + public OfxException(string? message, Exception? innerException) : base(message, innerException) + { + } + + protected OfxException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} diff --git a/OfxNet/OfxNet.csproj b/src/OfxNet/OfxNet.csproj similarity index 71% rename from OfxNet/OfxNet.csproj rename to src/OfxNet/OfxNet.csproj index 639b803..a20759b 100644 --- a/OfxNet/OfxNet.csproj +++ b/src/OfxNet/OfxNet.csproj @@ -1,24 +1,22 @@  - - net6.0 - Jim Dale https://github.com/jim-dale/BankingTools https://github.com/jim-dale/BankingTools.git git - enable en Parse Open Financial Exchange (OFX) files. Supports SGML and XML formatted OFX files. OFX MIT README.md - 1.2.0.0 + 1.4.0.0 - - - + + + + + diff --git a/src/OfxNet/OfxParser.cs b/src/OfxNet/OfxParser.cs new file mode 100644 index 0000000..634ae34 --- /dev/null +++ b/src/OfxNet/OfxParser.cs @@ -0,0 +1,167 @@ +namespace OfxNet; + +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.RegularExpressions; + +/// +/// Utility methods to parse OFX values. +/// +public static class OfxParser +{ + /// + /// Parses an OFX version string. The expected format is 3 digits - [Major][Minor][Revision] e.g. "100". + /// + /// The string containing the version number to convert. + /// + public static OfxVersion ParseVersion(string s) + { + OfxVersion result = TryParseVersion(s); + if (result == OfxVersion.InvalidHeader) + { + throw new SgmlParseException("Invalid OFX version number."); + } + + return result; + } + + /// + /// Try to parse an OFX version string. The expected format is 3 digits - [Major][Minor][Revision] e.g. "100". + /// + /// The string containing the version number to convert. + /// + public static OfxVersion TryParseVersion(string s) + { + OfxVersion result = OfxVersion.InvalidHeader; + + if (string.IsNullOrWhiteSpace(s) == false && s.Length == 3) + { + if (TryGetDigitValue(s[0], out int major) + && TryGetDigitValue(s[1], out int minor) + && TryGetDigitValue(s[2], out int revision)) + { + result = new OfxVersion(major, minor, revision); + } + } + + return result; + } + + /// + /// Parses an OFX integer value. + /// + /// The string containing the number to convert. + /// + public static (bool NullOrWhiteSpace, bool NotInteger, int Value) ParseInteger(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + { + return (true, false, default); + } + if (int.TryParse(s, out int temp)) + { + return (false, false, temp); + } + else + { + return (false, true, default); + } + } + + public static (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) ParseDecimal(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return (true, false, default); + } + if (decimal.TryParse(value, out decimal temp)) + { + return (false, false, temp); + } + else + { + return (false, true, default); + } + } + + public static DateTimeOffset? ParseNullableDateTime(string? value) + { + DateTimeOffset? result = default; + + if (string.IsNullOrWhiteSpace(value) == false) + { + result = ParseDateTime(value); + } + + return result; + } + + public static DateTimeOffset ParseDateTime(string? value) + { + DateTimeOffset result = default; + + if (string.IsNullOrWhiteSpace(value) == false) + { + if (TryParseDateTimeOffset(value, OfxConstants.DefaultDateTimeStyles, out result) == false) + { + throw new FormatException("String was not recognized as a valid DateTimeOffset."); + } + } + + return result; + } + + public static OfxAccountType ParseAccountType(string? value) + { + return ParseEnumString(value); + } + + public static OfxSeverity ParseSeverity(string? value) + { + return ParseEnumString(value); + } + + public static OfxTransactionType ParseTransactionType(string? value) + { + return ParseEnumString(value); + } + + public static OfxCorrectiveAction ParseCorrectiveAction(string? value) + { + return ParseEnumString(value); + } + + private static bool TryGetDigitValue(char ch, out int result) + { + var success = char.IsDigit(ch); + result = success ? (ch - '0') : default; + + return success; + } + + private static bool TryParseDateTimeOffset(string str, DateTimeStyles style, out DateTimeOffset result) + { + // Remove possible time zone name from string to be parsed + var noTimeZoneName = Regex.Replace(str, OfxConstants.TimeZoneRegexPattern, OfxConstants.TimeZoneReplacement); + + return DateTimeOffset.TryParseExact(noTimeZoneName, + OfxConstants.DateTimeFormats, + CultureInfo.InvariantCulture, + style, + out result); + } + + private static EnumType ParseEnumString(string? value) where EnumType : Enum + { + EnumType result = default!; + + if (string.IsNullOrWhiteSpace(value) == false + && Enum.IsDefined(typeof(EnumType), value)) + { + result = (EnumType)Enum.Parse(typeof(EnumType), value, true); + } + + return result; + } +} diff --git a/src/OfxNet/Sgml/SgmlConstants.cs b/src/OfxNet/Sgml/SgmlConstants.cs new file mode 100644 index 0000000..7171d71 --- /dev/null +++ b/src/OfxNet/Sgml/SgmlConstants.cs @@ -0,0 +1,33 @@ +namespace OfxNet; + +using System.Text.RegularExpressions; + +public static class SgmlConstants +{ + public const string Header = "OFXHEADER"; + public const string DataHeader = "DATA"; + public const string VersionHeader = "VERSION"; + public const string SecurityHeader = "SECURITY"; + public const string EncodingHeader = "ENCODING"; + public const string CharsetHeader = "CHARSET"; + public const string CompressionHeader = "COMPRESSION"; + public const string OldFileUIDHeader = "OLDFILEUID"; + public const string NewFileUIDHeader = "NEWFILEUID"; + + public const string HeaderRegexPrefix = "^"; + public const string HeaderRegexSeparator = @"\s*:\s*"; + public const string HeaderVersionRegexPattern = HeaderRegexPrefix + Header + HeaderRegexSeparator + @"(\d{3})" + @"\s*$"; + public const string HeaderRegexPattern = HeaderRegexPrefix + @"(\w+)" + HeaderRegexSeparator + @"(.+)" + @"$"; + + public const string OpeningTagRegexPattern = @"^\s*<([\w\.]+)>\s*$"; + public const string ClosingTagRegexPattern = @"^\s*\s*$"; + public const string ValueFullTagRegexPattern = @"^\s*<([\w\.]+)>(.+)\s*$"; + public const string ValuePartialTagRegexPattern = @"^\s*<([\w\.]+)>(.+)$"; + + public static readonly Regex HeaderVersionRegex = new Regex(HeaderVersionRegexPattern, RegexOptions.IgnoreCase); + public static readonly Regex HeaderRegex = new Regex(HeaderRegexPattern, RegexOptions.IgnoreCase); + public static readonly Regex OpeningTagRegex = new Regex(OpeningTagRegexPattern, RegexOptions.IgnoreCase); + public static readonly Regex ClosingTagRegex = new Regex(ClosingTagRegexPattern, RegexOptions.IgnoreCase); + public static readonly Regex ValueFullTagRegex = new Regex(ValueFullTagRegexPattern, RegexOptions.IgnoreCase); + public static readonly Regex ValuePartialTagRegex = new Regex(ValuePartialTagRegexPattern, RegexOptions.IgnoreCase); +} diff --git a/src/OfxNet/Sgml/SgmlDocument.cs b/src/OfxNet/Sgml/SgmlDocument.cs new file mode 100644 index 0000000..70e89bc --- /dev/null +++ b/src/OfxNet/Sgml/SgmlDocument.cs @@ -0,0 +1,35 @@ +namespace OfxNet; + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +public class SgmlDocument +{ + private SgmlDocument(SgmlHeader header, SgmlElement root) + { + Header = header; + Root = root; + } + + public SgmlHeader Header { get; set; } + public SgmlElement Root { get; set; } + + public static bool TryLoad(string path, [NotNullWhen(true)] out SgmlDocument? result) + { + result = null; + + SgmlHeader? header = new SgmlHeaderParser().TryGetHeader(path); + if (header != default) + { + Encoding encoding = header.GetEncoding(); + + SgmlElement root = new SgmlParser().Parse(path, encoding); + if (root != SgmlElement.Empty) + { + result = new SgmlDocument(header, root); + } + } + + return (result != null); + } +} diff --git a/src/OfxNet/Sgml/SgmlElement.cs b/src/OfxNet/Sgml/SgmlElement.cs new file mode 100644 index 0000000..77bfbff --- /dev/null +++ b/src/OfxNet/Sgml/SgmlElement.cs @@ -0,0 +1,61 @@ +namespace OfxNet; + +using System; +using System.Collections.Generic; +using System.Linq; + +public class SgmlElement : IOfxElement +{ + public static readonly SgmlElement Empty = new SgmlElement(string.Empty, string.Empty); + + public string Name { get; } + public string? Value { get; } + public string Text { get; } + public SgmlElement? Parent { get; } + public IList? Children { get; private set; } + + public SgmlElement(string name, string text) + : this(name, null, text, null) + { + } + + public SgmlElement(string name, string text, SgmlElement parent) + : this(name, null, text, parent) + { + } + + public SgmlElement(string name, string? value, string text, SgmlElement? parent) + { + Name = name; + Value = value; + Text = text; + Parent = parent; + } + + public SgmlElement AddChild(SgmlElement item) + { + Children ??= new List(); + Children.Add(item); + + return item; + } + + public IOfxElement? Element(string name, StringComparer comparer) + { + return Children?.SingleOrDefault(e => comparer.Equals(name, e.Name)); + } + + public IEnumerable Elements(string name, StringComparer comparer) + { + if (Children != null) + { + foreach (SgmlElement child in Children) + { + if (comparer.Equals(name, child.Name)) + { + yield return child; + } + } + } + } +} diff --git a/src/OfxNet/Sgml/SgmlHeader.cs b/src/OfxNet/Sgml/SgmlHeader.cs new file mode 100644 index 0000000..70395d0 --- /dev/null +++ b/src/OfxNet/Sgml/SgmlHeader.cs @@ -0,0 +1,14 @@ +namespace OfxNet; + +public class SgmlHeader +{ + public OfxVersion HeaderVersion { get; set; } + public string? Data { get; set; } + public OfxVersion Version { get; set; } + public string? Security { get; set; } + public string? Encoding { get; set; } + public string? Charset { get; set; } + public string? Compression { get; set; } + public string? OldFileUid { get; set; } + public string? NewFileUid { get; set; } +} diff --git a/src/OfxNet/Sgml/SgmlHeaderExtensions.cs b/src/OfxNet/Sgml/SgmlHeaderExtensions.cs new file mode 100644 index 0000000..bfe7de4 --- /dev/null +++ b/src/OfxNet/Sgml/SgmlHeaderExtensions.cs @@ -0,0 +1,46 @@ +namespace OfxNet; + +using System; +using System.Text; + +public static partial class SgmlHeaderExtensions +{ + public static Encoding GetEncoding(this SgmlHeader item) + { + var result = Encoding.Default; + + if (string.Equals("USASCII", item.Encoding, StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals("1252", item.Charset, StringComparison.OrdinalIgnoreCase)) + { + result = Encoding.GetEncoding(1252); + } + else if (string.Equals("ISO-8859-1", item.Charset, StringComparison.OrdinalIgnoreCase)) + { + result = Encoding.GetEncoding("iso-8859-1"); + } + else if (string.Equals("NONE", item.Charset, StringComparison.OrdinalIgnoreCase)) + { + result = Encoding.GetEncoding("us-ascii"); + } + else if (item.Charset is not null) + { + try + { + result = Encoding.GetEncoding(item.Charset); + } +#pragma warning disable CA1031 // Justification - this is the exact exception thrown + catch (ArgumentException) + { + } +#pragma warning restore CA1031 + } + } + else if (string.Equals("UTF-8", item.Encoding, StringComparison.OrdinalIgnoreCase)) + { + result = Encoding.UTF8; + } + + return result; + } +} diff --git a/src/OfxNet/Sgml/SgmlHeaderParser.cs b/src/OfxNet/Sgml/SgmlHeaderParser.cs new file mode 100644 index 0000000..039ba07 --- /dev/null +++ b/src/OfxNet/Sgml/SgmlHeaderParser.cs @@ -0,0 +1,161 @@ +namespace OfxNet; + +using System; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +public class SgmlHeaderParser +{ + private int _lineNumber; + + public SgmlHeader? TryGetHeader(string path) + { + using StreamReader stream = new (path, Encoding.ASCII); + + OfxVersion headerVersion = TryGetOfxHeaderVersion(stream); + + return (headerVersion == OfxVersion.HeaderV1) ? GetHeader(stream, headerVersion) : default; + } + + public int SkipToContent(TextReader reader) + { + SkipNoneContentLines(reader); + + ReadHeaders(reader, (line) => false); + + return _lineNumber; + } + + private OfxVersion TryGetOfxHeaderVersion(TextReader reader) + { + OfxVersion result = OfxVersion.InvalidHeader; + + SkipNoneContentLines(reader); + + ReadHeaders(reader, (line) => + { + // First header must be the OFX version header e.g. 'OFXHEADER:100' + Match match = SgmlConstants.HeaderVersionRegex.Match(line); + if (match.Success) + { + result = OfxParser.TryParseVersion(match.Groups[1].Value); + } + + return true; + }); + + return result; + } + + private SgmlHeader GetHeader(TextReader reader, OfxVersion headerVersion) + { + var result = new SgmlHeader + { + HeaderVersion = headerVersion + }; + + ReadHeaders(reader, (line) => + { + Match match = SgmlConstants.HeaderRegex.Match(line); + if (match.Success) + { + string name = match.Groups[1].Value.ToUpperInvariant(); + string value = match.Groups[2].Value.Trim(); + + _ = TrySetHeaderValue(result, name, value); + } + else + { + throw new SgmlParseException("Invalid format while parsing OFX headers, line number " + _lineNumber + "."); + } + + return false; + }); + + return result; + } + + private void SkipNoneContentLines(TextReader reader) + { + int nextChar; // When Peek returns -1 it is the end of the stream + while ((nextChar = reader.Peek()) != -1) + { + // OFX headers all start with a normal ASCII letter. Anything else + // stop processing. + if (char.IsWhiteSpace((char)nextChar) == false) + { + break; + } + + _ = reader.ReadLine(); + ++_lineNumber; + } + } + + private void ReadHeaders(TextReader reader, Func processLine) + { + int nextChar; // When Peek returns -1 it is the end of the stream + while ((nextChar = reader.Peek()) != -1) + { + // OFX headers all start with a normal ASCII letter. + // Anything else - stop processing. + if (char.IsLetter((char)nextChar) == false) + { + break; + } + + string? line = reader.ReadLine(); + if (line is null) + { + // EOF + break; + } + + ++_lineNumber; + + if (processLine.Invoke(line)) + { + break; + } + } + } + + private bool TrySetHeaderValue(SgmlHeader item, string name, string value) + { + bool result = true; + + switch (name) + { + case SgmlConstants.DataHeader: + item.Data = value; + break; + case SgmlConstants.VersionHeader: + item.Version = OfxParser.ParseVersion(value); + break; + case SgmlConstants.SecurityHeader: + item.Security = value; + break; + case SgmlConstants.EncodingHeader: + item.Encoding = value; + break; + case SgmlConstants.CharsetHeader: + item.Charset = value; + break; + case SgmlConstants.CompressionHeader: + item.Compression = value; + break; + case SgmlConstants.OldFileUIDHeader: + item.OldFileUid = value; + break; + case SgmlConstants.NewFileUIDHeader: + item.NewFileUid = value; + break; + default: + result = false; + break; + } + + return result; + } +} diff --git a/src/OfxNet/Sgml/SgmlParseException.cs b/src/OfxNet/Sgml/SgmlParseException.cs new file mode 100644 index 0000000..54683e7 --- /dev/null +++ b/src/OfxNet/Sgml/SgmlParseException.cs @@ -0,0 +1,25 @@ +namespace OfxNet; + +using System; +using System.Runtime.Serialization; + +[Serializable] +public class SgmlParseException : Exception +{ + public SgmlParseException() + { + } + + public SgmlParseException(string message) : base(message) + { + } + + public SgmlParseException(string message, Exception inner) : base(message, inner) + { + } + + protected SgmlParseException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } +} diff --git a/src/OfxNet/Sgml/SgmlParseResult.cs b/src/OfxNet/Sgml/SgmlParseResult.cs new file mode 100644 index 0000000..746d10e --- /dev/null +++ b/src/OfxNet/Sgml/SgmlParseResult.cs @@ -0,0 +1,20 @@ +namespace OfxNet; + +internal class SgmlParseResult +{ + internal SgmlTagType TagType { get; set; } + internal string Tag { get; set; } + internal string? Value { get; set; } + + internal SgmlParseResult(SgmlTagType tagType, string tag) + : this(tagType, tag, null) + { + } + + internal SgmlParseResult(SgmlTagType tagType, string tag, string? value) + { + TagType = tagType; + Tag = tag; + Value = value; + } +} diff --git a/src/OfxNet/Sgml/SgmlParser.cs b/src/OfxNet/Sgml/SgmlParser.cs new file mode 100644 index 0000000..3e630bd --- /dev/null +++ b/src/OfxNet/Sgml/SgmlParser.cs @@ -0,0 +1,165 @@ +namespace OfxNet; + +using System; +using System.IO; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; + +public class SgmlParser +{ + private int _lineNumber; + private SgmlElement _root = SgmlElement.Empty; + private SgmlElement _currentNode = SgmlElement.Empty; + + public SgmlElement Parse(string path, Encoding encoding) + { + using StreamReader reader = new(path, encoding); + + _lineNumber = new SgmlHeaderParser().SkipToContent(reader); + + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + ++_lineNumber; + + if (string.IsNullOrWhiteSpace(line) == false) + { + ProcessLine(line); + } + } + + return _root; + } + + #region Private methods + private void ProcessLine(string text) + { + SgmlParseResult? parseResult = TryParseLine(text); + if (parseResult == null) + { + throw new SgmlParseException("Invalid OFX SGML, line " + _lineNumber + "."); + } + else + { + switch (parseResult.TagType) + { + case SgmlTagType.OpeningTag: + ProcessOpeningTag(parseResult.Tag, text); + break; + case SgmlTagType.ValueTag: + ProcessValueTag(parseResult.Tag, parseResult.Value, text); + break; + case SgmlTagType.ClosingTag: + ProcessClosingTag(parseResult.Tag); + break; + default: + break; + } + } + } + + private void ProcessOpeningTag(string tag, string text) + { + if (_root == SgmlElement.Empty) + { + _root = new SgmlElement(tag, text); + _currentNode = _root; + } + else + { + _currentNode = _currentNode.AddChild(new SgmlElement(tag, text, _currentNode)); + } + } + + private void ProcessValueTag(string tag, string? value, string text) + { + value = GetValue(value); + + _currentNode.AddChild(new SgmlElement(tag, value, text, _currentNode)); + } + + private void ProcessClosingTag(string tag) + { + string expectedTag = _currentNode.Name; + if (string.Equals(expectedTag, tag, StringComparison.CurrentCultureIgnoreCase) == false) + { + throw new SgmlParseException($"Closing tag '{tag}' does not match opening tag '{expectedTag}', line {_lineNumber}."); + } + + _currentNode = _currentNode.Parent ?? _root; + } + + private SgmlParseResult? TryParseLine(string line) + { + SgmlParseResult? result = TryParseOpeningTag(line); + if (result == default) + { + result = TryParseClosingTag(line); + } + if (result == default) + { + result = TryParseValueFullTag(line); + } + if (result == default) + { + result = TryParseValuePartialTag(line); + } + return result; + } + + private SgmlParseResult? TryParseOpeningTag(string line) + { + SgmlParseResult? result = default; + + Match match = SgmlConstants.OpeningTagRegex.Match(line); + if (match.Success && match.Groups.Count == 2) + { + result = new SgmlParseResult(SgmlTagType.OpeningTag, match.Groups[1].Value); + } + return result; + } + + private SgmlParseResult? TryParseClosingTag(string line) + { + SgmlParseResult? result = default; + + Match match = SgmlConstants.ClosingTagRegex.Match(line); + if (match.Success && match.Groups.Count == 2) + { + result = new SgmlParseResult(SgmlTagType.ClosingTag, match.Groups[1].Value); + } + return result; + } + + private SgmlParseResult? TryParseValueFullTag(string line) + { + SgmlParseResult? result = default; + + Match match = SgmlConstants.ValueFullTagRegex.Match(line); + if (match.Success && match.Groups.Count == 4) + { + result = new SgmlParseResult(SgmlTagType.ValueTag, match.Groups[1].Value, match.Groups[2].Value); + } + return result; + } + + private SgmlParseResult? TryParseValuePartialTag(string line) + { + SgmlParseResult? result = default; + + Match match = SgmlConstants.ValuePartialTagRegex.Match(line); + if (match.Success && match.Groups.Count == 3) + { + result = new SgmlParseResult(SgmlTagType.ValueTag, match.Groups[1].Value, match.Groups[2].Value); + } + return result; + } + + private string? GetValue(string? value) + { + return WebUtility.HtmlDecode(value); + } + + #endregion +} diff --git a/src/OfxNet/Sgml/SgmlTagType.cs b/src/OfxNet/Sgml/SgmlTagType.cs new file mode 100644 index 0000000..a9b693e --- /dev/null +++ b/src/OfxNet/Sgml/SgmlTagType.cs @@ -0,0 +1,8 @@ +namespace OfxNet; + +internal enum SgmlTagType +{ + OpeningTag, + ValueTag, + ClosingTag +} diff --git a/src/OfxNet/Xml/XElementAdapter.cs b/src/OfxNet/Xml/XElementAdapter.cs new file mode 100644 index 0000000..d450aef --- /dev/null +++ b/src/OfxNet/Xml/XElementAdapter.cs @@ -0,0 +1,39 @@ +namespace OfxNet; + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Xml.Linq; + +/// +/// The XElementAdapter provides a IOfxElement interface for built-in XElement objects. +/// +public readonly struct XElementAdapter : IOfxElement +{ + private readonly XElement _element; + + public XElementAdapter(XElement element) + { + _element = element; + } + + public string Value => _element.Value; + + IOfxElement? IOfxElement.Element(string name, StringComparer comparer) + { + XElement? element = (from e in _element.Elements() + where comparer.Equals(name, e.Name.LocalName) + select e) + .FirstOrDefault(); + + return (element is null) ? null : new XElementAdapter(element); + } + + public IEnumerable Elements(string name, StringComparer comparer) + { + return from element in _element.Elements() + where comparer.Equals(name, element.Name.LocalName) + select new XElementAdapter(element) as IOfxElement; + } +} diff --git a/test/OfxNet.IntegrationTests/OfxDocumentTests.cs b/test/OfxNet.IntegrationTests/OfxDocumentTests.cs new file mode 100644 index 0000000..cab1dee --- /dev/null +++ b/test/OfxNet.IntegrationTests/OfxDocumentTests.cs @@ -0,0 +1,102 @@ +namespace OfxNet.IntegrationTests; + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using Microsoft; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class OfxDocumentTests +{ + public static IEnumerable SampleOfxFiles + { + get + { + yield return new object[] { "SampleBankStatement-1.ofx", 1, 3 }; + yield return new object[] { "SampleBankStatement-2.ofx", 1, 2 }; + yield return new object[] { "SampleCreditCardStatement.ofx", 1, 1 }; + yield return new object[] { "SampleMultiStatement.ofx", 2, 3 }; + yield return new object[] { "SampleSignOnResponse.ofx", 0, 0 }; + yield return new object[] { "Sample-itau.ofx", 1, 3 }; + yield return new object[] { "Sample-santander.ofx", 1, 3 }; + yield return new object[] { "Sample-Banco do Brasil.ofx", 1, 3 }; + } + } + + [TestInitialize] + public void Setup() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + [DataTestMethod] + [DynamicData(nameof(SampleOfxFiles), DynamicDataSourceType.Property)] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Required for testing.")] + public void OfxDocumentLoad_Succeeds(string path, int statementCount, int txCount) + { + var actual = OfxDocument.Load(path); + Assert.IsNotNull(actual); + } + + [DataTestMethod] + [DynamicData(nameof(SampleOfxFiles), DynamicDataSourceType.Property)] + public void OfxDocumentLoad_GetStatements_ReturnsCorrectNumberOfStatementsAndTransactions(string path, int statementCount, int txCount) + { + var actual = OfxDocument.Load(path); + Assert.IsNotNull(actual); + + IEnumerable allStatements = actual.GetStatements(); + Assert.AreEqual(statementCount, allStatements.Count()); + + IEnumerable allTransactions = allStatements.SelectMany(s => s.TransactionList!.Transactions); + Assert.AreEqual(txCount, allTransactions.Count()); + } + + [TestMethod] + public void CanParseItau() + { + IEnumerable actual = OfxDocument.Load(@"Sample-itau.ofx") + .GetStatements(); + + OfxStatement statement = actual.First(); + Assert.IsInstanceOfType(statement, typeof(OfxBankStatement)); + OfxBankStatement? bankStatement = statement as OfxBankStatement; + Assert.IsNotNull(bankStatement); + Assert.IsNotNull(bankStatement.Account); + + Assert.AreEqual("9999 99999-9", bankStatement.Account.AccountNumber); + Assert.AreEqual("0341", bankStatement.Account.BankId); + + Assert.IsNotNull(statement.TransactionList); + Assert.AreEqual(3, statement.TransactionList.Transactions.Count); + CollectionAssert.AreEqual( + statement.TransactionList.Transactions.Select(x => x.Memo).ToArray(), + new string[] { "RSHOP", "REND PAGO APLIC AUT MAIS", "SISDEB" }); + } + + [TestMethod] + public void CanParseBancoDoBrasil() + { + IEnumerable actual = OfxDocument.Load("Sample-Banco do Brasil.ofx") + .GetStatements(); + + OfxStatement statement = actual.First(); + Assert.IsInstanceOfType(statement, typeof(OfxBankStatement)); + var bankStatement = statement as OfxBankStatement; + Assert.IsNotNull(bankStatement); + Assert.IsNotNull(bankStatement.Account); + + Assert.AreEqual(bankStatement.Account.AccountNumber, "99999-9"); + Assert.AreEqual(bankStatement.Account.BranchId, "9999-9"); + Assert.AreEqual(bankStatement.Account.BankId, "1"); + + Assert.IsNotNull(statement.TransactionList); + Assert.AreEqual(3, statement.TransactionList.Transactions.Count); + + CollectionAssert.AreEqual( + statement.TransactionList.Transactions.Select(x => x.Memo).ToArray(), + new string[] { "Transferência Agendada", "Compra com Cartão", "Saque" }); + } +} diff --git a/test/OfxNet.IntegrationTests/OfxNet.IntegrationTests.csproj b/test/OfxNet.IntegrationTests/OfxNet.IntegrationTests.csproj new file mode 100644 index 0000000..4a3711e --- /dev/null +++ b/test/OfxNet.IntegrationTests/OfxNet.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + false + + + + + + + + + + + + + + + + Always + + + + diff --git a/OfxNet.IntegrationTests/Sample-Banco do Brasil.ofx b/test/OfxNet.IntegrationTests/Sample-Banco do Brasil.ofx similarity index 100% rename from OfxNet.IntegrationTests/Sample-Banco do Brasil.ofx rename to test/OfxNet.IntegrationTests/Sample-Banco do Brasil.ofx diff --git a/OfxNet.IntegrationTests/Sample-itau.ofx b/test/OfxNet.IntegrationTests/Sample-itau.ofx similarity index 100% rename from OfxNet.IntegrationTests/Sample-itau.ofx rename to test/OfxNet.IntegrationTests/Sample-itau.ofx diff --git a/OfxNet.IntegrationTests/Sample-santander.ofx b/test/OfxNet.IntegrationTests/Sample-santander.ofx similarity index 100% rename from OfxNet.IntegrationTests/Sample-santander.ofx rename to test/OfxNet.IntegrationTests/Sample-santander.ofx diff --git a/OfxNet.IntegrationTests/SampleBankStatement-1.ofx b/test/OfxNet.IntegrationTests/SampleBankStatement-1.ofx similarity index 100% rename from OfxNet.IntegrationTests/SampleBankStatement-1.ofx rename to test/OfxNet.IntegrationTests/SampleBankStatement-1.ofx diff --git a/OfxNet.IntegrationTests/SampleBankStatement-2.ofx b/test/OfxNet.IntegrationTests/SampleBankStatement-2.ofx similarity index 100% rename from OfxNet.IntegrationTests/SampleBankStatement-2.ofx rename to test/OfxNet.IntegrationTests/SampleBankStatement-2.ofx diff --git a/OfxNet.IntegrationTests/SampleCreditCardStatement.ofx b/test/OfxNet.IntegrationTests/SampleCreditCardStatement.ofx similarity index 100% rename from OfxNet.IntegrationTests/SampleCreditCardStatement.ofx rename to test/OfxNet.IntegrationTests/SampleCreditCardStatement.ofx diff --git a/OfxNet.IntegrationTests/SampleMultiStatement.ofx b/test/OfxNet.IntegrationTests/SampleMultiStatement.ofx similarity index 100% rename from OfxNet.IntegrationTests/SampleMultiStatement.ofx rename to test/OfxNet.IntegrationTests/SampleMultiStatement.ofx diff --git a/OfxNet.IntegrationTests/SampleSignOnResponse.ofx b/test/OfxNet.IntegrationTests/SampleSignOnResponse.ofx similarity index 100% rename from OfxNet.IntegrationTests/SampleSignOnResponse.ofx rename to test/OfxNet.IntegrationTests/SampleSignOnResponse.ofx diff --git a/test/OfxNet.UnitTests/OfxNet.UnitTests.csproj b/test/OfxNet.UnitTests/OfxNet.UnitTests.csproj new file mode 100644 index 0000000..661d984 --- /dev/null +++ b/test/OfxNet.UnitTests/OfxNet.UnitTests.csproj @@ -0,0 +1,17 @@ + + + + false + + + + + + + + + + + + + diff --git a/test/OfxNet.UnitTests/OfxParserTests.cs b/test/OfxNet.UnitTests/OfxParserTests.cs new file mode 100644 index 0000000..4607ee6 --- /dev/null +++ b/test/OfxNet.UnitTests/OfxParserTests.cs @@ -0,0 +1,105 @@ +namespace OfxNet.UnitTests; + +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class OfxParserTests +{ + [TestMethod] + public void ParseInteger_WithNull_ReturnsExpectedValue() + { + (bool NullOrWhiteSpace, bool NotInteger, int Value) actual = OfxParser.ParseInteger(null); + + Assert.AreEqual((true, false, default(int)), actual); + } + + [DataTestMethod] + [DynamicData(nameof(IntegerParserTestData), DynamicDataSourceType.Property)] + public void ParseInteger_WithString_ReturnsExpectedValue(string str, (bool NullOrEmpty, bool NotInteger, int Value) expected) + { + (bool NullOrWhiteSpace, bool NotInteger, int Value) actual = OfxParser.ParseInteger(str); + + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void ParseDecimal_WithNull_ReturnsExpectedValue() + { + (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) actual = OfxParser.ParseDecimal(null); + + Assert.AreEqual((true, false, default(decimal)), actual); + } + + [DataTestMethod] + [DynamicData(nameof(DecimalParserTestData), DynamicDataSourceType.Property)] + public void ParseDecimal_WithString_ReturnsExpectedValue(string str, (bool NullOrEmpty, bool NotDecimal, decimal Value) expected) + { + (bool NullOrWhiteSpace, bool NotDecimal, decimal Value) actual = OfxParser.ParseDecimal(str); + + Assert.AreEqual(expected, actual); + } + + [DataTestMethod] + [DynamicData(nameof(OfxDateTimeTestData), DynamicDataSourceType.Property)] + public void ParseDateTime_WithValidString_ReturnsCorrectDateTime(string str, DateTimeOffset expected) + { + DateTimeOffset actual = OfxParser.ParseDateTime(str); + + Assert.AreEqual(expected, actual); + } + + [DataTestMethod] + [DynamicData(nameof(OfxAccountTypeTestData), DynamicDataSourceType.Property)] + public void ParseOfxAccountType_WithValidString_ReturnsExpectedValue(string str, OfxAccountType expected) + { + OfxAccountType actual = OfxParser.ParseAccountType(str); + + Assert.AreEqual(expected, actual); + } + + private static IEnumerable IntegerParserTestData + { + get + { + yield return new object[] { string.Empty, (true, false, default(int)) }; + yield return new object[] { " ", (true, false, default(int)) }; + yield return new object[] { "Forty One", (false, true, default(int)) }; + yield return new object[] { "41.98", (false, true, default(int)) }; + yield return new object[] { "48", (false, false, 48) }; + } + } + + private static IEnumerable DecimalParserTestData + { + get + { + yield return new object[] { string.Empty, (true, false, default(decimal)) }; + yield return new object[] { " ", (true, false, default(decimal)) }; + yield return new object[] { "Forty One", (false, true, default(decimal)) }; + yield return new object[] { "41.98", (false, false, 41.98M) }; + } + } + + private static IEnumerable OfxAccountTypeTestData + { + get + { + yield return new object?[] { "MONEYMRKT", OfxAccountType.MONEYMRKT }; + yield return new object?[] { null, OfxAccountType.NotSet }; + yield return new object?[] { "NotValid", OfxAccountType.NotSet }; + } + } + + private static IEnumerable OfxDateTimeTestData + { + get + { + yield return new object[] { "20150602100000.000[-4:EDT]", new DateTimeOffset(2015, 6, 2, 10, 0, 0, new TimeSpan(-4, 0, 0)) }; + yield return new object[] { "199610291120", new DateTimeOffset(1996, 10, 29, 11, 20, 0, new TimeSpan(0, 0, 0)) }; + yield return new object[] { "19961005132200.124[-5:EST]", new DateTimeOffset(1996, 10, 5, 13, 22, 0, 124, new TimeSpan(-5, 0, 0)) }; + yield return new object[] { "20131205100000[-03:EST]", new DateTimeOffset(2013, 12, 5, 10, 0, 0, new TimeSpan(-3, 0, 0)) }; + } + } +} diff --git a/test/OfxNet.UnitTests/SgmlOfxElementTests.cs b/test/OfxNet.UnitTests/SgmlOfxElementTests.cs new file mode 100644 index 0000000..58be468 --- /dev/null +++ b/test/OfxNet.UnitTests/SgmlOfxElementTests.cs @@ -0,0 +1,30 @@ +namespace OfxNet.UnitTests; + +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class SgmlOfxElementTests +{ + [TestMethod] + public void GetRequiredChildElement_AndChildExists_Succeeds() + { + SgmlElement sut = new("OFX", ""); + SgmlElement expected = sut.AddChild(new SgmlElement("Exists", string.Empty, sut)); + + IOfxElement? actual = sut.Element("Exists", StringComparer.OrdinalIgnoreCase); + + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void GetRequiredChildElement_AndChildDoesNotExist_ReturnsNull() + { + SgmlElement sut = new("OFX", ""); + _ = sut.AddChild(new SgmlElement("Exists", string.Empty, sut)); + + IOfxElement? actual = sut.Element("NotExists", StringComparer.OrdinalIgnoreCase); + + Assert.IsNull(actual); + } +}