diff --git a/CAMTParser/CAMTParser.csproj b/CAMTParser/CAMTParser.csproj
new file mode 100644
index 0000000..92e0649
--- /dev/null
+++ b/CAMTParser/CAMTParser.csproj
@@ -0,0 +1,12 @@
+
+
+
+ net10.0
+ enable
+ enable
+ CAMTParser
+ CAMTParser
+ ISO 20022 CAMT (camt.052 / camt.053 / camt.054) bank statement parser — a variant of the MT940Parser producing an equivalent statement/entry model.
+
+
+
diff --git a/CAMTParser/CamtModels.cs b/CAMTParser/CamtModels.cs
new file mode 100644
index 0000000..bc15946
--- /dev/null
+++ b/CAMTParser/CamtModels.cs
@@ -0,0 +1,81 @@
+namespace CAMTParser;
+
+/// The CAMT message flavour a document was recognised as.
+public enum CamtDocumentType
+{
+ Unknown = 0,
+ /// camt.052 — Bank to Customer Account Report (intraday).
+ Camt052,
+ /// camt.053 — Bank to Customer Statement (end of day).
+ Camt053,
+ /// camt.054 — Bank to Customer Debit/Credit Notification.
+ Camt054
+}
+
+/// Debit/credit indicator, mirroring the MT940 parser's marks.
+public enum CamtDebitCreditMark
+{
+ Credit,
+ Debit,
+ ReverseCredit,
+ ReverseDebit
+}
+
+/// A parsed CAMT account statement/report/notification.
+public sealed class CamtStatement
+{
+ /// Account IBAN (or other account id when IBAN is absent).
+ public string AccountIdentification { get; set; } = "";
+ /// Statement currency (from the account or balances), when available.
+ public string? Currency { get; set; }
+ public CamtDocumentType DocumentType { get; set; } = CamtDocumentType.Unknown;
+ public List Entries { get; } = new();
+}
+
+///
+/// A single booked transaction line. Field names are aligned with the
+/// banking DataTable columns so the host can map both CAMT and MT940 uniformly.
+///
+public sealed class CamtEntry
+{
+ public decimal? Amount { get; set; }
+ public string? Currency { get; set; }
+ public CamtDebitCreditMark Mark { get; set; }
+ public DateTime? EntryDate { get; set; }
+ public DateTime? ValueDate { get; set; }
+
+ public string? BankReference { get; set; }
+ public string? EndToEndReference { get; set; }
+ public string? MandateReference { get; set; }
+ public string? CustomerReference { get; set; }
+ public string? CreditorReference { get; set; }
+
+ /// Counterparty (payer for credits, payee for debits) display name.
+ public string? CounterpartyName { get; set; }
+ /// Counterparty IBAN, when present.
+ public string? CounterpartyIban { get; set; }
+ /// Counterparty agent BIC, when present.
+ public string? CounterpartyBic { get; set; }
+
+ /// Unstructured remittance information (joined RmtInf/Ustrd).
+ public string? RemittanceUnstructured { get; set; }
+ /// Structured remittance information (RmtInf/Strd), flattened to text.
+ public string? RemittanceStructured { get; set; }
+ /// Additional entry information (AddtlNtryInf).
+ public string? AdditionalInfo { get; set; }
+ /// Bank transaction code (Domn/Fmly/SubFmly or Prtry), as text.
+ public string? BankTransactionCode { get; set; }
+
+ /// True when only unstructured remittance information is present.
+ public bool IsUnstructuredData =>
+ !string.IsNullOrEmpty(RemittanceUnstructured) && string.IsNullOrEmpty(RemittanceStructured);
+
+ public string MarkAbbreviation => Mark switch
+ {
+ CamtDebitCreditMark.Credit => "C",
+ CamtDebitCreditMark.Debit => "D",
+ CamtDebitCreditMark.ReverseCredit => "RC",
+ CamtDebitCreditMark.ReverseDebit => "RD",
+ _ => ""
+ };
+}
diff --git a/CAMTParser/CamtParser.cs b/CAMTParser/CamtParser.cs
new file mode 100644
index 0000000..00727b7
--- /dev/null
+++ b/CAMTParser/CamtParser.cs
@@ -0,0 +1,237 @@
+using System.Globalization;
+using System.Text;
+using System.Xml.Linq;
+
+namespace CAMTParser;
+
+///
+/// Parser for ISO 20022 CAMT bank statements (camt.052 / camt.053 / camt.054).
+/// A variant of the MT940 Parser producing an equivalent statement/entry
+/// model. Element lookups are namespace-agnostic (matched by local name), so the
+/// same code handles every camt schema version (…001.02, …001.08, etc.).
+///
+public sealed class CamtParser
+{
+ /// Cheap content sniff: is this payload XML (and therefore a CAMT candidate)?
+ public static bool LooksLikeXml(ReadOnlySpan bytes)
+ {
+ int i = 0;
+ // skip UTF-8 BOM
+ if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) i = 3;
+ for (; i < bytes.Length; i++)
+ {
+ byte b = bytes[i];
+ if (b is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or 0xFF or 0xFE) continue;
+ return b == (byte)'<';
+ }
+ return false;
+ }
+
+ /// Heuristic: does the XML look like a CAMT document?
+ public static bool LooksLikeCamt(string xml) =>
+ xml.Contains("camt.05", StringComparison.OrdinalIgnoreCase) ||
+ xml.Contains("BkToCstmr", StringComparison.OrdinalIgnoreCase);
+
+ public List Parse(Stream stream)
+ {
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
+ return Parse(reader.ReadToEnd());
+ }
+
+ public List Parse(byte[] bytes) => Parse(Encoding.UTF8.GetString(bytes));
+
+ public List Parse(string xml)
+ {
+ var result = new List();
+ if (string.IsNullOrWhiteSpace(xml)) return result;
+
+ XDocument doc;
+ try { doc = XDocument.Parse(xml); }
+ catch (System.Xml.XmlException ex) { throw new FormatException("Invalid CAMT XML.", ex); }
+
+ var root = doc.Root;
+ if (root is null) return result;
+
+ var docType = DetectType(xml, root);
+
+ // Stmt (053) / Rpt (052) / Ntfctn (054)
+ foreach (var stmtEl in root.DescendantsLocal("Stmt")
+ .Concat(root.DescendantsLocal("Rpt"))
+ .Concat(root.DescendantsLocal("Ntfctn")))
+ {
+ var stmt = new CamtStatement { DocumentType = docType };
+ var acct = stmtEl.ChildLocal("Acct");
+ stmt.AccountIdentification = AccountId(acct);
+ stmt.Currency = acct?.ChildLocal("Ccy")?.Value?.Trim();
+
+ foreach (var ntry in stmtEl.ChildrenLocal("Ntry"))
+ ParseEntry(ntry, stmt);
+
+ result.Add(stmt);
+ }
+ return result;
+ }
+
+ private static CamtDocumentType DetectType(string xml, XElement root)
+ {
+ string probe = xml.Length > 4000 ? xml[..4000] : xml;
+ if (probe.Contains("camt.052", StringComparison.OrdinalIgnoreCase) ||
+ root.DescendantsLocal("BkToCstmrAcctRpt").Any()) return CamtDocumentType.Camt052;
+ if (probe.Contains("camt.054", StringComparison.OrdinalIgnoreCase) ||
+ root.DescendantsLocal("BkToCstmrDbtCdtNtfctn").Any()) return CamtDocumentType.Camt054;
+ if (probe.Contains("camt.053", StringComparison.OrdinalIgnoreCase) ||
+ root.DescendantsLocal("BkToCstmrStmt").Any()) return CamtDocumentType.Camt053;
+ return CamtDocumentType.Unknown;
+ }
+
+ private static void ParseEntry(XElement ntry, CamtStatement stmt)
+ {
+ bool reversal = string.Equals(ntry.ChildLocal("RvslInd")?.Value?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
+ string? ntryCdtDbt = ntry.ChildLocal("CdtDbtInd")?.Value?.Trim();
+ DateTime? entryDate = ParseDate(ntry.ChildLocal("BookgDt"));
+ DateTime? valueDate = ParseDate(ntry.ChildLocal("ValDt"));
+ string? bankRef = ntry.ChildLocal("NtryRef")?.Value?.Trim()
+ ?? ntry.ChildLocal("AcctSvcrRef")?.Value?.Trim();
+ string? addtlInfo = ntry.ChildLocal("AddtlNtryInf")?.Value?.Trim();
+ string? bkTxCd = BankTxCode(ntry.ChildLocal("BkTxCd"));
+ var (ntryAmt, ntryCcy) = ParseAmount(ntry.ChildLocal("Amt"));
+
+ var txDetails = ntry.ChildLocal("NtryDtls")?.ChildrenLocal("TxDtls").ToList() ?? new List();
+
+ if (txDetails.Count == 0)
+ {
+ // No transaction detail block — emit a single entry from the Ntry level.
+ stmt.Entries.Add(new CamtEntry
+ {
+ Amount = ntryAmt,
+ Currency = ntryCcy ?? stmt.Currency,
+ Mark = MarkOf(ntryCdtDbt, reversal),
+ EntryDate = entryDate,
+ ValueDate = valueDate,
+ BankReference = bankRef,
+ AdditionalInfo = addtlInfo,
+ BankTransactionCode = bkTxCd
+ });
+ return;
+ }
+
+ foreach (var tx in txDetails)
+ {
+ var (txAmt, txCcy) = ParseAmount(tx.ChildLocal("Amt"));
+ string? cdtDbt = tx.ChildLocal("CdtDbtInd")?.Value?.Trim() ?? ntryCdtDbt;
+ var mark = MarkOf(cdtDbt, reversal);
+ bool isCredit = mark is CamtDebitCreditMark.Credit or CamtDebitCreditMark.ReverseCredit;
+
+ var refs = tx.ChildLocal("Refs");
+ var rmtInf = tx.ChildLocal("RmtInf");
+ var rltdPties = tx.ChildLocal("RltdPties");
+ var rltdAgts = tx.ChildLocal("RltdAgts");
+
+ // Counterparty: payer (Dbtr) for incoming credits, payee (Cdtr) for outgoing debits.
+ string partyTag = isCredit ? "Dbtr" : "Cdtr";
+ string acctTag = isCredit ? "DbtrAcct" : "CdtrAcct";
+ string agtTag = isCredit ? "DbtrAgt" : "CdtrAgt";
+
+ stmt.Entries.Add(new CamtEntry
+ {
+ Amount = txAmt ?? ntryAmt,
+ Currency = txCcy ?? ntryCcy ?? stmt.Currency,
+ Mark = mark,
+ EntryDate = entryDate,
+ ValueDate = valueDate,
+ BankReference = bankRef,
+ EndToEndReference = refs?.ChildLocal("EndToEndId")?.Value?.Trim().NullIfNa(),
+ MandateReference = refs?.ChildLocal("MndtId")?.Value?.Trim(),
+ CustomerReference = refs?.ChildLocal("InstrId")?.Value?.Trim()
+ ?? refs?.ChildLocal("AcctSvcrRef")?.Value?.Trim(),
+ CreditorReference = rmtInf?.ChildLocal("Strd")?.ChildLocal("CdtrRefInf")?.ChildLocal("Ref")?.Value?.Trim(),
+ CounterpartyName = rltdPties?.ChildLocal(partyTag)?.ChildLocal("Nm")?.Value?.Trim(),
+ CounterpartyIban = rltdPties?.ChildLocal(acctTag)?.ChildLocal("Id")?.ChildLocal("IBAN")?.Value?.Trim(),
+ CounterpartyBic = rltdAgts?.ChildLocal(agtTag)?.ChildLocal("FinInstnId")?.ChildLocal("BICFI")?.Value?.Trim()
+ ?? rltdAgts?.ChildLocal(agtTag)?.ChildLocal("FinInstnId")?.ChildLocal("BIC")?.Value?.Trim(),
+ RemittanceUnstructured = JoinUstrd(rmtInf),
+ RemittanceStructured = FlattenStrd(rmtInf?.ChildLocal("Strd")),
+ AdditionalInfo = addtlInfo,
+ BankTransactionCode = bkTxCd
+ });
+ }
+ }
+
+ // ── Mapping helpers ──────────────────────────────────────────────────────
+ private static CamtDebitCreditMark MarkOf(string? cdtDbtInd, bool reversal)
+ {
+ bool credit = string.Equals(cdtDbtInd, "CRDT", StringComparison.OrdinalIgnoreCase);
+ if (reversal) return credit ? CamtDebitCreditMark.ReverseCredit : CamtDebitCreditMark.ReverseDebit;
+ return credit ? CamtDebitCreditMark.Credit : CamtDebitCreditMark.Debit;
+ }
+
+ private static (decimal? amount, string? ccy) ParseAmount(XElement? amt)
+ {
+ if (amt is null) return (null, null);
+ string? ccy = amt.Attribute("Ccy")?.Value?.Trim();
+ return decimal.TryParse(amt.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)
+ ? (d, ccy) : (null, ccy);
+ }
+
+ private static DateTime? ParseDate(XElement? dateContainer)
+ {
+ if (dateContainer is null) return null;
+ string? raw = dateContainer.ChildLocal("Dt")?.Value?.Trim()
+ ?? dateContainer.ChildLocal("DtTm")?.Value?.Trim();
+ if (string.IsNullOrEmpty(raw)) return null;
+ return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) ? dt : null;
+ }
+
+ private static string AccountId(XElement? acct)
+ {
+ var id = acct?.ChildLocal("Id");
+ return id?.ChildLocal("IBAN")?.Value?.Trim()
+ ?? id?.ChildLocal("Othr")?.ChildLocal("Id")?.Value?.Trim()
+ ?? "";
+ }
+
+ private static string? BankTxCode(XElement? bkTxCd)
+ {
+ if (bkTxCd is null) return null;
+ var domn = bkTxCd.ChildLocal("Domn");
+ if (domn is not null)
+ {
+ string? d = domn.ChildLocal("Cd")?.Value?.Trim();
+ string? f = domn.ChildLocal("Fmly")?.ChildLocal("Cd")?.Value?.Trim();
+ string? s = domn.ChildLocal("Fmly")?.ChildLocal("SubFmlyCd")?.Value?.Trim();
+ string joined = string.Join("/", new[] { d, f, s }.Where(x => !string.IsNullOrEmpty(x)));
+ if (!string.IsNullOrEmpty(joined)) return joined;
+ }
+ return bkTxCd.ChildLocal("Prtry")?.ChildLocal("Cd")?.Value?.Trim();
+ }
+
+ private static string? JoinUstrd(XElement? rmtInf)
+ {
+ if (rmtInf is null) return null;
+ var parts = rmtInf.ChildrenLocal("Ustrd").Select(e => e.Value.Trim()).Where(s => s.Length > 0).ToArray();
+ return parts.Length == 0 ? null : string.Join(" ", parts);
+ }
+
+ private static string? FlattenStrd(XElement? strd)
+ {
+ if (strd is null) return null;
+ var parts = strd.Descendants().Where(e => !e.HasElements)
+ .Select(e => e.Value.Trim()).Where(s => s.Length > 0).ToArray();
+ return parts.Length == 0 ? null : string.Join(" ", parts);
+ }
+}
+
+internal static class CamtXmlExtensions
+{
+ public static XElement? ChildLocal(this XElement? e, string localName) =>
+ e?.Elements().FirstOrDefault(x => x.Name.LocalName == localName);
+
+ public static IEnumerable ChildrenLocal(this XElement? e, string localName) =>
+ e?.Elements().Where(x => x.Name.LocalName == localName) ?? Enumerable.Empty();
+
+ public static IEnumerable DescendantsLocal(this XElement e, string localName) =>
+ e.Descendants().Where(x => x.Name.LocalName == localName);
+
+ public static string? NullIfNa(this string? s) =>
+ string.IsNullOrEmpty(s) || s.Equals("NOTPROVIDED", StringComparison.OrdinalIgnoreCase) ? null : s;
+}
diff --git a/Fuchs.Tests/CamtParserTests.cs b/Fuchs.Tests/CamtParserTests.cs
new file mode 100644
index 0000000..0bf3476
--- /dev/null
+++ b/Fuchs.Tests/CamtParserTests.cs
@@ -0,0 +1,223 @@
+using System;
+using System.Data;
+using System.IO;
+using System.Linq;
+using System.Text;
+using CAMTParser;
+using Fuchs.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Fuchs.Tests;
+
+///
+/// Tests for the ISO 20022 CAMT parser and for the BankingService dual-format
+/// (CAMT + MT940) routing. Covers both succeeding and failing/edge inputs.
+///
+public class CamtParserTests
+{
+ // camt.053 with one incoming (CRDT) and one outgoing (DBIT) entry.
+ private const string Camt053 = """
+
+
+
+ MSG12023-01-31T08:00:00
+
+ STMT-1
+ DE12345678901234567890EUR
+
+ 500.00
+ CRDT
+ 2023-01-15
+ 2023-01-16
+ REF-CR-1
+
+ E2E-CR-1
+
+ Max Mustermann
+ DE99888877776666555544
+
+ Rechnung 4711
+
+
+
+ 89.90
+ DBIT
+ 2023-01-16
+ 2023-01-16
+ REF-DB-1
+
+ MND-1
+
+ Stadtwerke
+ DE11223344556677889900
+
+ Strom Januar
+
+
+
+
+
+ """;
+
+ // ── Detection ──────────────────────────────────────────────────────────────
+ [Fact]
+ public void LooksLikeXml_XmlContent_True()
+ => Assert.True(CamtParser.LooksLikeXml(Encoding.UTF8.GetBytes(Camt053)));
+
+ [Fact]
+ public void LooksLikeXml_Mt940Content_False()
+ => Assert.False(CamtParser.LooksLikeXml(Encoding.UTF8.GetBytes(":20:STARTUMSE\r\n:25:DE123\r\n")));
+
+ [Fact]
+ public void LooksLikeXml_Empty_False()
+ => Assert.False(CamtParser.LooksLikeXml(Array.Empty()));
+
+ // ── Successful parse ────────────────────────────────────────────────────────
+ [Fact]
+ public void Parse_Camt053_ReturnsOneStatementWithAccountAndTwoEntries()
+ {
+ var statements = new CamtParser().Parse(Camt053);
+ var stmt = Assert.Single(statements);
+ Assert.Equal(CamtDocumentType.Camt053, stmt.DocumentType);
+ Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
+ Assert.Equal(2, stmt.Entries.Count);
+ }
+
+ [Fact]
+ public void Parse_Camt053_CreditEntry_MappedCorrectly()
+ {
+ var stmt = new CamtParser().Parse(Camt053).Single();
+ var credit = stmt.Entries[0];
+ Assert.Equal(500.00m, credit.Amount);
+ Assert.Equal("EUR", credit.Currency);
+ Assert.Equal(CamtDebitCreditMark.Credit, credit.Mark);
+ Assert.Equal("C", credit.MarkAbbreviation);
+ Assert.Equal(new DateTime(2023, 1, 15), credit.EntryDate);
+ Assert.Equal(new DateTime(2023, 1, 16), credit.ValueDate);
+ Assert.Equal("Max Mustermann", credit.CounterpartyName); // payer for a credit
+ Assert.Equal("DE99888877776666555544", credit.CounterpartyIban);
+ Assert.Equal("E2E-CR-1", credit.EndToEndReference);
+ Assert.Equal("Rechnung 4711", credit.RemittanceUnstructured);
+ }
+
+ [Fact]
+ public void Parse_Camt053_DebitEntry_MappedCorrectly()
+ {
+ var stmt = new CamtParser().Parse(Camt053).Single();
+ var debit = stmt.Entries[1];
+ Assert.Equal(89.90m, debit.Amount);
+ Assert.Equal(CamtDebitCreditMark.Debit, debit.Mark);
+ Assert.Equal("D", debit.MarkAbbreviation);
+ Assert.Equal("Stadtwerke", debit.CounterpartyName); // payee for a debit
+ Assert.Equal("MND-1", debit.MandateReference);
+ }
+
+ [Fact]
+ public void Parse_NamespaceVersionAgnostic_StillParses()
+ {
+ // Different schema version namespace (…001.08) must parse identically.
+ string v8 = Camt053.Replace("camt.053.001.02", "camt.053.001.08");
+ var stmt = new CamtParser().Parse(v8).Single();
+ Assert.Equal(2, stmt.Entries.Count);
+ Assert.Equal("DE12345678901234567890", stmt.AccountIdentification);
+ }
+
+ [Fact]
+ public void Parse_ReversalEntry_MarkedAsReverseCredit()
+ {
+ string xml = """
+
+
+ DE00
+
+ 10.00CRDTtrue
+ 2023-02-01
+
+
+
+ """;
+ var entry = new CamtParser().Parse(xml).Single().Entries.Single();
+ Assert.Equal(CamtDebitCreditMark.ReverseCredit, entry.Mark);
+ Assert.Equal("RC", entry.MarkAbbreviation);
+ }
+
+ // ── Failing / edge inputs ───────────────────────────────────────────────────
+ [Fact]
+ public void Parse_EmptyString_ReturnsEmptyList()
+ => Assert.Empty(new CamtParser().Parse(""));
+
+ [Fact]
+ public void Parse_InvalidXml_ThrowsFormatException()
+ => Assert.Throws(() => new CamtParser().Parse(" Assert.Empty(new CamtParser().Parse("bar"));
+}
+
+/// BankingService routing: CAMT and MT940 both land in the same DataTable.
+public class BankingDualFormatTests
+{
+ private static readonly BankingService Svc = new(NullLogger.Instance);
+
+ private static Stream ToStream(string s) => new MemoryStream(Encoding.UTF8.GetBytes(s));
+
+ private const string Camt053 = """
+
+
+
+ DE12345678901234567890EUR
+
+ 500.00CRDT
+ 2023-01-15
+
+ Max Mustermann
+ Rechnung 4711
+
+
+
+
+ """;
+
+ private const string MinimalMT940 =
+ "\r\n:20:STARTUMSE\r\n:25:DE12345678901234567890\r\n:28C:00001/001\r\n" +
+ ":60F:C230101EUR1000,00\r\n:61:2301010101CR500,00NTRFREF123\r\n:86:Gutschrift\r\n" +
+ ":62F:C230101EUR1500,00\r\n-\r\n";
+
+ [Fact]
+ public void ParseToDatatable_Camt_RoutesToCamtAndMapsColumns()
+ {
+ using var s = ToStream(Camt053);
+ var t = Svc.ParseToDatatable(s);
+ Assert.Equal(1, t.Rows.Count);
+ Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
+ Assert.Equal(500.00m, t.Rows[0]["Amount"]);
+ Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
+ Assert.Equal("Max Mustermann", t.Rows[0]["NameOfPayer"]);
+ Assert.Equal("Rechnung 4711", t.Rows[0]["SepaRemittanceInformation"]);
+ }
+
+ [Fact]
+ public void ParseToDatatable_Mt940_StillRoutesToMt940()
+ {
+ using var s = ToStream(MinimalMT940);
+ var t = Svc.ParseToDatatable(s);
+ Assert.Equal(1, t.Rows.Count);
+ Assert.Equal("DE12345678901234567890", t.Rows[0]["AccountIdentification"]);
+ Assert.Equal(500m, t.Rows[0]["Amount"]);
+ Assert.Equal("C", t.Rows[0]["DebitCreditMark"]);
+ }
+
+ [Fact]
+ public void ParseToDatatable_Camt_RespectsCustomSchema()
+ {
+ var schema = new DataTable();
+ schema.Columns.Add("AccountIdentification", typeof(string));
+ schema.Columns.Add("Amount", typeof(decimal));
+ using var s = ToStream(Camt053);
+ var t = Svc.ParseToDatatable(s, schemaDatatable: schema);
+ Assert.Equal(2, t.Columns.Count);
+ Assert.Equal(1, t.Rows.Count);
+ }
+}
diff --git a/Fuchs.Tests/Fuchs.Tests.csproj b/Fuchs.Tests/Fuchs.Tests.csproj
index aefc47f..adb96fb 100644
--- a/Fuchs.Tests/Fuchs.Tests.csproj
+++ b/Fuchs.Tests/Fuchs.Tests.csproj
@@ -27,6 +27,7 @@
+
diff --git a/Fuchs/Fuchs.csproj b/Fuchs/Fuchs.csproj
index e6f800e..c0299e6 100644
--- a/Fuchs/Fuchs.csproj
+++ b/Fuchs/Fuchs.csproj
@@ -21,6 +21,7 @@
+
diff --git a/Fuchs/Services/BankingService.cs b/Fuchs/Services/BankingService.cs
index 715980a..5e2e903 100644
--- a/Fuchs/Services/BankingService.cs
+++ b/Fuchs/Services/BankingService.cs
@@ -1,5 +1,6 @@
-using System.Data;
+using System.Data;
using System.Diagnostics;
+using CAMTParser;
using Fuchs.Observability;
using Microsoft.Extensions.Logging;
using programmersdigest.MT940Parser;
@@ -7,7 +8,9 @@ using programmersdigest.MT940Parser;
namespace Fuchs.Services;
///
-/// MT940 bank statement parsing service. Replaces the static Banking class.
+/// Bank statement parsing service. Accepts both MT940 (SWIFT text) and
+/// CAMT (ISO 20022 camt.052/053/054 XML) and maps either into the same
+/// banking-transactions DataTable. The format is auto-detected from content.
///
public class BankingService : IBankingService
{
@@ -20,23 +23,56 @@ public class BankingService : IBankingService
public string DebitCreditMarkAbb(DebitCreditMark mark) => mark switch
{
- DebitCreditMark.Credit => "C",
- DebitCreditMark.Debit => "D",
+ DebitCreditMark.Credit => "C",
+ DebitCreditMark.Debit => "D",
DebitCreditMark.ReverseCredit => "RC",
- DebitCreditMark.ReverseDebit => "RD",
- _ => ""
+ DebitCreditMark.ReverseDebit => "RD",
+ _ => ""
};
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
{
- using var act = FuchsTelemetry.StartActivity("banking.mt940.parse");
+ using var act = FuchsTelemetry.StartActivity("banking.parse");
var sw = Stopwatch.StartNew();
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
+ // Buffer once so we can sniff the format and (re)parse from the bytes.
+ byte[] bytes;
+ using (var ms = new MemoryStream())
+ {
+ stream.CopyTo(ms);
+ bytes = ms.ToArray();
+ }
+
+ string format;
+ if (CamtParser.LooksLikeXml(bytes))
+ {
+ format = "camt";
+ FillFromCamt(tbl, bytes);
+ }
+ else
+ {
+ format = "mt940";
+ using var msMt = new MemoryStream(bytes);
+ FillFromMt940(tbl, msMt);
+ }
+
+ tbl.AcceptChanges();
+ sw.Stop();
+ FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair("format", format));
+ act?.SetTag("fuchs.banking.format", format);
+ act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
+ _logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms",
+ format, tbl.Rows.Count, sw.ElapsedMilliseconds);
+ return tbl;
+ }
+
+ // ── MT940 ─────────────────────────────────────────────────────────────────
+ private void FillFromMt940(DataTable tbl, Stream stream)
+ {
void SetNfo(DataRow nr, string key, object? value)
{
- if (tbl.Columns.Contains(key) && value != null)
- nr[key] = value;
+ if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
}
using var ps = new Parser(stream: stream);
@@ -51,93 +87,130 @@ public class BankingService : IBankingService
{
var nr = tbl.NewRow();
SetNfo(nr, "AccountIdentification", statement.AccountIdentification);
- if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
- if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
- if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
+ if (line.Amount.HasValue) SetNfo(nr, "Amount", line.Amount);
+ if (line.EntryDate.HasValue) SetNfo(nr, "EntryDate", line.EntryDate);
+ if (line.FundsCode.HasValue) SetNfo(nr, "FundsCode", line.FundsCode.ToString());
SetNfo(nr, "BankReference", line.BankReference);
var info = line.InformationToOwner;
- SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
- SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
- SetNfo(nr, "CompensationAmount", info.CompensationAmount);
- SetNfo(nr, "CreditorReference", info.CreditorReference);
- SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
- SetNfo(nr, "CustomerReference", info.CustomerReference);
- SetNfo(nr, "EndToEndReference", info.EndToEndReference);
- SetNfo(nr, "JournalNumber", info.JournalNumber);
- SetNfo(nr, "MandateReference", info.MandateReference);
- SetNfo(nr, "NameOfPayer", info.NameOfPayer);
- SetNfo(nr, "OriginalAmount", info.OriginalAmount);
- SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
- SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
- SetNfo(nr, "PostingText", info.PostingText);
- SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
+ SetNfo(nr, "AccountNumberOfPayer", info.AccountNumberOfPayer);
+ SetNfo(nr, "BankCodeOfPayer", info.BankCodeOfPayer);
+ SetNfo(nr, "CompensationAmount", info.CompensationAmount);
+ SetNfo(nr, "CreditorReference", info.CreditorReference);
+ SetNfo(nr, "CreditorsReferenceParty", info.CreditorsReferenceParty);
+ SetNfo(nr, "CustomerReference", info.CustomerReference);
+ SetNfo(nr, "EndToEndReference", info.EndToEndReference);
+ SetNfo(nr, "JournalNumber", info.JournalNumber);
+ SetNfo(nr, "MandateReference", info.MandateReference);
+ SetNfo(nr, "NameOfPayer", info.NameOfPayer);
+ SetNfo(nr, "OriginalAmount", info.OriginalAmount);
+ SetNfo(nr, "OriginatorsIdentificationCode", info.OriginatorsIdentificationCode);
+ SetNfo(nr, "PayersReferenceParty", info.PayersReferenceParty);
+ SetNfo(nr, "PostingText", info.PostingText);
+ SetNfo(nr, "SepaRemittanceInformation", info.SepaRemittanceInformation);
if (info.TextKeyAddition.HasValue) SetNfo(nr, "TextKeyAddition", info.TextKeyAddition);
- SetNfo(nr, "TransactionCode", info.TransactionCode);
- SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
- SetNfo(nr, "UnstructuredData", info.UnstructuredData);
- SetNfo(nr, "UnstructuredRemittanceInformation", info.UnstructuredRemittanceInformation);
+ SetNfo(nr, "TransactionCode", info.TransactionCode);
+ SetNfo(nr, "IsUnstructuredData", info.IsUnstructuredData);
+ SetNfo(nr, "UnstructuredData", info.UnstructuredData);
+ SetNfo(nr, "UnstructuredRemittanceInformation",info.UnstructuredRemittanceInformation);
- SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
+ SetNfo(nr, "DebitCreditMark", DebitCreditMarkAbb(line.Mark));
SetNfo(nr, "SupplementaryDetails", line.SupplementaryDetails);
- SetNfo(nr, "TransactionTypeIdCode", line.TransactionTypeIdCode);
- SetNfo(nr, "ValueDate", line.ValueDate);
+ SetNfo(nr, "TransactionTypeIdCode",line.TransactionTypeIdCode);
+ SetNfo(nr, "ValueDate", line.ValueDate);
tbl.Rows.Add(nr);
}
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification);
- }
+ catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
}
}
}
- catch (Exception ex)
+ catch (Exception ex) { _logger.LogError(ex, "MT940 statement parse failed."); }
+ }
+
+ // ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
+ private void FillFromCamt(DataTable tbl, byte[] bytes)
+ {
+ void SetNfo(DataRow nr, string key, object? value)
{
- _logger.LogError(ex, "MT940 statement parse failed.");
+ if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
}
- tbl.AcceptChanges();
- sw.Stop();
- FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count);
- act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
- _logger.LogInformation("MT940 parsed {Rows} transaction lines in {Ms} ms", tbl.Rows.Count, sw.ElapsedMilliseconds);
- return tbl;
+ try
+ {
+ var statements = new CamtParser().Parse(bytes);
+ foreach (var stmt in statements)
+ {
+ if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
+ foreach (var e in stmt.Entries)
+ {
+ try
+ {
+ var nr = tbl.NewRow();
+ SetNfo(nr, "AccountIdentification", stmt.AccountIdentification);
+ if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount);
+ if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate);
+ if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate);
+ SetNfo(nr, "FundsCode", e.Currency);
+ SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation);
+ SetNfo(nr, "BankReference", e.BankReference);
+ SetNfo(nr, "EndToEndReference", e.EndToEndReference);
+ SetNfo(nr, "MandateReference", e.MandateReference);
+ SetNfo(nr, "CustomerReference", e.CustomerReference);
+ SetNfo(nr, "CreditorReference", e.CreditorReference);
+ SetNfo(nr, "AccountNumberOfPayer", e.CounterpartyIban);
+ SetNfo(nr, "BankCodeOfPayer", e.CounterpartyBic);
+ SetNfo(nr, "NameOfPayer", e.CounterpartyName);
+ SetNfo(nr, "PostingText", e.AdditionalInfo);
+ SetNfo(nr, "TransactionTypeIdCode",e.BankTransactionCode);
+ SetNfo(nr, "SepaRemittanceInformation",
+ string.IsNullOrEmpty(e.RemittanceUnstructured) ? e.RemittanceStructured : e.RemittanceUnstructured);
+ SetNfo(nr, "UnstructuredRemittanceInformation", e.RemittanceUnstructured);
+ SetNfo(nr, "UnstructuredData", e.RemittanceUnstructured);
+ SetNfo(nr, "IsUnstructuredData", e.IsUnstructuredData);
+
+ tbl.Rows.Add(nr);
+ }
+ catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); }
+ }
+ }
+ }
+ catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
}
private static DataTable BuildDefaultSchema()
{
var t = new DataTable();
var cols = t.Columns;
- cols.Add("AccountIdentification", typeof(string));
- cols.Add("Amount", typeof(decimal));
- cols.Add("BankReference", typeof(string));
- cols.Add("EntryDate", typeof(DateTime));
- cols.Add("FundsCode", typeof(string));
- cols.Add("AccountNumberOfPayer", typeof(string));
- cols.Add("BankCodeOfPayer", typeof(string));
- cols.Add("CompensationAmount", typeof(string));
- cols.Add("CreditorReference", typeof(string));
- cols.Add("CreditorsReferenceParty", typeof(string));
- cols.Add("CustomerReference", typeof(string));
- cols.Add("EndToEndReference", typeof(string));
- cols.Add("JournalNumber", typeof(string));
- cols.Add("MandateReference", typeof(string));
- cols.Add("NameOfPayer", typeof(string));
- cols.Add("OriginalAmount", typeof(string));
- cols.Add("OriginatorsIdentificationCode", typeof(string));
- cols.Add("PayersReferenceParty", typeof(string));
- cols.Add("PostingText", typeof(string));
- cols.Add("SepaRemittanceInformation", typeof(string));
- cols.Add("TextKeyAddition", typeof(int));
- cols.Add("TransactionCode", typeof(int));
- cols.Add("IsUnstructuredData", typeof(bool));
- cols.Add("UnstructuredData", typeof(string));
+ cols.Add("AccountIdentification", typeof(string));
+ cols.Add("Amount", typeof(decimal));
+ cols.Add("BankReference", typeof(string));
+ cols.Add("EntryDate", typeof(DateTime));
+ cols.Add("FundsCode", typeof(string));
+ cols.Add("AccountNumberOfPayer", typeof(string));
+ cols.Add("BankCodeOfPayer", typeof(string));
+ cols.Add("CompensationAmount", typeof(string));
+ cols.Add("CreditorReference", typeof(string));
+ cols.Add("CreditorsReferenceParty", typeof(string));
+ cols.Add("CustomerReference", typeof(string));
+ cols.Add("EndToEndReference", typeof(string));
+ cols.Add("JournalNumber", typeof(string));
+ cols.Add("MandateReference", typeof(string));
+ cols.Add("NameOfPayer", typeof(string));
+ cols.Add("OriginalAmount", typeof(string));
+ cols.Add("OriginatorsIdentificationCode", typeof(string));
+ cols.Add("PayersReferenceParty", typeof(string));
+ cols.Add("PostingText", typeof(string));
+ cols.Add("SepaRemittanceInformation", typeof(string));
+ cols.Add("TextKeyAddition", typeof(int));
+ cols.Add("TransactionCode", typeof(int));
+ cols.Add("IsUnstructuredData", typeof(bool));
+ cols.Add("UnstructuredData", typeof(string));
cols.Add("UnstructuredRemittanceInformation", typeof(string));
- cols.Add("DebitCreditMark", typeof(string));
- cols.Add("SupplementaryDetails", typeof(string));
- cols.Add("TransactionTypeIdCode", typeof(string));
- cols.Add("ValueDate", typeof(DateTime));
+ cols.Add("DebitCreditMark", typeof(string));
+ cols.Add("SupplementaryDetails", typeof(string));
+ cols.Add("TransactionTypeIdCode", typeof(string));
+ cols.Add("ValueDate", typeof(DateTime));
return t;
}
}
diff --git a/Fuchs/Services/IBankingService.cs b/Fuchs/Services/IBankingService.cs
index ca4e723..2a62a40 100644
--- a/Fuchs/Services/IBankingService.cs
+++ b/Fuchs/Services/IBankingService.cs
@@ -3,13 +3,17 @@
namespace Fuchs.Services;
///
-/// Abstraction for MT940 bank statement parsing.
+/// Abstraction for bank statement parsing. Supports both MT940 (SWIFT text)
+/// and CAMT (ISO 20022 camt.052/053/054 XML); the format is auto-detected.
///
public interface IBankingService
{
/// Abbreviation for a debit/credit mark.
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark);
- /// Parses an MT940 stream into a DataTable.
+ ///
+ /// Parses a bank statement stream (MT940 or CAMT, auto-detected) into a DataTable
+ /// matching the banking-transactions schema.
+ ///
DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
}
diff --git a/Fuchs/wwwroot/web/fis.bam.de.js b/Fuchs/wwwroot/web/fis.bam.de.js
index 23b8969..9d2cfdf 100644
--- a/Fuchs/wwwroot/web/fis.bam.de.js
+++ b/Fuchs/wwwroot/web/fis.bam.de.js
@@ -36,7 +36,8 @@ let $bcol = {
{ name: 'EndToEndReference', label: 'Referenz', type: 'string' }
]),
bsu: new fields_definition('Kontobericht', 'Kontoberichte', [
- { name: 'bsu', label: 'Export der Buchungen', type: 'file', required: true, prop: { multiple: true } }
+ // Accepts both MT940 (SWIFT text: .sta/.mt940/.txt) and CAMT (ISO 20022 XML: .xml/.camt).
+ { name: 'bsu', label: 'Export der Buchungen (MT940 oder CAMT)', type: 'file', required: true, prop: { multiple: true, accept: '.sta,.mt940,.txt,.xml,.camt,application/xml,text/xml,text/plain' } }
])
};
let gi = (n, c) => $$.sc(`glyphicon glyphicon-${n}`).aC(c);
diff --git a/Fuchs_Intranet.slnx b/Fuchs_Intranet.slnx
index c1448b4..738f6e7 100644
--- a/Fuchs_Intranet.slnx
+++ b/Fuchs_Intranet.slnx
@@ -12,6 +12,10 @@
+
+
+
+