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