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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user