From 224342e39af5f7614e45bf9675aa8add51485005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorbj=C3=B8rn=20Lindeijer?= Date: Mon, 14 Sep 2026 12:56:41 +0200 Subject: [PATCH] CAMT: join split Ustrd lines with a space, find names without Pty Banks split the remittance information into several Ustrd elements, one field per line, and _parse_element concatenated them with nothing in between, so "NR XXXX 1234 MUSTERSTADT DE" + "KAUFUMSATZ" came out as "...MUSTERSTADT DEKAUFUMSATZ". ING's own CSV export renders these lines with a space, which is what a reader expects. A space is now inserted unless one side already borders on whitespace, so chunks split mid-field with a trailing blank ("CRED: " + "DE00ZZZ...", as in the existing test) are unchanged. camt.052.001.02, which ING serves, has no Pty wrapper around party names, so applicant_name and recipient_name were always None there. Both spellings are tried now. Verified against a real 90-day ING report: all 352 entries get a name, and the joined purpose matches the bank's own export text. --- fints/camt_parser.py | 60 +++++++++++++--- tests/test_camt_parser_052_001_02.py | 101 +++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 tests/test_camt_parser_052_001_02.py diff --git a/fints/camt_parser.py b/fints/camt_parser.py index a284124..942b41c 100644 --- a/fints/camt_parser.py +++ b/fints/camt_parser.py @@ -2897,6 +2897,32 @@ def _modify_key(clean_mnems, translate=True): return clean_mnems +def _join_repeated(existing, addition): + """Join the text of a repeated element onto what came before. + + Banks split remittance information into several Ustrd elements, one + field per line. ING's own CSV export renders those with a space in + between ("NR XXXX 1234 MUSTERSTADT DE KAUFUMSATZ 12.01 30.95 ..."), + which is what a reader expects, so a space is inserted unless one side + already ends or starts with whitespace, as with chunks split mid-field + that carry a trailing blank ("CRED: " + "DE00ZZZ123456789"). + """ + existing = existing or '' + addition = addition or '' + if not existing or not addition or existing[-1].isspace() or addition[0].isspace(): + return existing + addition + return existing + ' ' + addition + + +def _first(record, *keys): + """The first of several keys that is present, or None.""" + for key in keys: + value = record.get(key) + if value is not None: + return value + return None + + def _parse_element(element, parent_name='', translate=True): data_dict = {} for child in element: @@ -2907,7 +2933,7 @@ def _parse_element(element, parent_name='', translate=True): child_name, translate=translate)) elif child_name in data_dict: - data_dict[child_name] += child.text + data_dict[child_name] = _join_repeated(data_dict[child_name], child.text) else: data_dict[child_name] = child.text @@ -2925,26 +2951,38 @@ def _add_backwards_compat_keys(record, currency): record["applicant_iban"] = record.get( "EntryDetails.TransactionDetails.RelatedParties.DebtorAccount.Identification.IBAN" ) - record["applicant_name"] = record.get( - "EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name" + record["applicant_name"] = _first( + record, + "EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name", + "EntryDetails.TransactionDetails.RelatedParties.Debtor.Name", ) - record["recipient_name"] = record.get( - "EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name" + record["recipient_name"] = _first( + record, + "EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name", + # camt.052.001.02 (ING) has no Pty wrapper around the name. + "EntryDetails.TransactionDetails.RelatedParties.Creditor.Name", ) record["status"] = "C" else: record["amount"] = Amount(-amt, currency) - record["applicant_creditor_id"] = record.get( - "EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Identification.PrivateIdentification.Other.Identification" + record["applicant_creditor_id"] = _first( + record, + "EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Identification.PrivateIdentification.Other.Identification", + "EntryDetails.TransactionDetails.RelatedParties.Creditor.Identification.PrivateIdentification.Other.Identification", ) record["applicant_iban"] = record.get( "EntryDetails.TransactionDetails.RelatedParties.CreditorAccount.Identification.IBAN" ) - record["applicant_name"] = record.get( - "EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name" + record["applicant_name"] = _first( + record, + "EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name", + # camt.052.001.02 (ING) has no Pty wrapper around the name. + "EntryDetails.TransactionDetails.RelatedParties.Creditor.Name", ) - record["recipient_name"] = record.get( - "EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name" + record["recipient_name"] = _first( + record, + "EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name", + "EntryDetails.TransactionDetails.RelatedParties.Debtor.Name", ) record["status"] = "D" diff --git a/tests/test_camt_parser_052_001_02.py b/tests/test_camt_parser_052_001_02.py new file mode 100644 index 0000000..f135354 --- /dev/null +++ b/tests/test_camt_parser_052_001_02.py @@ -0,0 +1,101 @@ +import datetime +from decimal import Decimal + +from fints.camt_parser import _join_repeated, camt053_to_dict +from fints.models import Amount + +# Modelled on what ING (camt.052.001.02) returns: no Pty wrapper around +# party names, and the remittance information split into one Ustrd per +# field rather than one per 35 characters. +data = b""" + + + + 1 + 2026-01-15T12:00:00+01:00 + + + 1 + 2026-01-15T12:00:00+01:00 + + DE02120300000000202051 + EUR + + + 30.95 + DBIT + BOOK +
2026-01-14
+
2026-01-14
+ + + + + VISA SUPERMARKT MUSTERSTADT + + + + NR XXXX 1234 MUSTERSTADT DE + KAUFUMSATZ + 12.01 30.95 + 123456 + ARN00000000000000000000000 + + + + Lastschrifteinzug +
+ + 22.00 + CRDT + BOOK +
2026-01-12
+
2026-01-12
+ + + + + Erika Musterfrau + + + DE02500105170137075030 + + + + Taschengeld Januar + + + + Gutschrift +
+
+
+
+""" + + +def test_join_repeated(): + # Fields on separate lines get a space between them. + assert _join_repeated("NR XXXX 1234 MUSTERSTADT DE", "KAUFUMSATZ") == "NR XXXX 1234 MUSTERSTADT DE KAUFUMSATZ" + # A chunk that already ends in whitespace is left alone. + assert _join_repeated("CRED: ", "DE00ZZZ123456789") == "CRED: DE00ZZZ123456789" + assert _join_repeated("CRED:", " DE00ZZZ123456789") == "CRED: DE00ZZZ123456789" + # Missing text does not raise and adds nothing. + assert _join_repeated("abc", None) == "abc" + assert _join_repeated(None, "abc") == "abc" + + +def test_parse_without_party_wrapper(): + debit, credit = camt053_to_dict(data) + + assert debit["amount"] == Amount(Decimal("-30.95"), "EUR") + assert debit["applicant_name"] == "VISA SUPERMARKT MUSTERSTADT " + assert debit["purpose"] == "NR XXXX 1234 MUSTERSTADT DE KAUFUMSATZ 12.01 30.95 123456 ARN00000000000000000000000" + assert debit["AdditionalEntryInformation"] == "Lastschrifteinzug" + assert debit["entry_date"] == datetime.date(2026, 1, 14) + + assert credit["amount"] == Amount(Decimal("22.00"), "EUR") + assert credit["applicant_name"] == "Erika Musterfrau" + assert credit["applicant_iban"] == "DE02500105170137075030" + assert credit["purpose"] == "Taschengeld Januar" + assert credit["AdditionalEntryInformation"] == "Gutschrift"