From 73d4715924c89e1ad95b8e4493a2a1f1c8868cdb Mon Sep 17 00:00:00 2001 From: Vaibhav Raina Date: Sat, 5 Sep 2026 13:27:27 +0530 Subject: [PATCH] Make export() idempotent instead of duplicating payments Calling export() twice on the same SepaTransfer or SepaDD doubled the transaction count and control sums, because _finalize_batch() appended the batch nodes to the live document on every call. This bites anyone who exports once to validate or preview and again to write the file. Finalize a copy of the document instead, so repeated exports (including after adding more payments) always reflect exactly the payments that were added. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PtpcFAThUxrGAyCyxiiEC5 --- sepaxml/shared.py | 17 +++++++---- tests/test_repeated_export.py | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 tests/test_repeated_export.py diff --git a/sepaxml/shared.py b/sepaxml/shared.py index 6bdff06..04b2062 100644 --- a/sepaxml/shared.py +++ b/sepaxml/shared.py @@ -21,6 +21,7 @@ """ import xml.etree.ElementTree as ET from collections import OrderedDict +from copy import copy, deepcopy from .utils import decimal_str_to_int, int_to_decimal_str, make_msg_id from .validation import try_valid_xml @@ -84,25 +85,31 @@ def export(self, validate=True, pretty_print=False): Method to output the xml as string. It will finalize the batches and then calculate the checksums (amount sum and transaction count), fill these into the group header and output the XML. + Exporting repeatedly does not add payments or change the totals. @param pretty_print: uses Python's xml.dom.minidom.Node.toprettyxml to make it easier to read for humans """ - self._finalize_batch() + # Finalization appends batches and fills the group totals. Keep those + # changes on a working document so exporting again starts from the + # original payments, including any added since the previous export. + document = copy(self) + document._xml = deepcopy(self._xml) + document._finalize_batch() ctrl_sum_total = 0 nb_of_txs_total = 0 - for ctrl_sum in self._xml.iter('CtrlSum'): + for ctrl_sum in document._xml.iter('CtrlSum'): if ctrl_sum.text is None: continue ctrl_sum_total += decimal_str_to_int(ctrl_sum.text) - for nb_of_txs in self._xml.iter('NbOfTxs'): + for nb_of_txs in document._xml.iter('NbOfTxs'): if nb_of_txs.text is None: continue nb_of_txs_total += int(nb_of_txs.text) - n = self._xml.find(self.root_el) + n = document._xml.find(self.root_el) GrpHdr_node = n.find('GrpHdr') CtrlSum_node = GrpHdr_node.find('CtrlSum') NbOfTxs_node = GrpHdr_node.find('NbOfTxs') @@ -112,7 +119,7 @@ def export(self, validate=True, pretty_print=False): # Prepending the XML version is hacky, but cElementTree only offers this # automatically if you write to a file, which we don't necessarily want. out = b"" + ET.tostring( - self._xml, "utf-8") + document._xml, "utf-8") if pretty_print: from xml.dom import minidom diff --git a/tests/test_repeated_export.py b/tests/test_repeated_export.py new file mode 100644 index 0000000..d69f3c0 --- /dev/null +++ b/tests/test_repeated_export.py @@ -0,0 +1,56 @@ +import datetime +from decimal import Decimal +from xml.etree import ElementTree as ET + +import pytest + +from sepaxml import SepaDD, SepaTransfer + + +@pytest.mark.parametrize("batch", [True, False]) +@pytest.mark.parametrize("payment_class,schema,transaction_tag", [ + (SepaDD, "pain.008.001.02", "DrctDbtTxInf"), + (SepaTransfer, "pain.001.001.03", "CdtTrfTxInf"), +]) +def test_repeated_export_preserves_payments(batch, payment_class, schema, transaction_tag): + document = payment_class({ + "name": "TestCreditor", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "batch": batch, + "creditor_id": "DE26ZZZ00000000000", + "currency": "EUR", + }, schema=schema) + payment = { + "name": "Test Debtor", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "amount": 1012, + "type": "RCUR", + "execution_date": datetime.date(2026, 1, 5), + "collection_date": datetime.date(2026, 1, 5), + "mandate_id": "1234", + "mandate_date": datetime.date(2025, 1, 5), + "description": "Test transaction", + } + document.add_payment(payment.copy()) + document.add_payment(dict(payment, amount=5000)) + namespace = {"s": "urn:iso:std:iso:20022:tech:xsd:" + schema} + + def check_export(expected_count, expected_sum, pretty_print=False): + xml = ET.fromstring(document.export(pretty_print=pretty_print)) + header = xml.find(".//s:GrpHdr", namespace) + assert header.find("s:NbOfTxs", namespace).text == str(expected_count) + assert header.find("s:CtrlSum", namespace).text == expected_sum + assert len(xml.findall(".//s:" + transaction_tag, namespace)) == expected_count + batches = xml.findall(".//s:PmtInf", namespace) + assert sum(int(b.find("s:NbOfTxs", namespace).text) for b in batches) == expected_count + assert sum(Decimal(b.find("s:CtrlSum", namespace).text) for b in batches) == Decimal(expected_sum) + + check_export(2, "60.12") + check_export(2, "60.12", pretty_print=True) + check_export(2, "60.12") + + document.add_payment(dict(payment, amount=123)) + check_export(3, "61.35") + check_export(3, "61.35")