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 @@ + + + +