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 95%
rename from .github/workflows/build.yml
rename to .github/workflows/publish.yml
index 42ccdc4..c7101c9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/publish.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
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/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/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..63d71cc
--- /dev/null
+++ b/src/CsvToOfx.Core/Parsing/TransactionCsvParser.cs
@@ -0,0 +1,114 @@
+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;
+
+ if (csv.HeaderRecord != null)
+ {
+ 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);
+ if (symbol != null)
+ return _securityResolver.Resolve(symbol);
+ else
+ {
+ return null;
+ }
+
+ }
+}
+
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));
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