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,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>CAMTParser</RootNamespace>
|
||||||
|
<AssemblyName>CAMTParser</AssemblyName>
|
||||||
|
<Description>ISO 20022 CAMT (camt.052 / camt.053 / camt.054) bank statement parser — a variant of the MT940Parser producing an equivalent statement/entry model.</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
namespace CAMTParser;
|
||||||
|
|
||||||
|
/// <summary>The CAMT message flavour a document was recognised as.</summary>
|
||||||
|
public enum CamtDocumentType
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
/// <summary>camt.052 — Bank to Customer Account Report (intraday).</summary>
|
||||||
|
Camt052,
|
||||||
|
/// <summary>camt.053 — Bank to Customer Statement (end of day).</summary>
|
||||||
|
Camt053,
|
||||||
|
/// <summary>camt.054 — Bank to Customer Debit/Credit Notification.</summary>
|
||||||
|
Camt054
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Debit/credit indicator, mirroring the MT940 parser's marks.</summary>
|
||||||
|
public enum CamtDebitCreditMark
|
||||||
|
{
|
||||||
|
Credit,
|
||||||
|
Debit,
|
||||||
|
ReverseCredit,
|
||||||
|
ReverseDebit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A parsed CAMT account statement/report/notification.</summary>
|
||||||
|
public sealed class CamtStatement
|
||||||
|
{
|
||||||
|
/// <summary>Account IBAN (or other account id when IBAN is absent).</summary>
|
||||||
|
public string AccountIdentification { get; set; } = "";
|
||||||
|
/// <summary>Statement currency (from the account or balances), when available.</summary>
|
||||||
|
public string? Currency { get; set; }
|
||||||
|
public CamtDocumentType DocumentType { get; set; } = CamtDocumentType.Unknown;
|
||||||
|
public List<CamtEntry> Entries { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single booked transaction line. Field names are aligned with the
|
||||||
|
/// banking DataTable columns so the host can map both CAMT and MT940 uniformly.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>Counterparty (payer for credits, payee for debits) display name.</summary>
|
||||||
|
public string? CounterpartyName { get; set; }
|
||||||
|
/// <summary>Counterparty IBAN, when present.</summary>
|
||||||
|
public string? CounterpartyIban { get; set; }
|
||||||
|
/// <summary>Counterparty agent BIC, when present.</summary>
|
||||||
|
public string? CounterpartyBic { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Unstructured remittance information (joined RmtInf/Ustrd).</summary>
|
||||||
|
public string? RemittanceUnstructured { get; set; }
|
||||||
|
/// <summary>Structured remittance information (RmtInf/Strd), flattened to text.</summary>
|
||||||
|
public string? RemittanceStructured { get; set; }
|
||||||
|
/// <summary>Additional entry information (AddtlNtryInf).</summary>
|
||||||
|
public string? AdditionalInfo { get; set; }
|
||||||
|
/// <summary>Bank transaction code (Domn/Fmly/SubFmly or Prtry), as text.</summary>
|
||||||
|
public string? BankTransactionCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when only unstructured remittance information is present.</summary>
|
||||||
|
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",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace CAMTParser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for ISO 20022 CAMT bank statements (camt.052 / camt.053 / camt.054).
|
||||||
|
/// A variant of the MT940 <c>Parser</c> 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.).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CamtParser
|
||||||
|
{
|
||||||
|
/// <summary>Cheap content sniff: is this payload XML (and therefore a CAMT candidate)?</summary>
|
||||||
|
public static bool LooksLikeXml(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Heuristic: does the XML look like a CAMT document?</summary>
|
||||||
|
public static bool LooksLikeCamt(string xml) =>
|
||||||
|
xml.Contains("camt.05", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
xml.Contains("BkToCstmr", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public List<CamtStatement> Parse(Stream stream)
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||||
|
return Parse(reader.ReadToEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CamtStatement> Parse(byte[] bytes) => Parse(Encoding.UTF8.GetString(bytes));
|
||||||
|
|
||||||
|
public List<CamtStatement> Parse(string xml)
|
||||||
|
{
|
||||||
|
var result = new List<CamtStatement>();
|
||||||
|
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<XElement>();
|
||||||
|
|
||||||
|
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<XElement> ChildrenLocal(this XElement? e, string localName) =>
|
||||||
|
e?.Elements().Where(x => x.Name.LocalName == localName) ?? Enumerable.Empty<XElement>();
|
||||||
|
|
||||||
|
public static IEnumerable<XElement> 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;
|
||||||
|
}
|
||||||
@@ -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="..\Fuchs_DataService\Fuchs_DataService.csproj" />
|
||||||
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
<ProjectReference Include="..\MFR_RESTClient\MFR_RESTClient.csproj" />
|
||||||
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
<ProjectReference Include="..\..\..\WebProjectComponents\MT940Parser\MT940Parser\MT940Parser.csproj" />
|
||||||
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
|
<ProjectReference Include="..\OCORE\OCORE\OCORE.csproj" />
|
||||||
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
<ProjectReference Include="..\OCORE_web\OCORE_web\OCORE_web.csproj" />
|
||||||
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
<ProjectReference Include="..\OCORE_web_pdf\OCORE_web_pdf.csproj" />
|
||||||
|
<ProjectReference Include="..\CAMTParser\CAMTParser.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="code\7z.dll" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using CAMTParser;
|
||||||
using Fuchs.Observability;
|
using Fuchs.Observability;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using programmersdigest.MT940Parser;
|
using programmersdigest.MT940Parser;
|
||||||
@@ -7,7 +8,9 @@ using programmersdigest.MT940Parser;
|
|||||||
namespace Fuchs.Services;
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MT940 bank statement parsing service. Replaces the static <c>Banking</c> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BankingService : IBankingService
|
public class BankingService : IBankingService
|
||||||
{
|
{
|
||||||
@@ -29,14 +32,47 @@ public class BankingService : IBankingService
|
|||||||
|
|
||||||
public DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null)
|
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 sw = Stopwatch.StartNew();
|
||||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
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<string, object?>("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)
|
void SetNfo(DataRow nr, string key, object? value)
|
||||||
{
|
{
|
||||||
if (tbl.Columns.Contains(key) && value != null)
|
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||||
nr[key] = value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
using var ps = new Parser(stream: stream);
|
using var ps = new Parser(stream: stream);
|
||||||
@@ -85,24 +121,61 @@ public class BankingService : IBankingService
|
|||||||
|
|
||||||
tbl.Rows.Add(nr);
|
tbl.Rows.Add(nr);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex) { _logger.LogError(ex, "MT940 statement parse failed."); }
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "MT940 statement parse failed.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tbl.AcceptChanges();
|
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
|
||||||
sw.Stop();
|
private void FillFromCamt(DataTable tbl, byte[] bytes)
|
||||||
FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count);
|
{
|
||||||
act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
|
void SetNfo(DataRow nr, string key, object? value)
|
||||||
_logger.LogInformation("MT940 parsed {Rows} transaction lines in {Ms} ms", tbl.Rows.Count, sw.ElapsedMilliseconds);
|
{
|
||||||
return tbl;
|
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
private static DataTable BuildDefaultSchema()
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
namespace Fuchs.Services;
|
namespace Fuchs.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBankingService
|
public interface IBankingService
|
||||||
{
|
{
|
||||||
/// <summary>Abbreviation for a debit/credit mark.</summary>
|
/// <summary>Abbreviation for a debit/credit mark.</summary>
|
||||||
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark);
|
string DebitCreditMarkAbb(programmersdigest.MT940Parser.DebitCreditMark mark);
|
||||||
|
|
||||||
/// <summary>Parses an MT940 stream into a DataTable.</summary>
|
/// <summary>
|
||||||
|
/// Parses a bank statement stream (MT940 or CAMT, auto-detected) into a DataTable
|
||||||
|
/// matching the banking-transactions schema.
|
||||||
|
/// </summary>
|
||||||
DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
|
DataTable ParseToDatatable(Stream stream, DataTable? schemaDatatable = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ let $bcol = {
|
|||||||
{ name: 'EndToEndReference', label: 'Referenz', type: 'string' }
|
{ name: 'EndToEndReference', label: 'Referenz', type: 'string' }
|
||||||
]),
|
]),
|
||||||
bsu: new fields_definition('Kontobericht', 'Kontoberichte', [
|
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);
|
let gi = (n, c) => $$.sc(`glyphicon glyphicon-${n}`).aC(c);
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
<BuildType Solution="db-dev.processweb.de|Any CPU" Project="Debug" />
|
<BuildType Solution="db-dev.processweb.de|Any CPU" Project="Debug" />
|
||||||
<BuildType Solution="server02.processweb.de|Any CPU" Project="Debug" />
|
<BuildType Solution="server02.processweb.de|Any CPU" Project="Debug" />
|
||||||
</Project>
|
</Project>
|
||||||
|
<Project Path="CAMTParser/CAMTParser.csproj">
|
||||||
|
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
|
||||||
|
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
|
||||||
|
</Project>
|
||||||
<Project Path="Fuchs.Tests/Fuchs.Tests.csproj">
|
<Project Path="Fuchs.Tests/Fuchs.Tests.csproj">
|
||||||
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
|
<BuildType Solution="db-dev.processweb.de|*" Project="Debug" />
|
||||||
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
|
<BuildType Solution="server02.processweb.de|*" Project="Debug" />
|
||||||
|
|||||||
Reference in New Issue
Block a user