From 08dafca8030990c4c25c37666b5ecd7d2568c832 Mon Sep 17 00:00:00 2001 From: "Shah, Ankur" Date: Tue, 23 Dec 2025 20:28:45 -0600 Subject: [PATCH 1/5] Updated to handle differnet headers for Trading and IRA Account from Fidelity --- src/CsvToOfx.Core/CsvToOfx.Core.csproj | 1 + src/CsvToOfx.Core/Models/CanonicalField.cs | 23 ++++ src/CsvToOfx.Core/Models/HeaderMap.cs | 8 ++ .../Parsing/ActionResolverAdapter.cs | 19 ++++ .../HeaderMaps/FidelityIraHeaderMap.cs | 28 +++++ .../HeaderMaps/FidelityTradingHeaderMap.cs | 32 ++++++ src/CsvToOfx.Core/Parsing/IActionResolver.cs | 9 ++ .../Parsing/ISecurityResolver.cs | 10 ++ .../Parsing/TransactionCsvParser.cs | 105 ++++++++++++++++++ .../Services/SecurityResolver.cs | 13 ++- src/CsvToOfx.Parsers/Shared/CsvRowReader.cs | 9 +- 11 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/CsvToOfx.Core/Models/CanonicalField.cs create mode 100644 src/CsvToOfx.Core/Models/HeaderMap.cs create mode 100644 src/CsvToOfx.Core/Parsing/ActionResolverAdapter.cs create mode 100644 src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityIraHeaderMap.cs create mode 100644 src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityTradingHeaderMap.cs create mode 100644 src/CsvToOfx.Core/Parsing/IActionResolver.cs create mode 100644 src/CsvToOfx.Core/Parsing/ISecurityResolver.cs create mode 100644 src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs diff --git a/src/CsvToOfx.Core/CsvToOfx.Core.csproj b/src/CsvToOfx.Core/CsvToOfx.Core.csproj index 1d4a2ec..87a0655 100644 --- a/src/CsvToOfx.Core/CsvToOfx.Core.csproj +++ b/src/CsvToOfx.Core/CsvToOfx.Core.csproj @@ -9,6 +9,7 @@ + diff --git a/src/CsvToOfx.Core/Models/CanonicalField.cs b/src/CsvToOfx.Core/Models/CanonicalField.cs new file mode 100644 index 0000000..ca4c03e --- /dev/null +++ b/src/CsvToOfx.Core/Models/CanonicalField.cs @@ -0,0 +1,23 @@ +namespace CsvToOfx.Core.Models; + +public enum CanonicalField +{ + TradeDate, + Action, + Symbol, + Description, + Type, + ExchangeQuantity, + ExchangeCurrency, + Currency, + Price, + Quantity, + ExchangeRate, + Commission, + Fees, + AccruedInterest, + Amount, + CashBalance, + SettlementDate +} + diff --git a/src/CsvToOfx.Core/Models/HeaderMap.cs b/src/CsvToOfx.Core/Models/HeaderMap.cs new file mode 100644 index 0000000..5c85d96 --- /dev/null +++ b/src/CsvToOfx.Core/Models/HeaderMap.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace CsvToOfx.Core.Models; + +public sealed record HeaderMap( + string Name, + IReadOnlyDictionary Columns +); diff --git a/src/CsvToOfx.Core/Parsing/ActionResolverAdapter.cs b/src/CsvToOfx.Core/Parsing/ActionResolverAdapter.cs new file mode 100644 index 0000000..34dcccc --- /dev/null +++ b/src/CsvToOfx.Core/Parsing/ActionResolverAdapter.cs @@ -0,0 +1,19 @@ +using CsvToOfx.Core.Models; + +namespace CsvToOfx.Core.Parsing; + +public sealed class ActionResolverAdapter : IActionResolver +{ + public CanonicalAction Resolve(string? actionText) + { + var a = (actionText ?? string.Empty).Trim().ToLowerInvariant(); + if (a.Contains("transfer") || a.Contains("transferred")) return CanonicalAction.CashTransfer; + if (a.Contains("reverse split") || a.Contains("stock split") || a.Contains("split r/s") || a.Contains("r/s to") || a.Contains("r/s from")) return CanonicalAction.StockSplit; + if (a.Contains("dividend") || a.Contains("interest") || a.Contains("return of capital") || a.Contains("in lieu")) return CanonicalAction.Income; + if (a.Contains("bought") || a.Contains("buy") || a.Contains("purchase") || a.Contains("reinvestment")) return CanonicalAction.BuyStock; + if (a.Contains("sold") || a.Contains("sell")) return CanonicalAction.SellStock; + if (a.Contains("fee charged") || a.Contains("adr fee") || a.Contains("adr pass-through fee") + || a.Contains("foreign tax paid") || a.Contains("withholding tax") || a.Contains("tax withheld")) return CanonicalAction.MiscExpense; + return CanonicalAction.CashTransfer; + } +} diff --git a/src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityIraHeaderMap.cs b/src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityIraHeaderMap.cs new file mode 100644 index 0000000..5585f29 --- /dev/null +++ b/src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityIraHeaderMap.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using CsvToOfx.Core.Models; + +namespace CsvToOfx.Core.Parsing.HeaderMaps; + +public static class FidelityIraHeaderMap +{ + public static HeaderMap Instance { get; } = new HeaderMap( + "Fidelity-IRA", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Run Date"] = CanonicalField.TradeDate, + ["Action"] = CanonicalField.Action, + ["Symbol"] = CanonicalField.Symbol, + ["Description"] = CanonicalField.Description, + ["Type"] = CanonicalField.Type, + ["Price ($)"] = CanonicalField.Price, + ["Quantity"] = CanonicalField.Quantity, + ["Commission ($)"] = CanonicalField.Commission, + ["Fees ($)"] = CanonicalField.Fees, + ["Accrued Interest ($)"] = CanonicalField.AccruedInterest, + ["Amount ($)"] = CanonicalField.Amount, + ["Cash Balance ($)"] = CanonicalField.CashBalance, + ["Settlement Date"] = CanonicalField.SettlementDate + }); +} + diff --git a/src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityTradingHeaderMap.cs b/src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityTradingHeaderMap.cs new file mode 100644 index 0000000..7a30ab5 --- /dev/null +++ b/src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityTradingHeaderMap.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using CsvToOfx.Core.Models; + +namespace CsvToOfx.Core.Parsing.HeaderMaps; + +public static class FidelityTradingHeaderMap +{ + public static HeaderMap Instance { get; } = new HeaderMap( + "Fidelity-Trading", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Run Date"] = CanonicalField.TradeDate, + ["Action"] = CanonicalField.Action, + ["Symbol"] = CanonicalField.Symbol, + ["Description"] = CanonicalField.Description, + ["Type"] = CanonicalField.Type, + ["Exchange Quantity"] = CanonicalField.ExchangeQuantity, + ["Exchange Currency"] = CanonicalField.ExchangeCurrency, + ["Currency"] = CanonicalField.Currency, + ["Price"] = CanonicalField.Price, + ["Quantity"] = CanonicalField.Quantity, + ["Exchange Rate"] = CanonicalField.ExchangeRate, + ["Commission"] = CanonicalField.Commission, + ["Fees"] = CanonicalField.Fees, + ["Accrued Interest"] = CanonicalField.AccruedInterest, + ["Amount"] = CanonicalField.Amount, + ["Cash Balance"] = CanonicalField.CashBalance, + ["Settlement Date"] = CanonicalField.SettlementDate + }); +} + diff --git a/src/CsvToOfx.Core/Parsing/IActionResolver.cs b/src/CsvToOfx.Core/Parsing/IActionResolver.cs new file mode 100644 index 0000000..f5e8143 --- /dev/null +++ b/src/CsvToOfx.Core/Parsing/IActionResolver.cs @@ -0,0 +1,9 @@ +using CsvToOfx.Core.Models; + +namespace CsvToOfx.Core.Parsing; + +public interface IActionResolver +{ + CanonicalAction Resolve(string? actionText); +} + diff --git a/src/CsvToOfx.Core/Parsing/ISecurityResolver.cs b/src/CsvToOfx.Core/Parsing/ISecurityResolver.cs new file mode 100644 index 0000000..d7af9c7 --- /dev/null +++ b/src/CsvToOfx.Core/Parsing/ISecurityResolver.cs @@ -0,0 +1,10 @@ +using CsvToOfx.Core.Models; + +namespace CsvToOfx.Core.Parsing; + +public interface ISecurityResolver +{ + SecurityRef? Resolve(string symbol); + SecurityRef? ResolveFromRow(IDictionary row); +} + diff --git a/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs b/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs new file mode 100644 index 0000000..90b3764 --- /dev/null +++ b/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using CsvHelper; +using CsvHelper.Configuration; +using CsvToOfx.Core.Models; + +namespace CsvToOfx.Core.Parsing; + +public sealed class TransactionCsvParser +{ + private readonly HeaderMap _headerMap; + private readonly ISecurityResolver _securityResolver; + private readonly IActionResolver _actionResolver; + + public TransactionCsvParser( + HeaderMap headerMap, + ISecurityResolver securityResolver, + IActionResolver actionResolver) + { + _headerMap = headerMap; + _securityResolver = securityResolver; + _actionResolver = actionResolver; + } + + public IEnumerable Parse(TextReader reader) + { + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + IgnoreBlankLines = true, + BadDataFound = null, + TrimOptions = TrimOptions.Trim + }); + + if (!csv.Read() || !csv.ReadHeader()) + yield break; + + var indexMap = BuildIndexMap(csv.HeaderRecord); + if (indexMap.Count == 0) + yield break; + + while (csv.Read()) + { + if (string.IsNullOrWhiteSpace(csv.Parser.RawRecord)) + continue; + + DateTime tradeDate = csv.GetField(indexMap[CanonicalField.TradeDate]); + var action = _actionResolver.Resolve(csv.GetField(indexMap[CanonicalField.Action])); + var security = ResolveSecurity(csv, indexMap); + decimal? units = GetOptional(csv, indexMap, CanonicalField.Quantity); + decimal? price = GetOptional(csv, indexMap, CanonicalField.Price); + decimal amount = csv.GetField(indexMap[CanonicalField.Amount]); + string currency = GetOptional(csv, indexMap, CanonicalField.Currency) ?? "USD"; + string? memo = GetOptional(csv, indexMap, CanonicalField.Description); + decimal? fees = GetOptional(csv, indexMap, CanonicalField.Fees); + string? fitId = null; + + yield return new NormalizedTransaction( + tradeDate, + action, + security, + units, + price, + amount, + currency, + memo, + fees, + fitId); + } + } + + private Dictionary BuildIndexMap(string[] headerRecord) + { + var indexMap = new Dictionary(); + for (int i = 0; i < headerRecord.Length; i++) + { + var header = headerRecord[i].Trim(); + if (_headerMap.Columns.TryGetValue(header, out var field)) + indexMap[field] = i; + } + return indexMap; + } + + private T? GetOptional(CsvReader csv, Dictionary indexMap, CanonicalField field) + { + if (!indexMap.TryGetValue(field, out var idx)) + return default; + var raw = csv.GetField(idx); + if (string.IsNullOrWhiteSpace(raw)) + return default; + return csv.GetField(idx); + } + + private SecurityRef? ResolveSecurity(CsvReader csv, Dictionary indexMap) + { + if (!indexMap.TryGetValue(CanonicalField.Symbol, out var idx)) + return null; + var symbol = csv.GetField(idx); + return _securityResolver.Resolve(symbol); + } +} + diff --git a/src/CsvToOfx.Core/Services/SecurityResolver.cs b/src/CsvToOfx.Core/Services/SecurityResolver.cs index 140eda3..2b03b9d 100644 --- a/src/CsvToOfx.Core/Services/SecurityResolver.cs +++ b/src/CsvToOfx.Core/Services/SecurityResolver.cs @@ -1,9 +1,10 @@ // C# using CsvToOfx.Core.Models; +using CsvToOfx.Core.Parsing; namespace CsvToOfx.Core.Services { - public sealed class SecurityResolver + public sealed class SecurityResolver : ISecurityResolver { private readonly bool _preferCusip; @@ -41,6 +42,16 @@ public SecurityResolver(bool preferCusip = true) return new SecurityRef(symbol, idType, displayName, tickerField); } + public SecurityRef? Resolve(string symbol) + { + if (string.IsNullOrWhiteSpace(symbol)) return null; + var row = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Symbol"] = symbol + }; + return ResolveFromRow(row); + } + private static bool LooksLikeCusip(string value) { if (value.Length != 9) return false; diff --git a/src/CsvToOfx.Parsers/Shared/CsvRowReader.cs b/src/CsvToOfx.Parsers/Shared/CsvRowReader.cs index 8397dfc..d186711 100644 --- a/src/CsvToOfx.Parsers/Shared/CsvRowReader.cs +++ b/src/CsvToOfx.Parsers/Shared/CsvRowReader.cs @@ -8,7 +8,7 @@ public sealed class CsvRowReader private readonly CsvConfiguration _conf = new(CultureInfo.InvariantCulture) { HasHeaderRecord = true, - DetectColumnCountChanges = true, + DetectColumnCountChanges = false, BadDataFound = null, TrimOptions = TrimOptions.Trim }; @@ -21,9 +21,16 @@ public sealed class CsvRowReader var seenData = false; while (csvr.Read()) { + var colCount = csvr.Parser.Count; + if (colCount == 0) continue; // skip empty physical rows + var dict = csvr.GetRecord() as IDictionary; if (dict is null) continue; + // drop rows that don't match header shape (e.g., disclaimers/footers) + if (csvr.HeaderRecord is { Length: > 0 } && colCount < csvr.HeaderRecord.Length) + continue; + var converted = dict.ToDictionary(k => k.Key, v => v.Value?.ToString()); var nonEmpty = converted.Values.Count(v => !string.IsNullOrWhiteSpace(v)); From 35e4ba6afe7173d35125e3f3a57755da319699cd Mon Sep 17 00:00:00 2001 From: "Shah, Ankur" Date: Wed, 24 Dec 2025 06:35:06 -0600 Subject: [PATCH 2/5] Since we are targetting multiple framework, provide target framework to net8.0 in dotnet publish --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 42ccdc4..c7101c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: fi echo "VERSION=$VERSION" >> "$GITHUB_ENV" - name: Publish self-contained single file - run: dotnet publish src/CsvToOfx.Cli/CsvToOfx.Cli.csproj -c Release -r ${{ matrix.rid }} --self-contained true /p:PublishSingleFile=true /p:IncludeAllContentForSelfExtract=true /p:Version=${{ env.VERSION }} + run: dotnet publish src/CsvToOfx.Cli/CsvToOfx.Cli.csproj -c Release -r ${{ matrix.rid }} --self-contained true --framework net8.0 /p:PublishSingleFile=true /p:IncludeAllContentForSelfExtract=true /p:Version=${{ env.VERSION }} - name: Archive artifact run: | cd src/CsvToOfx.Cli/bin/Release/net8.0/${{ matrix.rid }}/publish From 2db43a953d3c571926f90b8cd69ca85d6523ffdf Mon Sep 17 00:00:00 2001 From: "Shah, Ankur" Date: Wed, 24 Dec 2025 06:40:52 -0600 Subject: [PATCH 3/5] New Workflow file to test commits on Feature branches. Reneamed build.yml to publish.yml --- .github/workflows/build-test.yml | 30 ++++++++++++++++++++ .github/workflows/{build.yml => publish.yml} | 0 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/build-test.yml rename .github/workflows/{build.yml => publish.yml} (100%) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..18c707a --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,30 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "feature/*" ] + +env: + VERSION: 0.0.0-local + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read # default is read; fine for checkout/build/test + actions: read + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore + run: dotnet restore + - name: Build + run: dotnet build --no-restore -c Release + - name: Test + run: dotnet test -c Release --no-build --verbosity normal \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/publish.yml similarity index 100% rename from .github/workflows/build.yml rename to .github/workflows/publish.yml From 610dc53b0b3ea0ed1e4bf50f4504118bd592cfbe Mon Sep 17 00:00:00 2001 From: "Shah, Ankur" Date: Wed, 24 Dec 2025 06:45:33 -0600 Subject: [PATCH 4/5] Handle Possible Null reference --- .../Parsing/TransactionCsvParser.cs | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs b/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs index 90b3764..63d71cc 100644 --- a/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs +++ b/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs @@ -38,37 +38,40 @@ public IEnumerable Parse(TextReader reader) if (!csv.Read() || !csv.ReadHeader()) yield break; - var indexMap = BuildIndexMap(csv.HeaderRecord); - if (indexMap.Count == 0) - yield break; - - while (csv.Read()) + if (csv.HeaderRecord != null) { - if (string.IsNullOrWhiteSpace(csv.Parser.RawRecord)) - continue; + var indexMap = BuildIndexMap(csv.HeaderRecord); + if (indexMap.Count == 0) + yield break; + + while (csv.Read()) + { + if (string.IsNullOrWhiteSpace(csv.Parser.RawRecord)) + continue; - DateTime tradeDate = csv.GetField(indexMap[CanonicalField.TradeDate]); - var action = _actionResolver.Resolve(csv.GetField(indexMap[CanonicalField.Action])); - var security = ResolveSecurity(csv, indexMap); - decimal? units = GetOptional(csv, indexMap, CanonicalField.Quantity); - decimal? price = GetOptional(csv, indexMap, CanonicalField.Price); - decimal amount = csv.GetField(indexMap[CanonicalField.Amount]); - string currency = GetOptional(csv, indexMap, CanonicalField.Currency) ?? "USD"; - string? memo = GetOptional(csv, indexMap, CanonicalField.Description); - decimal? fees = GetOptional(csv, indexMap, CanonicalField.Fees); - string? fitId = null; + DateTime tradeDate = csv.GetField(indexMap[CanonicalField.TradeDate]); + var action = _actionResolver.Resolve(csv.GetField(indexMap[CanonicalField.Action])); + var security = ResolveSecurity(csv, indexMap); + decimal? units = GetOptional(csv, indexMap, CanonicalField.Quantity); + decimal? price = GetOptional(csv, indexMap, CanonicalField.Price); + decimal amount = csv.GetField(indexMap[CanonicalField.Amount]); + string currency = GetOptional(csv, indexMap, CanonicalField.Currency) ?? "USD"; + string? memo = GetOptional(csv, indexMap, CanonicalField.Description); + decimal? fees = GetOptional(csv, indexMap, CanonicalField.Fees); + string? fitId = null; - yield return new NormalizedTransaction( - tradeDate, - action, - security, - units, - price, - amount, - currency, - memo, - fees, - fitId); + yield return new NormalizedTransaction( + tradeDate, + action, + security, + units, + price, + amount, + currency, + memo, + fees, + fitId); + } } } @@ -99,7 +102,13 @@ private Dictionary BuildIndexMap(string[] headerRecord) if (!indexMap.TryGetValue(CanonicalField.Symbol, out var idx)) return null; var symbol = csv.GetField(idx); - return _securityResolver.Resolve(symbol); + if (symbol != null) + return _securityResolver.Resolve(symbol); + else + { + return null; + } + } } From ebe4b03ab42a307b4c089c530bb373a377304e86 Mon Sep 17 00:00:00 2001 From: "Shah, Ankur" Date: Wed, 24 Dec 2025 17:57:50 -0600 Subject: [PATCH 5/5] Adding test case for Services --- src/CsvToOfx.Core/Models/CanonicalAction.cs | 10 +- .../CsvToOfx.Core.Tests.csproj | 4 +- tests/CsvToOfx.Core.Tests/ServicesTest.cs | 145 ++++++++++++++++ tests/CsvToOfx.Core.Tests/UnitTest1.cs | 160 +++++++++++++++++- 4 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 tests/CsvToOfx.Core.Tests/ServicesTest.cs diff --git a/src/CsvToOfx.Core/Models/CanonicalAction.cs b/src/CsvToOfx.Core/Models/CanonicalAction.cs index 9ef0f9a..7a113c3 100644 --- a/src/CsvToOfx.Core/Models/CanonicalAction.cs +++ b/src/CsvToOfx.Core/Models/CanonicalAction.cs @@ -1,2 +1,10 @@ namespace CsvToOfx.Core.Models; -public enum CanonicalAction { Income, BuyStock, SellStock, StockSplit, MiscExpense, CashTransfer } \ No newline at end of file +public enum CanonicalAction { + Income, + BuyStock, + SellStock, + Reinvest, + StockSplit, + CashTransfer, + MiscExpense +} diff --git a/tests/CsvToOfx.Core.Tests/CsvToOfx.Core.Tests.csproj b/tests/CsvToOfx.Core.Tests/CsvToOfx.Core.Tests.csproj index b8ae637..41afe2f 100644 --- a/tests/CsvToOfx.Core.Tests/CsvToOfx.Core.Tests.csproj +++ b/tests/CsvToOfx.Core.Tests/CsvToOfx.Core.Tests.csproj @@ -4,9 +4,11 @@ net8.0 enable enable - false true + true + lcov + ../TestResults/coverage.info diff --git a/tests/CsvToOfx.Core.Tests/ServicesTest.cs b/tests/CsvToOfx.Core.Tests/ServicesTest.cs new file mode 100644 index 0000000..2174926 --- /dev/null +++ b/tests/CsvToOfx.Core.Tests/ServicesTest.cs @@ -0,0 +1,145 @@ +using CsvToOfx.Core.Models; +using CsvToOfx.Core.Services; +using FluentAssertions; +using System; +using System.Collections.Generic; +using System.IO; + +namespace CsvToOfx.Core.Tests; + +public class AmountParserTests +{ + private readonly AmountParser _parser = new(); + + [Theory] + [InlineData("1,234.56", 1234.56)] + [InlineData("-78.90", 78.90)] + [InlineData("100", 100.0)] + public void ParseAbsOrNull_ReturnsAbsoluteValue_ForValidStrings(string input, decimal expected) + { + _parser.ParseAbsOrNull(input).Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseAbsOrNull_ReturnsNull_ForNullOrWhitespace(string? input) + { + _parser.ParseAbsOrNull(input).Should().BeNull(); + } + + [Fact] + public void ParseAbsOrNull_ThrowsFormatException_ForInvalidInput() + { + Action act = () => _parser.ParseAbsOrNull("invalid"); + act.Should().Throw(); + } +} + +public class DateParserTests +{ + private readonly DateParser _parser = new(); + + [Theory] + [InlineData("2025-12-04", 2025, 12, 4)] + [InlineData("12/04/2025", 2025, 12, 4)] + [InlineData("12-04-2025", 2025, 12, 4)] + [InlineData("12/04/25", 2025, 12, 4)] + [InlineData("1/4/25", 2025, 1, 4)] + public void ParseOrNull_ReturnsDate_ForValidFormats(string input, int y, int m, int d) + { + _parser.ParseOrNull(input).Should().Be(new DateTime(y, m, d)); + } + + [Fact] + public void ParseOrNull_ThrowsFormatException_ForInvalidFormat() + { + Action act = () => _parser.ParseOrNull("twentyfive 12 04"); + act.Should().Throw(); + } +} + +public class FitIdGeneratorTests +{ + private readonly FitIdGenerator _generator = new(); + + [Fact] + public void FromSortedRow_IsDeterministic() + { + var row1 = new Dictionary { ["A"] = "1", ["B"] = "2" }; + var row2 = new Dictionary { ["B"] = "2", ["A"] = "1" }; + + var fitId1 = _generator.FromSortedRow(row1); + var fitId2 = _generator.FromSortedRow(row2); + + fitId1.Should().Be(fitId2); + fitId1.Should().HaveLength(12); + } +} + +public class OutputPathServiceTests +{ + private readonly OutputPathService _service = new(); + + [Fact] + public void ResolveOfxPath_GeneratesCorrectPath_WhenNotProvided() + { + var result = _service.ResolveOfxPath("/path/to/file.csv", null); + result.Should().Be(Path.Combine("/path/to", "file.ofx")); + } + + [Fact] + public void ResolveOfxPath_ReturnsProvidedPath_WhenProvided() + { + var result = _service.ResolveOfxPath("/path/to/file.csv", "/another/path.ofx"); + result.Should().Be("/another/path.ofx"); + } +} + +public class SplitRatioParserTests +{ + private readonly SplitRatioParser _parser = new(); + + [Fact] + public void Parse_ReturnsRatio_ForValidString() + { + var ratio = _parser.Parse("2 for 1"); + ratio.Should().Be(new SplitRatio(2, 1)); + } + + [Theory] + [InlineData("1:2")] + [InlineData("abc")] + [InlineData(null)] + public void Parse_ReturnsNull_ForInvalidString(string? input) + { + _parser.Parse(input).Should().BeNull(); + } +} + +public class SubacctResolverTests +{ + private readonly SubacctResolver _resolver = new(); + + [Fact] + public void Resolve_ReturnsCash_ForNormalTransaction() + { + var row = new Dictionary { ["Action"] = "Buy" }; + _resolver.Resolve(row, 100).Should().Be("CASH"); + } + + [Fact] + public void Resolve_ReturnsMargin_WhenTextContainsMargin() + { + var row = new Dictionary { ["Description"] = "stuff on margin" }; + _resolver.Resolve(row, 100).Should().Be("MARGIN"); + } + + [Fact] + public void Resolve_ReturnsShort_ForNegativeUnits() + { + var row = new Dictionary { ["Action"] = "Sell" }; + _resolver.Resolve(row, -50).Should().Be("SHORT"); + } +} \ No newline at end of file diff --git a/tests/CsvToOfx.Core.Tests/UnitTest1.cs b/tests/CsvToOfx.Core.Tests/UnitTest1.cs index 706646c..a971bb2 100644 --- a/tests/CsvToOfx.Core.Tests/UnitTest1.cs +++ b/tests/CsvToOfx.Core.Tests/UnitTest1.cs @@ -1,10 +1,162 @@ -namespace CsvToOfx.Core.Tests; +using CsvToOfx.Core.Models; +using CsvToOfx.Core.Parsing; +using CsvToOfx.Core.Services; +using FluentAssertions; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; -public class UnitTest1 +namespace CsvToOfx.Core.Tests { - [Fact] - public void Test1() + public class TransactionCsvParserTests { + [Fact] + public void Parse_SkipsBlankLines_ReturnsTransactions() + { + var headerMap = new HeaderMap("Test", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Run Date"] = CanonicalField.TradeDate, + ["Action"] = CanonicalField.Action, + ["Symbol"] = CanonicalField.Symbol, + ["Description"] = CanonicalField.Description, + ["Price"] = CanonicalField.Price, + ["Quantity"] = CanonicalField.Quantity, + ["Amount"] = CanonicalField.Amount, + ["Currency"] = CanonicalField.Currency + }); + var csv = "\n" + + "Run Date,Action,Symbol,Description,Price,Quantity,Amount,Currency\n" + + "2025-01-01,Buy,ABC,Alpha,10.5,2,21,USD\n" + + "\n" + + "2025-01-02,Dividend,XYZ,Beta,,,5,USD\n" + + "\n\n"; + + var parser = new TransactionCsvParser(headerMap, new StubSecurityResolver(), new StubActionResolver()); + var results = parser.Parse(new StringReader(csv)).ToList(); + + results.Should().HaveCount(2); + results[0].TradeDate.Should().Be(DateTime.Parse("2025-01-01")); + results[0].Action.Should().Be(CanonicalAction.BuyStock); + results[0].Security!.Id.Should().Be("ABC"); + results[0].Units.Should().Be(2); + results[0].UnitPrice.Should().Be(10.5m); + results[0].Amount.Should().Be(21m); + + results[1].Action.Should().Be(CanonicalAction.Income); + results[1].Security!.Id.Should().Be("XYZ"); + results[1].Units.Should().Be(0); + results[1].UnitPrice.Should().Be(0); + results[1].Amount.Should().Be(5m); + results[1].Fees.Should().Be(0); + } + + [Fact] + public void Parse_ReturnsNulls_WhenOptionalColumnsMissing() + { + var headerMap = new HeaderMap("Test", + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Run Date"] = CanonicalField.TradeDate, + ["Action"] = CanonicalField.Action, + ["Symbol"] = CanonicalField.Symbol, + ["Amount"] = CanonicalField.Amount + }); + + var csv = "Run Date,Action,Symbol,Amount\n" + + "2025-01-01,Buy,ABC,21\n"; + + var parser = new TransactionCsvParser(headerMap, new StubSecurityResolver(), new StubActionResolver()); + var results = parser.Parse(new StringReader(csv)).ToList(); + var resultsCount = results.Count; + resultsCount.Should().Be(1); + results[0].Units.Should().Be(0); + results[0].UnitPrice.Should().Be(0); + results[0].Currency.Should().Be("USD"); + results[0].Fees.Should().Be(0); + } + } + + public class SecurityResolverTests + { + [Fact] + public void ResolveFromRow_UsesTicker_WhenNoCusip() + { + var resolver = new SecurityResolver(preferCusip: false); + var row = new Dictionary { ["Symbol"] = "ABC", ["Description"] = "Alpha" }; + + var sec = resolver.ResolveFromRow(row); + + sec.Should().NotBeNull(); + sec!.Id.Should().Be("ABC"); + sec.IdType.Should().Be(IdType.Ticker); + sec.Name.Should().Be("Alpha (ABC)"); + sec.Ticker.Should().Be("ABC"); + } + + [Fact] + public void ResolveFromRow_DetectsCusip_WhenSymbolLooksLikeCusip() + { + var resolver = new SecurityResolver(preferCusip: false); + var row = new Dictionary { ["Symbol"] = "123456789" }; + + var sec = resolver.ResolveFromRow(row); + + sec.Should().NotBeNull(); + sec!.IdType.Should().Be(IdType.Cusip); + sec.Name.Should().Be("123456789"); + sec.Ticker.Should().BeNull(); + } + + [Fact] + public void Resolve_FallsBackToRowResolution() + { + var resolver = new SecurityResolver(preferCusip: false); + var sec = resolver.Resolve("XYZ"); + + sec.Should().NotBeNull(); + sec!.Id.Should().Be("XYZ"); + sec.Name.Should().Be("XYZ"); + } + } + + public class ActionResolverAdapterTests + { + private readonly ActionResolverAdapter _adapter = new(); + + [Theory] + [InlineData("Dividend", CanonicalAction.Income)] + [InlineData("You bought", CanonicalAction.BuyStock)] + [InlineData("Sold shares", CanonicalAction.SellStock)] + [InlineData("Transfer", CanonicalAction.CashTransfer)] + [InlineData("Fee charged", CanonicalAction.MiscExpense)] + public void Resolve_MapsCommonActions(string input, CanonicalAction expected) + { + _adapter.Resolve(input).Should().Be(expected); + } + } + + // Test doubles + file sealed class StubSecurityResolver : ISecurityResolver + { + public SecurityRef? Resolve(string symbol) => new(symbol, IdType.Ticker, symbol, symbol); + public SecurityRef? ResolveFromRow(IDictionary row) + { + row.TryGetValue("Symbol", out var symbol); + return symbol is null ? null : Resolve(symbol); + } + } + + file sealed class StubActionResolver : IActionResolver + { + public CanonicalAction Resolve(string? actionText) + { + var text = (actionText ?? "").ToLowerInvariant(); + if (text.Contains("div")) return CanonicalAction.Income; + if (text.Contains("buy")) return CanonicalAction.BuyStock; + return CanonicalAction.CashTransfer; + } } } \ No newline at end of file