From 861c801b15c655b7759b1c849d27855ab77f13b6 Mon Sep 17 00:00:00 2001 From: Wolfram Arnold Date: Thu, 16 Jul 2026 18:37:14 +0200 Subject: [PATCH] Add opt-in SEPA Instant (SCT Inst / Echtzeitueberweisung) to credit transfers Under the EU Instant Payments Regulation (2024/886) euro-area PSPs must offer instant credit transfers at price parity with standard ones, so users increasingly need to flag a SEPA batch as instant. The EPC/DK mechanism is PmtTpInf/LclInstrm/Cd=INST, which sepaxml could emit for direct debits but not for credit transfers. - transfer.py: when config['instant'] is set, emit INST directly after SvcLvl inside PmtTpInf, preserving the ISO 20022 PaymentTypeInformation sequence (InstrPrty?, SvcLvl?, LclInstrm?, CtgyPurp?). Mirrors the existing SepaDD precedent, which already sets LclInstrm/Cd from config['instrument']. Gated inside the existing non-domestic (SEPA) guard, since INST is a SEPA local instrument. - The flag is config-level, not per-payment: PmtTpInf is a payment-information-block element shared by all grouped transfers, so a per-payment flag is ambiguous for mixed batches. - tests/transfer/test_instant.py: INST present directly after SvcLvl (batch and non-batch), XSD-valid, and absent without the flag. - README: document the config['instant'] flag next to 'domestic'. --- README.rst | 1 + sepaxml/transfer.py | 13 ++ tests/transfer/test_instant.py | 219 +++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 tests/transfer/test_instant.py diff --git a/README.rst b/README.rst index 8189860..4601811 100644 --- a/README.rst +++ b/README.rst @@ -122,6 +122,7 @@ Example: "BIC": "BANKNL2A", "batch": True, # For non-SEPA transfers, set "domestic" to True, necessary e.g. for CH/LI + # For SEPA Instant (SCT Inst / Echtzeitüberweisung), set "instant" to True "currency": "EUR", # ISO 4217 "address": { # The address and all of its fields are optional but in some countries they are required diff --git a/sepaxml/transfer.py b/sepaxml/transfer.py index 669632f..829c6b1 100644 --- a/sepaxml/transfer.py +++ b/sepaxml/transfer.py @@ -108,6 +108,8 @@ def add_payment(self, payment): ) if not self._config.get('domestic', False): PmtInf_nodes['Cd_SvcLvl_Node'].text = "SEPA" + if self._config.get('instant', False): + PmtInf_nodes['Cd_LclInstrm_Node'].text = "INST" if 'execution_date' in payment: if self.schema == "pain.001.001.03": PmtInf_nodes['ReqdExctnDtNode'].text = payment['execution_date'] @@ -226,6 +228,9 @@ def _create_PmtInf_node(self): if not self._config.get('domestic', False): ED['SvcLvlNode'] = ET.Element("SvcLvl") ED['Cd_SvcLvl_Node'] = ET.Element("Cd") + if self._config.get('instant', False): + ED['LclInstrmNode'] = ET.Element("LclInstrm") + ED['Cd_LclInstrm_Node'] = ET.Element("Cd") ED['ReqdExctnDtNode'] = ET.Element("ReqdExctnDt") ED['ReqdExctnDt_Dt_Node'] = ET.Element("Dt") @@ -287,6 +292,9 @@ def _add_non_batch(self, TX_nodes, PmtInf_nodes): if not self._config.get('domestic', False): PmtInf_nodes['SvcLvlNode'].append(PmtInf_nodes['Cd_SvcLvl_Node']) PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SvcLvlNode']) + if self._config.get('instant', False): + PmtInf_nodes['LclInstrmNode'].append(PmtInf_nodes['Cd_LclInstrm_Node']) + PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['LclInstrmNode']) PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtTpInfNode']) if 'ReqdExctnDtNode' in PmtInf_nodes: PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ReqdExctnDtNode']) @@ -400,6 +408,8 @@ def _finalize_batch(self): PmtInf_nodes['BtchBookgNode'].text = "true" if not self._config.get('domestic', False): PmtInf_nodes['Cd_SvcLvl_Node'].text = "SEPA" + if self._config.get('instant', False): + PmtInf_nodes['Cd_LclInstrm_Node'].text = "INST" if batch_meta: if self.schema == "pain.001.001.03": @@ -438,6 +448,9 @@ def _finalize_batch(self): if not self._config.get('domestic', False): PmtInf_nodes['SvcLvlNode'].append(PmtInf_nodes['Cd_SvcLvl_Node']) PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['SvcLvlNode']) + if self._config.get('instant', False): + PmtInf_nodes['LclInstrmNode'].append(PmtInf_nodes['Cd_LclInstrm_Node']) + PmtInf_nodes['PmtTpInfNode'].append(PmtInf_nodes['LclInstrmNode']) PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['PmtTpInfNode']) if 'ReqdExctnDtNode' in PmtInf_nodes: PmtInf_nodes['PmtInfNode'].append(PmtInf_nodes['ReqdExctnDtNode']) diff --git a/tests/transfer/test_instant.py b/tests/transfer/test_instant.py new file mode 100644 index 0000000..302ab53 --- /dev/null +++ b/tests/transfer/test_instant.py @@ -0,0 +1,219 @@ +import datetime + +import pytest + +from sepaxml import SepaTransfer +from tests.utils import clean_ids, validate_xml + + +@pytest.fixture +def strf_instant(): + return SepaTransfer({ + "name": "TestCreditor", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "batch": True, + "instant": True, + "currency": "EUR" + }, schema="pain.001.001.03") + + +# Identical to tests/transfer/test_00100103.py's SAMPLE_RESULT, but with the +# SEPA Instant local instrument (LclInstrm/Cd=INST) emitted directly after +# SvcLvl inside PmtTpInf -- the ISO 20022 PaymentTypeInformation sequence is +# InstrPrty?, SvcLvl?, LclInstrm?, CtgyPurp?. +SAMPLE_RESULT = b""" + + + + 20180724040432-d24ce3b3e284 + 2018-07-24T16:04:32 + 2 + 60.12 + + TestCreditor + + + + TestCreditor-90102652f82a + TRF + true + 2 + 60.12 + + + SEPA + + + INST + + + 2018-07-24 + + TestCreditor + + + + NL50BANK1234567890 + + + + + BANKNL2A + + + SLEV + + + NOTPROVIDED + + + 10.12 + + + + BANKNL2A + + + + Test von Testenstein + + + + NL50BANK1234567890 + + + + Test transaction1 + + + + + NOTPROVIDED + + + 50.00 + + + + BANKNL2A + + + + Test du Test + + + + NL50BANK1234567890 + + + + Test transaction2 + + + + + +""" + + +def test_instant_batch_emits_inst_after_svclvl(strf_instant): + payment1 = { + "name": "Test von Testenstein", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "amount": 1012, + "execution_date": datetime.date.today(), + "description": "Test transaction1" + } + payment2 = { + "name": "Test du Test", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "amount": 5000, + "execution_date": datetime.date.today(), + "description": "Test transaction2" + } + + strf_instant.add_payment(payment1) + strf_instant.add_payment(payment2) + xmlout = strf_instant.export() + xmlpretty = validate_xml(xmlout, "pain.001.001.03") + assert clean_ids(xmlpretty.strip()) == clean_ids(SAMPLE_RESULT.strip()) + + +def test_instant_non_batch_emits_inst_after_svclvl(): + strf = SepaTransfer({ + "name": "TestCreditor", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "batch": False, + "instant": True, + "currency": "EUR" + }, schema="pain.001.001.03") + + strf.add_payment({ + "name": "Test von Testenstein", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "amount": 1012, + "execution_date": datetime.date.today(), + "description": "Test transaction1" + }) + + xmlout = strf.export() + # export() output is not pretty-printed, so the elements are adjacent: + # asserting the compact substring pins both presence and SvcLvl->LclInstrm order. + assert b"SEPAINST" in xmlout + validate_xml(xmlout, "pain.001.001.03") + + +def test_no_instant_flag_emits_no_lclinstrm(): + """Without the flag, the transfer is a plain SEPA transfer -- no INST.""" + strf = SepaTransfer({ + "name": "TestCreditor", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "batch": True, + "currency": "EUR" + }, schema="pain.001.001.03") + + strf.add_payment({ + "name": "Test von Testenstein", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "amount": 1012, + "execution_date": datetime.date.today(), + "description": "Test transaction1" + }) + + xmlout = strf.export() + assert b"LclInstrm" not in xmlout + assert b"INST" not in xmlout + validate_xml(xmlout, "pain.001.001.03") + + +def test_instant_false_emits_no_lclinstrm(): + """instant=False is the verified plain-SEPA fallback -- no INST node.""" + strf = SepaTransfer({ + "name": "TestCreditor", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "batch": True, + "instant": False, + "currency": "EUR" + }, schema="pain.001.001.03") + + strf.add_payment({ + "name": "Test von Testenstein", + "IBAN": "NL50BANK1234567890", + "BIC": "BANKNL2A", + "amount": 1012, + "execution_date": datetime.date.today(), + "description": "Test transaction1" + }) + + xmlout = strf.export() + assert b"LclInstrm" not in xmlout + assert b"INST" not in xmlout + validate_xml(xmlout, "pain.001.001.03")