Add CAMTParser (ISO 20022) alongside MT940; accept both formats

- New CAMTParser project: namespace-agnostic parser for camt.052/053/054
  producing a statement/entry model aligned with the banking columns
  (account, amount, debit/credit, dates, counterparty, references, remittance).
- BankingService now auto-detects the upload format (XML→CAMT, else MT940)
  and maps either into the same fds__tt__bankingtransactions DataTable, so the
  bam/up handler transparently accepts both.
- Frontend (fis.bam.de.js) upload field now advertises accept for both
  MT940 (.sta/.mt940/.txt) and CAMT (.xml/.camt).
- Tests (+14, 151 total): CamtParserTests cover parsing (credit/debit,
  namespace-version agnostic, reversals), detection, and failure/edge inputs
  (empty, invalid XML, non-CAMT); BankingDualFormatTests verify CAMT and MT940
  both land in the same DataTable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:39:54 +02:00
parent e04d590c3a
commit 7ee4e5302a
10 changed files with 714 additions and 77 deletions
+223
View File
@@ -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;
/// <summary>
/// Tests for the ISO 20022 CAMT parser and for the BankingService dual-format
/// (CAMT + MT940) routing. Covers both succeeding and failing/edge inputs.
/// </summary>
public class CamtParserTests
{
// camt.053 with one incoming (CRDT) and one outgoing (DBIT) entry.
private const string Camt053 = """
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<GrpHdr><MsgId>MSG1</MsgId><CreDtTm>2023-01-31T08:00:00</CreDtTm></GrpHdr>
<Stmt>
<Id>STMT-1</Id>
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
<Ntry>
<Amt Ccy="EUR">500.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
<ValDt><Dt>2023-01-16</Dt></ValDt>
<NtryRef>REF-CR-1</NtryRef>
<NtryDtls><TxDtls>
<Refs><EndToEndId>E2E-CR-1</EndToEndId></Refs>
<RltdPties>
<Dbtr><Nm>Max Mustermann</Nm></Dbtr>
<DbtrAcct><Id><IBAN>DE99888877776666555544</IBAN></Id></DbtrAcct>
</RltdPties>
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
<Ntry>
<Amt Ccy="EUR">89.90</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<BookgDt><Dt>2023-01-16</Dt></BookgDt>
<ValDt><Dt>2023-01-16</Dt></ValDt>
<NtryRef>REF-DB-1</NtryRef>
<NtryDtls><TxDtls>
<Refs><MndtId>MND-1</MndtId></Refs>
<RltdPties>
<Cdtr><Nm>Stadtwerke</Nm></Cdtr>
<CdtrAcct><Id><IBAN>DE11223344556677889900</IBAN></Id></CdtrAcct>
</RltdPties>
<RmtInf><Ustrd>Strom Januar</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>
""";
// ── 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<byte>()));
// ── 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 = """
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt><Stmt>
<Acct><Id><IBAN>DE00</IBAN></Id></Acct>
<Ntry>
<Amt Ccy="EUR">10.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><RvslInd>true</RvslInd>
<BookgDt><Dt>2023-02-01</Dt></BookgDt>
</Ntry>
</Stmt></BkToCstmrStmt>
</Document>
""";
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<FormatException>(() => new CamtParser().Parse("<broken"));
[Fact]
public void Parse_NonCamtXml_ReturnsNoStatements()
=> Assert.Empty(new CamtParser().Parse("<root><foo>bar</foo></root>"));
}
/// <summary>BankingService routing: CAMT and MT940 both land in the same DataTable.</summary>
public class BankingDualFormatTests
{
private static readonly BankingService Svc = new(NullLogger<BankingService>.Instance);
private static Stream ToStream(string s) => new MemoryStream(Encoding.UTF8.GetBytes(s));
private const string Camt053 = """
<?xml version="1.0"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt><Stmt>
<Acct><Id><IBAN>DE12345678901234567890</IBAN></Id><Ccy>EUR</Ccy></Acct>
<Ntry>
<Amt Ccy="EUR">500.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
<BookgDt><Dt>2023-01-15</Dt></BookgDt>
<NtryDtls><TxDtls>
<RltdPties><Dbtr><Nm>Max Mustermann</Nm></Dbtr></RltdPties>
<RmtInf><Ustrd>Rechnung 4711</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt></BkToCstmrStmt>
</Document>
""";
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);
}
}
+1
View File
@@ -27,6 +27,7 @@
<ProjectReference Include="..\Fuchs_DataService\Fuchs_DataService.csproj" />
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
</ItemGroup>
</Project>