Skip to content

Commit 224342e

Browse files
committed
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.
1 parent e3c916c commit 224342e

2 files changed

Lines changed: 150 additions & 11 deletions

File tree

‎fints/camt_parser.py‎

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2897,6 +2897,32 @@ def _modify_key(clean_mnems, translate=True):
28972897
return clean_mnems
28982898

28992899

2900+
def _join_repeated(existing, addition):
2901+
"""Join the text of a repeated element onto what came before.
2902+
2903+
Banks split remittance information into several Ustrd elements, one
2904+
field per line. ING's own CSV export renders those with a space in
2905+
between ("NR XXXX 1234 MUSTERSTADT DE KAUFUMSATZ 12.01 30.95 ..."),
2906+
which is what a reader expects, so a space is inserted unless one side
2907+
already ends or starts with whitespace, as with chunks split mid-field
2908+
that carry a trailing blank ("CRED: " + "DE00ZZZ123456789").
2909+
"""
2910+
existing = existing or ''
2911+
addition = addition or ''
2912+
if not existing or not addition or existing[-1].isspace() or addition[0].isspace():
2913+
return existing + addition
2914+
return existing + ' ' + addition
2915+
2916+
2917+
def _first(record, *keys):
2918+
"""The first of several keys that is present, or None."""
2919+
for key in keys:
2920+
value = record.get(key)
2921+
if value is not None:
2922+
return value
2923+
return None
2924+
2925+
29002926
def _parse_element(element, parent_name='', translate=True):
29012927
data_dict = {}
29022928
for child in element:
@@ -2907,7 +2933,7 @@ def _parse_element(element, parent_name='', translate=True):
29072933
child_name,
29082934
translate=translate))
29092935
elif child_name in data_dict:
2910-
data_dict[child_name] += child.text
2936+
data_dict[child_name] = _join_repeated(data_dict[child_name], child.text)
29112937
else:
29122938
data_dict[child_name] = child.text
29132939

@@ -2925,26 +2951,38 @@ def _add_backwards_compat_keys(record, currency):
29252951
record["applicant_iban"] = record.get(
29262952
"EntryDetails.TransactionDetails.RelatedParties.DebtorAccount.Identification.IBAN"
29272953
)
2928-
record["applicant_name"] = record.get(
2929-
"EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name"
2954+
record["applicant_name"] = _first(
2955+
record,
2956+
"EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name",
2957+
"EntryDetails.TransactionDetails.RelatedParties.Debtor.Name",
29302958
)
2931-
record["recipient_name"] = record.get(
2932-
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name"
2959+
record["recipient_name"] = _first(
2960+
record,
2961+
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name",
2962+
# camt.052.001.02 (ING) has no Pty wrapper around the name.
2963+
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Name",
29332964
)
29342965
record["status"] = "C"
29352966
else:
29362967
record["amount"] = Amount(-amt, currency)
2937-
record["applicant_creditor_id"] = record.get(
2938-
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Identification.PrivateIdentification.Other.Identification"
2968+
record["applicant_creditor_id"] = _first(
2969+
record,
2970+
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Identification.PrivateIdentification.Other.Identification",
2971+
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Identification.PrivateIdentification.Other.Identification",
29392972
)
29402973
record["applicant_iban"] = record.get(
29412974
"EntryDetails.TransactionDetails.RelatedParties.CreditorAccount.Identification.IBAN"
29422975
)
2943-
record["applicant_name"] = record.get(
2944-
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name"
2976+
record["applicant_name"] = _first(
2977+
record,
2978+
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Party.Name",
2979+
# camt.052.001.02 (ING) has no Pty wrapper around the name.
2980+
"EntryDetails.TransactionDetails.RelatedParties.Creditor.Name",
29452981
)
2946-
record["recipient_name"] = record.get(
2947-
"EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name"
2982+
record["recipient_name"] = _first(
2983+
record,
2984+
"EntryDetails.TransactionDetails.RelatedParties.Debtor.Party.Name",
2985+
"EntryDetails.TransactionDetails.RelatedParties.Debtor.Name",
29482986
)
29492987
record["status"] = "D"
29502988

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import datetime
2+
from decimal import Decimal
3+
4+
from fints.camt_parser import _join_repeated, camt053_to_dict
5+
from fints.models import Amount
6+
7+
# Modelled on what ING (camt.052.001.02) returns: no Pty wrapper around
8+
# party names, and the remittance information split into one Ustrd per
9+
# field rather than one per 35 characters.
10+
data = b"""<?xml version="1.0" encoding="UTF-8"?>
11+
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02">
12+
<BkToCstmrAcctRpt>
13+
<GrpHdr>
14+
<MsgId>1</MsgId>
15+
<CreDtTm>2026-01-15T12:00:00+01:00</CreDtTm>
16+
</GrpHdr>
17+
<Rpt>
18+
<Id>1</Id>
19+
<CreDtTm>2026-01-15T12:00:00+01:00</CreDtTm>
20+
<Acct>
21+
<Id><IBAN>DE02120300000000202051</IBAN></Id>
22+
<Ccy>EUR</Ccy>
23+
</Acct>
24+
<Ntry>
25+
<Amt Ccy="EUR">30.95</Amt>
26+
<CdtDbtInd>DBIT</CdtDbtInd>
27+
<Sts>BOOK</Sts>
28+
<BookgDt><Dt>2026-01-14</Dt></BookgDt>
29+
<ValDt><Dt>2026-01-14</Dt></ValDt>
30+
<NtryDtls>
31+
<TxDtls>
32+
<RltdPties>
33+
<Cdtr>
34+
<Nm>VISA SUPERMARKT MUSTERSTADT </Nm>
35+
</Cdtr>
36+
</RltdPties>
37+
<RmtInf>
38+
<Ustrd>NR XXXX 1234 MUSTERSTADT DE</Ustrd>
39+
<Ustrd>KAUFUMSATZ</Ustrd>
40+
<Ustrd>12.01 30.95</Ustrd>
41+
<Ustrd>123456</Ustrd>
42+
<Ustrd>ARN00000000000000000000000</Ustrd>
43+
</RmtInf>
44+
</TxDtls>
45+
</NtryDtls>
46+
<AddtlNtryInf>Lastschrifteinzug</AddtlNtryInf>
47+
</Ntry>
48+
<Ntry>
49+
<Amt Ccy="EUR">22.00</Amt>
50+
<CdtDbtInd>CRDT</CdtDbtInd>
51+
<Sts>BOOK</Sts>
52+
<BookgDt><Dt>2026-01-12</Dt></BookgDt>
53+
<ValDt><Dt>2026-01-12</Dt></ValDt>
54+
<NtryDtls>
55+
<TxDtls>
56+
<RltdPties>
57+
<Dbtr>
58+
<Nm>Erika Musterfrau</Nm>
59+
</Dbtr>
60+
<DbtrAcct>
61+
<Id><IBAN>DE02500105170137075030</IBAN></Id>
62+
</DbtrAcct>
63+
</RltdPties>
64+
<RmtInf>
65+
<Ustrd>Taschengeld Januar</Ustrd>
66+
</RmtInf>
67+
</TxDtls>
68+
</NtryDtls>
69+
<AddtlNtryInf>Gutschrift</AddtlNtryInf>
70+
</Ntry>
71+
</Rpt>
72+
</BkToCstmrAcctRpt>
73+
</Document>
74+
"""
75+
76+
77+
def test_join_repeated():
78+
# Fields on separate lines get a space between them.
79+
assert _join_repeated("NR XXXX 1234 MUSTERSTADT DE", "KAUFUMSATZ") == "NR XXXX 1234 MUSTERSTADT DE KAUFUMSATZ"
80+
# A chunk that already ends in whitespace is left alone.
81+
assert _join_repeated("CRED: ", "DE00ZZZ123456789") == "CRED: DE00ZZZ123456789"
82+
assert _join_repeated("CRED:", " DE00ZZZ123456789") == "CRED: DE00ZZZ123456789"
83+
# Missing text does not raise and adds nothing.
84+
assert _join_repeated("abc", None) == "abc"
85+
assert _join_repeated(None, "abc") == "abc"
86+
87+
88+
def test_parse_without_party_wrapper():
89+
debit, credit = camt053_to_dict(data)
90+
91+
assert debit["amount"] == Amount(Decimal("-30.95"), "EUR")
92+
assert debit["applicant_name"] == "VISA SUPERMARKT MUSTERSTADT "
93+
assert debit["purpose"] == "NR XXXX 1234 MUSTERSTADT DE KAUFUMSATZ 12.01 30.95 123456 ARN00000000000000000000000"
94+
assert debit["AdditionalEntryInformation"] == "Lastschrifteinzug"
95+
assert debit["entry_date"] == datetime.date(2026, 1, 14)
96+
97+
assert credit["amount"] == Amount(Decimal("22.00"), "EUR")
98+
assert credit["applicant_name"] == "Erika Musterfrau"
99+
assert credit["applicant_iban"] == "DE02500105170137075030"
100+
assert credit["purpose"] == "Taschengeld Januar"
101+
assert credit["AdditionalEntryInformation"] == "Gutschrift"

0 commit comments

Comments
 (0)