From 066989aa86b9e8e3ec7bae65e0b6a004124c92f8 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:19:13 +0200 Subject: [PATCH 01/19] Add get_standing_orders() to fetch SEPA standing orders (HKCDB/HICDB/HICDBS) --- fints/client.py | 33 +++++++++++++++++++++++++++ fints/formals.py | 35 ++++++++++++++++++++++++++++ fints/segments/standing_order.py | 39 ++++++++++++++++++++++++++++++++ tests/conftest.py | 25 ++++++++++++++++++++ tests/test_client.py | 15 ++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 fints/segments/standing_order.py diff --git a/fints/client.py b/fints/client.py index 213972a..8a975ae 100644 --- a/fints/client.py +++ b/fints/client.py @@ -38,6 +38,7 @@ from .segments.dialog import HIRMG2, HIRMS2, HISYN4, HKSYN3 from .segments.journal import HKPRO3, HKPRO4 from .segments.saldo import HKSAL5, HKSAL6, HKSAL7 +from .segments.standing_order import HKCDB1 from .segments.statement import DKKKU2, HKKAZ5, HKKAZ6, HKKAZ7, HKCAZ1, HKKAU2, HKKAU1, HKEKA3, HKEKA4, HKEKA5 from .segments.transfer import HKCCM1, HKCCS1, HKIPZ1, HKIPM1 from .types import SegmentSequence @@ -741,6 +742,38 @@ def get_scheduled_debits(self, account: SEPAAccount, multiple=False): return responses + def get_standing_orders(self, account: SEPAAccount, task_id: str = None): + """ + Retrieve SEPA standing orders (Daueraufträge) of an account. + + :param account: SEPAAccount to retrieve standing orders for. + :param task_id: Only retrieve the standing order with this task id (Auftragsidentifikation). + :return: List of HICDB objects, each containing the order as SEPA pain.001 message + """ + with self._get_dialog() as dialog: + hkcdb = self._find_highest_supported_command(HKCDB1) + + hispas = self.bpd.find_segment_first('HISPAS') + supported_formats = list(hispas.parameter.supported_sepa_formats) if hispas else [] + pain_descriptors = [f for f in supported_formats if 'pain.001' in f] or \ + ['urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'] + + responses = self._fetch_with_touchdowns( + dialog, + lambda touchdown: hkcdb( + account=hkcdb._fields['account'].type.from_sepa_account(account), + supported_sepa_pain_messages=hkcdb._fields['supported_sepa_pain_messages'].type( + sepa_descriptors=pain_descriptors, + ), + task_id=task_id, + touchdown_point=touchdown, + ), + lambda responses: responses, + 'HICDB', + ) + + return responses + def get_status_protocol(self): with self._get_dialog() as dialog: hkpro = self._find_highest_supported_command(HKPRO3, HKPRO4) diff --git a/fints/formals.py b/fints/formals.py index 2d9d907..1af4095 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -1001,6 +1001,41 @@ class QueryScheduledBatchDebitParameter1(DataElementGroup): date_range_allowed = DataElementField(type='jn', _d="Zeitraum möglich") +@doc_enum +class StandingOrderTimeUnit1(RepresentableEnum): + MONTHLY = 'M' # doc: Monatlich + WEEKLY = 'W' # doc: Wöchentlich + + +class StandingOrderDetails3(DataElementGroup): + """Dauerauftragsdetails, version 3 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ + first_execution_date = DataElementField(type='dat', _d="Erstmals ausführen am") + time_unit = CodeField(enum=StandingOrderTimeUnit1, length=1, _d="Zeiteinheit") + interval = DataElementField(type='num', max_length=2, _d="Turnus") + execution_day = DataElementField(type='num', max_length=2, _d="Ausführungstag") + last_execution_date = DataElementField(type='dat', required=False, _d="Letztmals ausführen am") + + +class StandingOrderSuspension3(DataElementGroup): + """Aussetzung, version 3 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ + annually_recurring = DataElementField(type='jn', _d="Aussetzung jährlich wiederkehrend") + suspension_start = DataElementField(type='dat', required=False, _d="Aussetzen von") + suspension_end = DataElementField(type='dat', required=False, _d="Aussetzen bis") + number_suspensions = DataElementField(type='num', max_length=2, required=False, _d="Anzahl Aussetzungen") + different_amount = DataElementGroupField(type=Amount1, required=False, _d="Abweichender Betrag") + + +class QueryStandingOrdersParameter1(DataElementGroup): + """Parameter SEPA-Dauerauftragsbestand, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ + max_number_responses_allowed = DataElementField(type='jn', _d="Eingabe Anzahl Einträge erlaubt") + + class QueryCreditCardStatements2(DataElementGroup): """Parameter Kreditkartenumsätze anfordern, version 2 diff --git a/fints/segments/standing_order.py b/fints/segments/standing_order.py new file mode 100644 index 0000000..4b5ae49 --- /dev/null +++ b/fints/segments/standing_order.py @@ -0,0 +1,39 @@ +from ..fields import DataElementField, DataElementGroupField +from ..formals import ( + KTI1, QueryStandingOrdersParameter1, StandingOrderDetails3, + StandingOrderSuspension3, SupportedSEPAPainMessages1, +) +from .base import FinTS3Segment, ParameterSegment + + +class HKCDB1(FinTS3Segment): + """SEPA-Dauerauftragsbestand anfordern, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + account = DataElementGroupField(type=KTI1, _d="Kontoverbindung international") + supported_sepa_pain_messages = DataElementGroupField(type=SupportedSEPAPainMessages1, _d="Unterstützte SEPA pain messages") + task_id = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation") + max_number_responses = DataElementField(type='num', max_length=4, required=False, _d="Maximale Anzahl Einträge") + touchdown_point = DataElementField(type='an', max_length=35, required=False, _d="Aufsetzpunkt") + + +class HICDB1(FinTS3Segment): + """SEPA-Dauerauftragsbestand rückmelden, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + account = DataElementGroupField(type=KTI1, _d="Kontoverbindung international") + sepa_descriptor = DataElementField(type='an', max_length=256, _d="SEPA Descriptor") + sepa_pain_message = DataElementField(type='bin', _d="SEPA pain message") + task_id = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation") + standing_order_details = DataElementGroupField(type=StandingOrderDetails3, _d="Dauerauftragsdetails") + suspension = DataElementGroupField(type=StandingOrderSuspension3, required=False, _d="Aussetzung") + task_changeable = DataElementField(type='jn', required=False, _d="Auftrag änderbar") + task_suspendable = DataElementField(type='jn', required=False, _d="Auftrag aussetzbar") + task_cancelable = DataElementField(type='jn', required=False, _d="Auftrag löschbar") + + +class HICDBS1(ParameterSegment): + """SEPA-Dauerauftragsbestand Parameter, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + parameter = DataElementGroupField(type=QueryStandingOrdersParameter1, _d="Parameter SEPA-Dauerauftragsbestand") diff --git a/tests/conftest.py b/tests/conftest.py index d584e8f..a801342 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,6 +167,31 @@ def make_answer(self, dialog_id, message): result.append("HIRMS::2:{}+0030::Auftragsfreigabe erforderlich'".format(hktan.group(1).decode('us-ascii')).encode('us-ascii')) result.append("HITAN::{}:{}+2++{}+{}'".format(hktan.group(2).decode('us-ascii'), hktan.group(1).decode('us-ascii'), ref, tanmsg).encode('us-ascii')) + hkcdb = re.search(rb"'HKCDB:(\d+):1\+", message) + if hkcdb: + segno = hkcdb.group(1).decode('us-ascii') + standing_orders = [ + ('ORDER1', 'Max Vermieter', 'DE02120300000000202051', '850.00', 'Miete', '20250101:M:1:1++J+J+J'), + ('ORDER2', 'Sportverein', 'DE02500105170137075030', '25.50', 'Beitrag', '20250215:M:3:15:20271231+N:20251101:20251130::0,:EUR+N+N+J'), + ] + for task_id, name, iban, amount, reason, details in standing_orders: + pain = ( + '' + '' + 'DE111234567800000001' + 'NOTPROVIDED' + '{}' + '{}{}' + '{}' + '' + ).format(amount, name, iban, reason).encode('utf-8') + result.append( + "HICDB::1:{}+DE111234567800000001:GENODE23X42+urn?:iso?:std?:iso?:20022?:tech?:xsd?:pain.001.001.03+@{}@".format( + segno, len(pain)).encode('us-ascii') + + pain + + "+{}+{}'".format(task_id, details).encode('us-ascii') + ) + hktan = re.search(rb"'HKTAN:(\d+):(\d+)\+2\+\+\+\+([^+]+)\+", message) if hktan: segno = hktan.group(1).decode('us-ascii') diff --git a/tests/test_client.py b/tests/test_client.py index f602e4f..285806a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -227,3 +227,18 @@ def test_get_transactions(fints_client): assert len(transactions) == 3 assert transactions[0].data['amount'].amount == Decimal('182.34') + + +def test_get_standing_orders(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + + standing_orders = fints_client.get_standing_orders(accounts[0]) + + assert len(standing_orders) == 2 + assert standing_orders[0].task_id == 'ORDER1' + assert standing_orders[0].standing_order_details.interval == 1 + assert b'Max Vermieter' in standing_orders[0].sepa_pain_message + assert standing_orders[1].suspension.annually_recurring is False + assert standing_orders[1].suspension.suspension_end.isoformat() == '2025-11-30' + assert standing_orders[1].task_cancelable is True From c8a3439aa07108b7e2ad7ebc8c1611876f494b79 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:52:55 +0200 Subject: [PATCH 02/19] Add docs for get_standing_orders() and standing order segments --- docs/developer/segments/all.rst | 10 +++++++++ docs/reading.rst | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/developer/segments/all.rst b/docs/developer/segments/all.rst index c5ff5bf..cae2e6f 100644 --- a/docs/developer/segments/all.rst +++ b/docs/developer/segments/all.rst @@ -102,6 +102,16 @@ fints.segments.saldo module :show-inheritance: :exclude-members: print_nested, naive_parse, find_subclass, is_unset +fints.segments.standing_order module +------------------------------------ + +.. automodule:: fints.segments.standing_order + :members: + :inherited-members: + :undoc-members: + :show-inheritance: + :exclude-members: print_nested, naive_parse, find_subclass, is_unset + fints.segments.statement module ------------------------------- diff --git a/docs/reading.rst b/docs/reading.rst index e34b91d..8fea585 100644 --- a/docs/reading.rst +++ b/docs/reading.rst @@ -74,3 +74,43 @@ You can fetch the holdings of an account with the ``get_holdings`` method: This method will return a list of ``Holding`` objects: .. autoclass:: fints.models.Holding + + +Fetching standing orders +------------------------ + +You can fetch the SEPA standing orders ("Daueraufträge") of an account with the ``get_standing_orders`` method: + +.. autoclass:: fints.client.FinTS3Client + :members: get_standing_orders + :noindex: + +This method will return a list of ``HICDB1`` segments, one per standing order. The schedule is available in +``standing_order_details`` (see :class:`~fints.formals.StandingOrderDetails3`), a suspension, if any, in +``suspension`` (see :class:`~fints.formals.StandingOrderSuspension3`). Recipient, amount and purpose are contained in +the SEPA pain.001 message in ``sepa_pain_message``, which you can parse e.g. with ``lxml``: + +.. code-block:: python + + from lxml import etree + + standing_orders = client.get_standing_orders(account) + while isinstance(standing_orders, NeedTANResponse): + standing_orders = ask_for_tan(standing_orders) + + for order in standing_orders: + details = order.standing_order_details + print("Task ID:", order.task_id) + print("First/last execution:", details.first_execution_date, details.last_execution_date) + print("Interval:", details.interval, details.time_unit, "on day", details.execution_day) + + # The pain.001 version depends on the bank, so match elements regardless of namespace + pain = etree.fromstring(order.sepa_pain_message) + for tx in pain.xpath('//*[local-name()="CdtTrfTxInf"]'): + def text(path): + found = tx.xpath('.//' + '/'.join('*[local-name()="{}"]'.format(p) for p in path.split('/'))) + return found[0].text if found else None + + print("Recipient:", text('Cdtr/Nm'), text('CdtrAcct/Id/IBAN')) + print("Amount:", text('Amt/InstdAmt')) + print("Purpose:", text('RmtInf/Ustrd')) From 2c70e18cf6ad656e7e679f262d9fbbbcf7c6a512 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:07:30 +0200 Subject: [PATCH 03/19] Add vdat field type for standing order execution dates --- docs/reading.rst | 10 +++++++++- fints/fields.py | 24 ++++++++++++++++++++++++ fints/formals.py | 4 ++-- fints/models.py | 20 ++++++++++++++++++++ tests/conftest.py | 2 +- tests/test_client.py | 1 + tests/test_formals.py | 29 +++++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/reading.rst b/docs/reading.rst index 8fea585..a7c7d6f 100644 --- a/docs/reading.rst +++ b/docs/reading.rst @@ -88,7 +88,15 @@ You can fetch the SEPA standing orders ("Daueraufträge") of an account with the This method will return a list of ``HICDB1`` segments, one per standing order. The schedule is available in ``standing_order_details`` (see :class:`~fints.formals.StandingOrderDetails3`), a suspension, if any, in ``suspension`` (see :class:`~fints.formals.StandingOrderSuspension3`). Recipient, amount and purpose are contained in -the SEPA pain.001 message in ``sepa_pain_message``, which you can parse e.g. with ``lxml``: +the SEPA pain.001 message in ``sepa_pain_message``, which you can parse e.g. with ``lxml``. + +The first and last execution dates are *virtual dates*, which allow 31 days in every month (e.g. 2025-04-31 for a +standing order executed on the last day of each month). They are returned as ``VirtualDate`` objects: + +.. autoclass:: fints.models.VirtualDate + :members: to_date + +Example: .. code-block:: python diff --git a/fints/fields.py b/fints/fields.py index 7f8d6e3..fad80c6 100644 --- a/fints/fields.py +++ b/fints/fields.py @@ -3,6 +3,7 @@ import re import warnings +from fints.models import VirtualDate from fints.types import Container, SegmentSequence, TypedField from fints.utils import ( DocTypeMixin, FieldRenderFormatStringMixin, FixedLengthMixin, Password, @@ -275,6 +276,29 @@ def _render_value(self, value): return super()._render_value(val) +class VirtualDateField(FixedLengthMixin, NumericField): + type = 'vdat' + _DOC_TYPE = VirtualDate + _FIXED_LENGTH = [8] + + def _parse_value(self, value): + if isinstance(value, VirtualDate): + return value + if isinstance(value, datetime.date): + return VirtualDate.from_date(value) + val = super()._parse_value(value) + val = str(val) + retval = VirtualDate(int(val[0:4]), int(val[4:6]), int(val[6:8])) + if not (1 <= retval.month <= 12 and 1 <= retval.day <= 31): + raise ValueError("Invalid virtual date: {!r}".format(value)) + return retval + + def _render_value(self, value): + val = "{:04d}{:02d}{:02d}".format(value.year, value.month, value.day) + val = int(val) + return super()._render_value(val) + + class TimeField(FixedLengthMixin, DigitsField): type = 'tim' # FIXME Need test _DOC_TYPE = datetime.time diff --git a/fints/formals.py b/fints/formals.py index 1af4095..5a57202 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -1011,11 +1011,11 @@ class StandingOrderDetails3(DataElementGroup): """Dauerauftragsdetails, version 3 Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ - first_execution_date = DataElementField(type='dat', _d="Erstmals ausführen am") + first_execution_date = DataElementField(type='vdat', _d="Erstmals ausführen am") time_unit = CodeField(enum=StandingOrderTimeUnit1, length=1, _d="Zeiteinheit") interval = DataElementField(type='num', max_length=2, _d="Turnus") execution_day = DataElementField(type='num', max_length=2, _d="Ausführungstag") - last_execution_date = DataElementField(type='dat', required=False, _d="Letztmals ausführen am") + last_execution_date = DataElementField(type='vdat', required=False, _d="Letztmals ausführen am") class StandingOrderSuspension3(DataElementGroup): diff --git a/fints/models.py b/fints/models.py index 7ed43ce..b472274 100644 --- a/fints/models.py +++ b/fints/models.py @@ -1,3 +1,5 @@ +import calendar +import datetime from collections import namedtuple SEPAAccount = namedtuple('SEPAAccount', 'iban bic accountnumber subaccount blz') @@ -10,3 +12,21 @@ Amount = namedtuple('Amount', 'amount currency') Transaction = namedtuple('Transaction', 'data') + + +class VirtualDate(namedtuple('VirtualDate', 'year month day')): + """Virtual date (FinTS format ``vdat``): Like a date, but every month may have 31 days, + e.g. 2025-04-31 as execution date of a standing order.""" + __slots__ = () + + @classmethod + def from_date(cls, date: datetime.date): + return cls(date.year, date.month, date.day) + + def to_date(self) -> datetime.date: + """Convert to a ``datetime.date``. Days beyond the end of the month are moved to the last day of the month.""" + last_day = calendar.monthrange(self.year, self.month)[1] + return datetime.date(self.year, self.month, min(self.day, last_day)) + + def __str__(self): + return "{:04d}-{:02d}-{:02d}".format(*self) diff --git a/tests/conftest.py b/tests/conftest.py index a801342..543ee9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -172,7 +172,7 @@ def make_answer(self, dialog_id, message): segno = hkcdb.group(1).decode('us-ascii') standing_orders = [ ('ORDER1', 'Max Vermieter', 'DE02120300000000202051', '850.00', 'Miete', '20250101:M:1:1++J+J+J'), - ('ORDER2', 'Sportverein', 'DE02500105170137075030', '25.50', 'Beitrag', '20250215:M:3:15:20271231+N:20251101:20251130::0,:EUR+N+N+J'), + ('ORDER2', 'Sportverein', 'DE02500105170137075030', '25.50', 'Beitrag', '20250215:M:3:15:20270431+N:20251101:20251130::0,:EUR+N+N+J'), ] for task_id, name, iban, amount, reason, details in standing_orders: pain = ( diff --git a/tests/test_client.py b/tests/test_client.py index 285806a..a0b33d7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -239,6 +239,7 @@ def test_get_standing_orders(fints_client): assert standing_orders[0].task_id == 'ORDER1' assert standing_orders[0].standing_order_details.interval == 1 assert b'Max Vermieter' in standing_orders[0].sepa_pain_message + assert str(standing_orders[1].standing_order_details.last_execution_date) == '2027-04-31' assert standing_orders[1].suspension.annually_recurring is False assert standing_orders[1].suspension.suspension_end.isoformat() == '2025-11-30' assert standing_orders[1].task_cancelable is True diff --git a/tests/test_formals.py b/tests/test_formals.py index d479110..85068b6 100644 --- a/tests/test_formals.py +++ b/tests/test_formals.py @@ -362,3 +362,32 @@ def test_find_by_class(): assert list(m.find_segments(HNHBS1))[0].__class__ == HNHBS1 assert m.find_segment_first(HNHBS1).header.type == 'HNHBS' + + +def test_virtual_date(): + import datetime + from fints.models import VirtualDate + from fints.parser import FinTS3Parser, FinTS3Serializer + from fints.segments.standing_order import HICDB1 # noqa: F401, registers the segment class + + class A(Container): + a = DataElementField(type='vdat') + + i1 = A.naive_parse(['20250431']) + assert i1.a == VirtualDate(2025, 4, 31) + assert i1.a.to_date() == datetime.date(2025, 4, 30) + assert str(i1.a) == '2025-04-31' + + i1.a = datetime.date(2025, 2, 28) + assert i1.a == VirtualDate(2025, 2, 28) + + with pytest.raises(ValueError): + A.naive_parse(['20251301']) + with pytest.raises(ValueError): + A.naive_parse(['20250432']) + + # Virtual dates must survive a parse/serialize roundtrip unchanged + message = b"HICDB:5:1:3+DE11:GENO+urn+@2@ab+ORDER1+20250131:M:1:31:20270431'" + seg = FinTS3Parser().parse_message(message).segments[0] + assert seg.standing_order_details.last_execution_date == VirtualDate(2027, 4, 31) + assert FinTS3Serializer().serialize_message(seg) == message From 45a40b0f1a9e18ec439828d402f1ea1ce275dfbc Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:34:17 +0200 Subject: [PATCH 04/19] Add delete_standing_order() to delete SEPA standing orders (HKCDL/HICDLS) --- docs/reading.rst | 2 ++ docs/transfers.rst | 28 ++++++++++++++++++++++++++++ fints/client.py | 32 +++++++++++++++++++++++++++++++- fints/formals.py | 10 ++++++++++ fints/segments/standing_order.py | 23 +++++++++++++++++++++-- tests/conftest.py | 6 ++++++ tests/test_client.py | 25 ++++++++++++++++++++++++- 7 files changed, 122 insertions(+), 4 deletions(-) diff --git a/docs/reading.rst b/docs/reading.rst index a7c7d6f..d7260b2 100644 --- a/docs/reading.rst +++ b/docs/reading.rst @@ -76,6 +76,8 @@ This method will return a list of ``Holding`` objects: .. autoclass:: fints.models.Holding +.. _standing-orders: + Fetching standing orders ------------------------ diff --git a/docs/transfers.rst b/docs/transfers.rst index c2e20c2..1d8f22f 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -102,3 +102,31 @@ Full example print(res.status) print(res.responses) + + +Deleting standing orders +------------------------ + +You can delete a SEPA standing order ("Dauerauftrag") by passing one of the objects returned by +``get_standing_orders`` (see :ref:`standing-orders`) to ``delete_standing_order``: + +.. autoclass:: fints.client.FinTS3Client + :members: delete_standing_order + :noindex: + +At any point, you might receive a `NeedTANResponse`. +You should then enter a TAN, read our chapter :ref:`tans` to find out more. + +.. code-block:: python + + standing_orders = client.get_standing_orders(account) + while isinstance(standing_orders, NeedTANResponse): + standing_orders = ask_for_tan(standing_orders) + + order = standing_orders[0] + res = client.delete_standing_order(account, order) + while isinstance(res, NeedTANResponse): + res = ask_for_tan(res) + + print(res.status) + print(res.responses) diff --git a/fints/client.py b/fints/client.py index 8a975ae..6f758bc 100644 --- a/fints/client.py +++ b/fints/client.py @@ -38,7 +38,7 @@ from .segments.dialog import HIRMG2, HIRMS2, HISYN4, HKSYN3 from .segments.journal import HKPRO3, HKPRO4 from .segments.saldo import HKSAL5, HKSAL6, HKSAL7 -from .segments.standing_order import HKCDB1 +from .segments.standing_order import HKCDB1, HKCDL1 from .segments.statement import DKKKU2, HKKAZ5, HKKAZ6, HKKAZ7, HKCAZ1, HKKAU2, HKKAU1, HKEKA3, HKEKA4, HKEKA5 from .segments.transfer import HKCCM1, HKCCS1, HKIPZ1, HKIPM1 from .types import SegmentSequence @@ -774,6 +774,36 @@ def get_standing_orders(self, account: SEPAAccount, task_id: str = None): return responses + def delete_standing_order(self, account: SEPAAccount, standing_order, execution_date: datetime.date = None): + """ + Delete a SEPA standing order (Dauerauftrag). + + :param account: SEPAAccount the standing order belongs to. + :param standing_order: HICDB object of the standing order, as returned by ``get_standing_orders``. + :param execution_date: Execution date from which on the standing order will no longer be executed. + Only allowed if the bank supports scheduled deletion. If not given, the deletion takes effect immediately. + :return: Returns either a NeedRetryResponse or TransactionResponse + """ + with self._get_dialog() as dialog: + hicdls, hkcdl = self._find_highest_supported_command( + HKCDL1, + return_parameter_segment=True + ) + + if execution_date and not hicdls.parameter.scheduled_deletion_allowed: + raise FinTSUnsupportedOperation("Scheduled deletion not allowed by bank.") + + seg = hkcdl( + account=hkcdl._fields['account'].type.from_sepa_account(account), + sepa_descriptor=standing_order.sepa_descriptor, + sepa_pain_message=standing_order.sepa_pain_message, + execution_date=execution_date, + task_id=standing_order.task_id, + standing_order_details=standing_order.standing_order_details, + ) + + return self._send_with_possible_retry(dialog, seg, self._continue_sepa_transfer) + def get_status_protocol(self): with self._get_dialog() as dialog: hkpro = self._find_highest_supported_command(HKPRO3, HKPRO4) diff --git a/fints/formals.py b/fints/formals.py index 5a57202..8a5a3f2 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -1036,6 +1036,16 @@ class QueryStandingOrdersParameter1(DataElementGroup): max_number_responses_allowed = DataElementField(type='jn', _d="Eingabe Anzahl Einträge erlaubt") +class DeleteStandingOrderParameter1(DataElementGroup): + """Parameter SEPA-Dauerauftrag löschen, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ + min_advance_notice = DataElementField(type='num', max_length=4, _d="Minimale Vorlaufzeit") + max_advance_notice = DataElementField(type='num', max_length=4, _d="Maximale Vorlaufzeit") + scheduled_deletion_allowed = DataElementField(type='jn', _d="Löschung terminierbar") + send_task_data_required = DataElementField(type='jn', _d="Senden der Auftragsdaten erforderlich") + + class QueryCreditCardStatements2(DataElementGroup): """Parameter Kreditkartenumsätze anfordern, version 2 diff --git a/fints/segments/standing_order.py b/fints/segments/standing_order.py index 4b5ae49..74bcc25 100644 --- a/fints/segments/standing_order.py +++ b/fints/segments/standing_order.py @@ -1,7 +1,7 @@ from ..fields import DataElementField, DataElementGroupField from ..formals import ( - KTI1, QueryStandingOrdersParameter1, StandingOrderDetails3, - StandingOrderSuspension3, SupportedSEPAPainMessages1, + KTI1, DeleteStandingOrderParameter1, QueryStandingOrdersParameter1, + StandingOrderDetails3, StandingOrderSuspension3, SupportedSEPAPainMessages1, ) from .base import FinTS3Segment, ParameterSegment @@ -37,3 +37,22 @@ class HICDBS1(ParameterSegment): Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ parameter = DataElementGroupField(type=QueryStandingOrdersParameter1, _d="Parameter SEPA-Dauerauftragsbestand") + + +class HKCDL1(FinTS3Segment): + """SEPA-Dauerauftrag löschen, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + account = DataElementGroupField(type=KTI1, _d="Kontoverbindung international") + sepa_descriptor = DataElementField(type='an', max_length=256, required=False, _d="SEPA Descriptor") + sepa_pain_message = DataElementField(type='bin', required=False, _d="SEPA pain message") + execution_date = DataElementField(type='dat', required=False, _d="Ausführungsdatum") + task_id = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation") + standing_order_details = DataElementGroupField(type=StandingOrderDetails3, _d="Dauerauftragsdetails") + + +class HICDLS1(ParameterSegment): + """SEPA-Dauerauftrag löschen Parameter, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + parameter = DataElementGroupField(type=DeleteStandingOrderParameter1, _d="Parameter SEPA-Dauerauftrag löschen") diff --git a/tests/conftest.py b/tests/conftest.py index 543ee9c..c919270 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -192,6 +192,12 @@ def make_answer(self, dialog_id, message): + "+{}+{}'".format(task_id, details).encode('us-ascii') ) + hkcdl = re.search(rb"'HKCDL:(\d+):1\+", message) + if hkcdl: + segno = hkcdl.group(1).decode('us-ascii') + task_id = re.search(rb"\+(ORDER\d+)\+", message).group(1).decode('us-ascii') + result.append("HIRMS::2:{}+0020::SEPA-Dauerauftrag {} gelöscht'".format(segno, task_id).encode('iso-8859-1')) + hktan = re.search(rb"'HKTAN:(\d+):(\d+)\+2\+\+\+\+([^+]+)\+", message) if hktan: segno = hktan.group(1).decode('us-ascii') diff --git a/tests/test_client.py b/tests/test_client.py index a0b33d7..afbad2a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ from fints.client import FinTS3PinTanClient, TransactionResponse, NeedTANResponse, ResponseStatus, NeedRetryResponse -from fints.exceptions import FinTSClientPINError, FinTSClientTemporaryAuthError +from fints.exceptions import FinTSClientPINError, FinTSClientTemporaryAuthError, FinTSUnsupportedOperation from decimal import Decimal +import datetime import pytest @@ -243,3 +244,25 @@ def test_get_standing_orders(fints_client): assert standing_orders[1].suspension.annually_recurring is False assert standing_orders[1].suspension.suspension_end.isoformat() == '2025-11-30' assert standing_orders[1].task_cancelable is True + + +def test_delete_standing_order(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + + res = fints_client.delete_standing_order(accounts[0], standing_orders[0]) + + assert isinstance(res, TransactionResponse) + assert res.status == ResponseStatus.SUCCESS + assert res.responses[0].code == '0020' + assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER1 gelöscht' + + +def test_delete_standing_order_scheduled_not_allowed(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + + with pytest.raises(FinTSUnsupportedOperation): + fints_client.delete_standing_order(accounts[0], standing_orders[0], datetime.date(2030, 1, 1)) From 726b15aada3e0861c816d2b809fdfb65fe196962 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:36:29 +0200 Subject: [PATCH 05/19] Add create_standing_order() to create SEPA standing orders (HKCDE/HICDE/HICDES) --- docs/transfers.rst | 58 ++++++++++++++++ fints/client.py | 110 +++++++++++++++++++++++++++++-- fints/formals.py | 13 ++++ fints/segments/standing_order.py | 29 +++++++- tests/conftest.py | 10 +++ tests/test_client.py | 48 ++++++++++++++ 6 files changed, 262 insertions(+), 6 deletions(-) diff --git a/docs/transfers.rst b/docs/transfers.rst index 1d8f22f..4136fb2 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -104,6 +104,64 @@ Full example +Creating standing orders +------------------------ + +You can create a SEPA standing order ("Dauerauftrag") with the same convenience as a simple transfer. The schedule +is given by the date of the first execution, the time unit (monthly or weekly), the interval (every n months or weeks) +and the execution day (day of the month or of the week): + +.. autoclass:: fints.client.FinTS3Client + :members: simple_create_standing_order + :noindex: + +.. autoclass:: fints.formals.StandingOrderTimeUnit1 + :noindex: + +If you want to supply your own SEPA XML, use ``create_standing_order``. The requested execution date +(``ReqdExctnDt``) in the pain message must be ``1999-01-01``, as for a simple transfer: + +.. autoclass:: fints.client.FinTS3Client + :members: create_standing_order + :noindex: + +As with transfers, the return value may be a `NeedVOPResponse` (call `approve_vop_response` to proceed) or a +`NeedTANResponse`. If the bank assigns a task id to the new standing order, it is returned in ``data['task_id']`` +of the final ``TransactionResponse``. + +.. note:: + + Returning the task id (Auftragsidentifikation) on creation is optional for banks, and many banks do not do it: + ``data['task_id']`` is then not set, even though the standing order was created successfully (e.g. response code + ``0010 Auftrag entgegengenommen``). In this case, call ``get_standing_orders`` afterwards to obtain the task id + of the new standing order. You need it for example to delete the standing order later on. + +.. code-block:: python + + from fints.formals import StandingOrderTimeUnit1 + + res = client.simple_create_standing_order( + account=account, + iban='DE89370400440532013000', + bic='COBADEFFXXX', + recipient_name='Max Mustermann', + amount=Decimal('25.00'), + account_name='Your name', + reason='Monthly rent', + first_execution_date=datetime.date(2030, 1, 1), + time_unit=StandingOrderTimeUnit1.MONTHLY, + interval=1, + execution_day=1, + ) + while isinstance(res, (NeedTANResponse, NeedVOPResponse)): + if isinstance(res, NeedTANResponse): + res = ask_for_tan(res) + else: + res = client.approve_vop_response(res) + + print(res.status, res.data.get('task_id')) + + Deleting standing orders ------------------------ diff --git a/fints/client.py b/fints/client.py index 6f758bc..707fa8b 100644 --- a/fints/client.py +++ b/fints/client.py @@ -18,7 +18,8 @@ from .formals import ( CUSTOMER_ID_ANONYMOUS, KTI1, BankIdentifier, DescriptionRequired, SynchronizationMode, TANMediaClass4, TANMediaType2, - SupportedMessageTypes, StatementFormat, TANUsageOption + SupportedMessageTypes, StatementFormat, TANUsageOption, + StandingOrderTimeUnit1, ) from .message import FinTSInstituteMessage from .models import SEPAAccount, Transaction @@ -38,7 +39,7 @@ from .segments.dialog import HIRMG2, HIRMS2, HISYN4, HKSYN3 from .segments.journal import HKPRO3, HKPRO4 from .segments.saldo import HKSAL5, HKSAL6, HKSAL7 -from .segments.standing_order import HKCDB1, HKCDL1 +from .segments.standing_order import HICDE1, HKCDB1, HKCDE1, HKCDL1 from .segments.statement import DKKKU2, HKKAZ5, HKKAZ6, HKKAZ7, HKCAZ1, HKKAU2, HKKAU1, HKEKA3, HKEKA4, HKEKA5 from .segments.transfer import HKCCM1, HKCCS1, HKIPZ1, HKIPM1 from .types import SegmentSequence @@ -804,6 +805,99 @@ def delete_standing_order(self, account: SEPAAccount, standing_order, execution_ return self._send_with_possible_retry(dialog, seg, self._continue_sepa_transfer) + def simple_create_standing_order(self, account: SEPAAccount, iban: str, bic: str, + recipient_name: str, amount: Decimal, account_name: str, reason: str, + first_execution_date: datetime.date, + time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, + last_execution_date: datetime.date = None, endtoend_id='NOTPROVIDED'): + """ + Simple SEPA standing order (Dauerauftrag). + + :param account: SEPAAccount to send the standing order from. + :param iban: Recipient's IBAN + :param bic: Recipient's BIC (Can be None if domestic) + :param recipient_name: Recipient name + :param amount: Amount as a ``Decimal`` + :param account_name: Sender account name + :param reason: Transfer reason + :param first_execution_date: Date of the first execution + :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` + :param interval: Execute every ``interval`` months or weeks + :param execution_day: Day of the month (1-31) or of the week (1-7) to execute on + :param last_execution_date: Date of the last execution (defaults to ``None``, i.e. unlimited) + :param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``) + :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse + (with data['task_id'] set, if available). Banks are not required to return the task id + (Auftragsidentifikation) of the new standing order here. If ``data['task_id']`` is not set, call + ``get_standing_orders`` afterwards to obtain it, e.g. before deleting the standing order. + """ + xml, pain_descriptor = self._build_simple_sepa_transfer_pain( + account, iban, bic, recipient_name, amount, account_name, reason, endtoend_id + ) + return self.create_standing_order( + account, xml, first_execution_date, time_unit, interval, execution_day, + last_execution_date=last_execution_date, pain_descriptor=pain_descriptor, + ) + + def create_standing_order(self, account: SEPAAccount, pain_message: str, first_execution_date: datetime.date, + time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, + last_execution_date: datetime.date = None, + pain_descriptor='urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'): + """ + Custom SEPA standing order (Dauerauftrag). + + :param account: SEPAAccount to send the standing order from. + :param pain_message: SEPA PAIN message containing the transfer details. The requested execution date + in the message must be ``1999-01-01``, the schedule is given by the other parameters. + :param first_execution_date: Date of the first execution + :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` + :param interval: Execute every ``interval`` months or weeks + :param execution_day: Day of the month (1-31) or of the week (1-7) to execute on + :param last_execution_date: Date of the last execution (defaults to ``None``, i.e. unlimited) + :param pain_descriptor: URN of the PAIN message schema used. + :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse + (with data['task_id'] set, if available). Banks are not required to return the task id + (Auftragsidentifikation) of the new standing order here. If ``data['task_id']`` is not set, call + ``get_standing_orders`` afterwards to obtain it, e.g. before deleting the standing order. + """ + with self._get_dialog() as dialog: + hicdes, hkcde = self._find_highest_supported_command( + HKCDE1, + return_parameter_segment=True + ) + + seg = hkcde( + account=hkcde._fields['account'].type.from_sepa_account(account), + sepa_descriptor=pain_descriptor, + sepa_pain_message=pain_message.encode(), + standing_order_details=hkcde._fields['standing_order_details'].type( + first_execution_date=first_execution_date, + time_unit=time_unit, + interval=interval, + execution_day=execution_day, + last_execution_date=last_execution_date, + ), + ) + + if seg.standing_order_details.time_unit == StandingOrderTimeUnit1.WEEKLY and not hicdes.parameter.weeks: + raise FinTSUnsupportedOperation("Weekly standing orders not supported by bank.") + + return self._send_pay_with_possible_retry(dialog, seg, self._continue_create_standing_order) + + def _continue_create_standing_order(self, command_seg, response): + retval = TransactionResponse(response) + + for seg in response.find_segments(HIRMS2): + for resp in seg.responses: + retval.set_status_if_higher(_RESPONSE_STATUS_MAPPING.get(resp.code[0], ResponseStatus.UNKNOWN)) + retval.responses.append(resp) + + for seg in response.find_segments(HICDE1): + if seg.task_id: + retval.data['task_id'] = seg.task_id + + return retval + def get_status_protocol(self): with self._get_dialog() as dialog: hkpro = self._find_highest_supported_command(HKPRO3, HKPRO4) @@ -917,6 +1011,14 @@ def simple_sepa_transfer(self, account: SEPAAccount, iban: str, bic: str, :param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``) :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse """ + xml, pain_descriptor = self._build_simple_sepa_transfer_pain( + account, iban, bic, recipient_name, amount, account_name, reason, endtoend_id + ) + return self.sepa_transfer(account, xml, pain_descriptor=pain_descriptor, instant_payment=instant_payment) + + def _build_simple_sepa_transfer_pain(self, account: SEPAAccount, iban: str, bic: str, + recipient_name: str, amount: Decimal, account_name: str, reason: str, + endtoend_id='NOTPROVIDED'): config = { "name": account_name, "IBAN": account.iban, @@ -924,7 +1026,7 @@ def simple_sepa_transfer(self, account: SEPAAccount, iban: str, bic: str, "batch": False, "currency": "EUR", } - + version = self._find_supported_sepa_version([ 'pain.001.001.09', 'pain.001.001.03' @@ -943,7 +1045,7 @@ def simple_sepa_transfer(self, account: SEPAAccount, iban: str, bic: str, payment["BIC"] = bic sepa.add_payment(payment) xml = sepa.export().decode() - return self.sepa_transfer(account, xml, pain_descriptor="urn:iso:std:iso:20022:tech:xsd:"+version, instant_payment=instant_payment) + return xml, "urn:iso:std:iso:20022:tech:xsd:"+version def sepa_transfer(self, account: SEPAAccount, pain_message: str, multiple=False, control_sum=None, currency='EUR', book_as_single=False, diff --git a/fints/formals.py b/fints/formals.py index 8a5a3f2..a290ace 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -1046,6 +1046,19 @@ class DeleteStandingOrderParameter1(DataElementGroup): send_task_data_required = DataElementField(type='jn', _d="Senden der Auftragsdaten erforderlich") +class CreateStandingOrderParameter1(DataElementGroup): + """Parameter SEPA-Dauerauftrag einrichten, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ + max_purpose_lines = DataElementField(type='num', max_length=2, _d="Maximale Anzahl Verwendungszweckzeilen") + min_advance_notice = DataElementField(type='num', max_length=4, _d="Minimale Vorlaufzeit") + max_advance_notice = DataElementField(type='num', max_length=4, _d="Maximale Vorlaufzeit") + months = DataElementField(type='dig', max_length=24, _d="Turnus in Monaten") + execution_days_month = DataElementField(type='dig', max_length=64, _d="Ausführungstage pro Monat") + weeks = DataElementField(type='dig', max_length=104, required=False, _d="Turnus in Wochen") + execution_days_week = DataElementField(type='dig', max_length=7, required=False, _d="Ausführungstage pro Woche") + + class QueryCreditCardStatements2(DataElementGroup): """Parameter Kreditkartenumsätze anfordern, version 2 diff --git a/fints/segments/standing_order.py b/fints/segments/standing_order.py index 74bcc25..dda0796 100644 --- a/fints/segments/standing_order.py +++ b/fints/segments/standing_order.py @@ -1,7 +1,8 @@ from ..fields import DataElementField, DataElementGroupField from ..formals import ( - KTI1, DeleteStandingOrderParameter1, QueryStandingOrdersParameter1, - StandingOrderDetails3, StandingOrderSuspension3, SupportedSEPAPainMessages1, + KTI1, CreateStandingOrderParameter1, DeleteStandingOrderParameter1, + QueryStandingOrdersParameter1, StandingOrderDetails3, + StandingOrderSuspension3, SupportedSEPAPainMessages1, ) from .base import FinTS3Segment, ParameterSegment @@ -56,3 +57,27 @@ class HICDLS1(ParameterSegment): Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ parameter = DataElementGroupField(type=DeleteStandingOrderParameter1, _d="Parameter SEPA-Dauerauftrag löschen") + + +class HKCDE1(FinTS3Segment): + """SEPA-Dauerauftrag einrichten, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + account = DataElementGroupField(type=KTI1, _d="Kontoverbindung international") + sepa_descriptor = DataElementField(type='an', max_length=256, _d="SEPA Descriptor") + sepa_pain_message = DataElementField(type='bin', _d="SEPA pain message") + standing_order_details = DataElementGroupField(type=StandingOrderDetails3, _d="Dauerauftragsdetails") + + +class HICDE1(FinTS3Segment): + """SEPA-Dauerauftragseinrichtung bestätigen, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + task_id = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation") + + +class HICDES1(ParameterSegment): + """SEPA-Dauerauftrag einrichten Parameter, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + parameter = DataElementGroupField(type=CreateStandingOrderParameter1, _d="Parameter SEPA-Dauerauftrag einrichten") diff --git a/tests/conftest.py b/tests/conftest.py index c919270..e07b9ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -198,6 +198,16 @@ def make_answer(self, dialog_id, message): task_id = re.search(rb"\+(ORDER\d+)\+", message).group(1).decode('us-ascii') result.append("HIRMS::2:{}+0020::SEPA-Dauerauftrag {} gelöscht'".format(segno, task_id).encode('iso-8859-1')) + hkcde = re.search(rb"'HKCDE:(\d+):1\+.*@\d+@(.*)\+([^']+)'", message, re.DOTALL) + if hkcde: + segno = hkcde.group(1).decode('us-ascii') + details = hkcde.group(3).decode('us-ascii').replace(':', '?:') + if re.search(rb"(
)?1999-01-01", hkcde.group(2)): + result.append("HIRMS::2:{}+0020::SEPA-Dauerauftrag eingerichtet {}'".format(segno, details).encode('iso-8859-1')) + result.append("HICDE::1:{}+ORDER3'".format(segno).encode('us-ascii')) + else: + result.append("HIRMS::2:{}+9150::Ausführungsdatum darf nicht belegt werden'".format(segno).encode('iso-8859-1')) + hktan = re.search(rb"'HKTAN:(\d+):(\d+)\+2\+\+\+\+([^+]+)\+", message) if hktan: segno = hktan.group(1).decode('us-ascii') diff --git a/tests/test_client.py b/tests/test_client.py index afbad2a..e530313 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,7 @@ from fints.client import FinTS3PinTanClient, TransactionResponse, NeedTANResponse, ResponseStatus, NeedRetryResponse from fints.exceptions import FinTSClientPINError, FinTSClientTemporaryAuthError, FinTSUnsupportedOperation +from fints.formals import StandingOrderTimeUnit1 +from fints.models import VirtualDate from decimal import Decimal import datetime import pytest @@ -266,3 +268,49 @@ def test_delete_standing_order_scheduled_not_allowed(fints_client): with pytest.raises(FinTSUnsupportedOperation): fints_client.delete_standing_order(accounts[0], standing_orders[0], datetime.date(2030, 1, 1)) + + +def test_create_standing_order(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + + res = fints_client.simple_create_standing_order( + accounts[0], + 'DE111234567800000002', + 'GENODE23X42', + 'Test Receiver', + Decimal('1.23'), + 'Test Sender', + 'Test standing order', + datetime.date(2030, 1, 31), + StandingOrderTimeUnit1.MONTHLY, + 1, + 31, + last_execution_date=VirtualDate(2030, 4, 31), + ) + + assert isinstance(res, TransactionResponse) + assert res.status == ResponseStatus.SUCCESS + assert res.responses[0].code == '0020' + assert res.responses[0].text == 'SEPA-Dauerauftrag eingerichtet 20300131:M:1:31:20300431' + assert res.data['task_id'] == 'ORDER3' + + +def test_create_standing_order_weekly_not_supported(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + + with pytest.raises(FinTSUnsupportedOperation): + fints_client.simple_create_standing_order( + accounts[0], + 'DE111234567800000002', + 'GENODE23X42', + 'Test Receiver', + Decimal('1.23'), + 'Test Sender', + 'Test standing order', + datetime.date(2030, 1, 7), + StandingOrderTimeUnit1.WEEKLY, + 1, + 1, + ) From 1bb739531e0918ffc254facc586d8c2affa6e727 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:02:01 +0200 Subject: [PATCH 06/19] Add change_standing_order() to change SEPA standing orders (HKCDN/HICDN/HICDNS) --- docs/transfers.rst | 50 +++++++++++- fints/client.py | 136 ++++++++++++++++++++++++++++++- fints/formals.py | 22 +++++ fints/segments/standing_order.py | 33 +++++++- fints/utils.py | 86 +++++++++++++++++++ tests/conftest.py | 14 ++++ tests/test_client.py | 59 ++++++++++++++ tests/test_utils.py | 53 ++++++++++++ 8 files changed, 447 insertions(+), 6 deletions(-) diff --git a/docs/transfers.rst b/docs/transfers.rst index 4136fb2..8e9e965 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -134,7 +134,8 @@ of the final ``TransactionResponse``. Returning the task id (Auftragsidentifikation) on creation is optional for banks, and many banks do not do it: ``data['task_id']`` is then not set, even though the standing order was created successfully (e.g. response code ``0010 Auftrag entgegengenommen``). In this case, call ``get_standing_orders`` afterwards to obtain the task id - of the new standing order. You need it for example to delete the standing order later on. + of the new standing order. You need it for example to delete the standing order later on. As with changes (see + below), it may take a while until the new standing order shows up there. .. code-block:: python @@ -162,6 +163,53 @@ of the final ``TransactionResponse``. print(res.status, res.data.get('task_id')) +Changing standing orders +------------------------ + +To change a SEPA standing order, pass one of the objects returned by ``get_standing_orders`` (see +:ref:`standing-orders`) together with the values to change. All values that are not given are kept: + +.. autoclass:: fints.client.FinTS3Client + :members: simple_change_standing_order + :noindex: + +Which values may be changed is decided by the bank. The bank may assign a new task id to the changed standing order, +it is returned in ``data['task_id']`` (and the previous one in ``data['task_id_old']``) of the final +``TransactionResponse`` if the bank reports it. Otherwise, call ``get_standing_orders`` again to obtain it. + +.. note:: + + Banks may process changes asynchronously, especially if they answer with response code + ``0010 Auftrag entgegengenommen`` (order accepted) instead of ``0020`` (order executed). It can then take a while + until the change is visible in online banking and in ``get_standing_orders``. In the meantime, the standing order + may even be missing completely from ``get_standing_orders`` for a minute or so. Wait a bit before fetching the + standing orders again, and do not submit the change a second time. + +.. code-block:: python + + standing_orders = client.get_standing_orders(account) + order = standing_orders[0] + + res = client.simple_change_standing_order(account, order, amount=Decimal('30.00'), execution_day=15) + while isinstance(res, (NeedTANResponse, NeedVOPResponse)): + if isinstance(res, NeedTANResponse): + res = ask_for_tan(res) + else: + res = client.approve_vop_response(res) + + print(res.status, res.data.get('task_id')) + +If you want to supply your own SEPA XML, use ``change_standing_order``. The bank requires the complete new pain +message in the same schema as the pain message of the existing standing order (``order.sepa_descriptor``). To change +single values of the existing pain message while keeping its schema, you can use ``modify_sepa_transfer_pain``: + +.. autoclass:: fints.client.FinTS3Client + :members: change_standing_order + :noindex: + +.. autofunction:: fints.utils.modify_sepa_transfer_pain + + Deleting standing orders ------------------------ diff --git a/fints/client.py b/fints/client.py index 707fa8b..6043f90 100644 --- a/fints/client.py +++ b/fints/client.py @@ -39,13 +39,13 @@ from .segments.dialog import HIRMG2, HIRMS2, HISYN4, HKSYN3 from .segments.journal import HKPRO3, HKPRO4 from .segments.saldo import HKSAL5, HKSAL6, HKSAL7 -from .segments.standing_order import HICDE1, HKCDB1, HKCDE1, HKCDL1 +from .segments.standing_order import HICDE1, HICDN1, HKCDB1, HKCDE1, HKCDL1, HKCDN1 from .segments.statement import DKKKU2, HKKAZ5, HKKAZ6, HKKAZ7, HKCAZ1, HKKAU2, HKKAU1, HKEKA3, HKEKA4, HKEKA5 from .segments.transfer import HKCCM1, HKCCS1, HKIPZ1, HKIPM1 from .types import SegmentSequence from .utils import ( MT535_Miniparser, Password, SubclassesMixin, - compress_datablob, decompress_datablob, mt940_to_array, + compress_datablob, decompress_datablob, modify_sepa_transfer_pain, mt940_to_array, ) logger = logging.getLogger(__name__) @@ -898,6 +898,138 @@ def _continue_create_standing_order(self, command_seg, response): return retval + def simple_change_standing_order(self, account: SEPAAccount, standing_order, amount: Decimal = None, + reason: str = None, recipient_name: str = None, iban: str = None, + bic: str = None, first_execution_date: datetime.date = None, + time_unit: StandingOrderTimeUnit1 = None, interval: int = None, + execution_day: int = None, last_execution_date: datetime.date = None, + execution_date: datetime.date = None): + """ + Simple change of a SEPA standing order (Dauerauftrag). + + Only the given values are changed, all values that are ``None`` are kept. The SEPA pain message of the + standing order is changed accordingly, keeping its schema (see ``modify_sepa_transfer_pain``). + Which values may be changed is decided by the bank. + + :param account: SEPAAccount the standing order belongs to. + :param standing_order: HICDB object of the standing order, as returned by ``get_standing_orders``. + :param amount: New amount as a ``Decimal`` + :param reason: New transfer reason + :param recipient_name: New recipient name + :param iban: New recipient IBAN. If no ``bic`` is given, an existing recipient BIC is removed. + :param bic: New recipient BIC + :param first_execution_date: New date of the first execution + :param time_unit: New time unit, ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` + :param interval: New interval (every ``interval`` months or weeks) + :param execution_day: New day of the month (1-31) or of the week (1-7) to execute on + :param last_execution_date: New date of the last execution + :param execution_date: Execution date from which on the change applies. Only allowed if the bank + supports scheduled changes. If not given, the change applies from the next execution on. + :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse + (with data['task_id'] and data['task_id_old'] set, if available). Banks may process the change + asynchronously, so it can take a while until it is visible in ``get_standing_orders``. + """ + details = standing_order.standing_order_details + pain_message = modify_sepa_transfer_pain( + standing_order.sepa_pain_message, amount=amount, reason=reason, + recipient_name=recipient_name, iban=iban, bic=bic, + ) + return self.change_standing_order( + account, standing_order, pain_message, + first_execution_date if first_execution_date is not None else details.first_execution_date, + time_unit if time_unit is not None else details.time_unit, + interval if interval is not None else details.interval, + execution_day if execution_day is not None else details.execution_day, + last_execution_date=last_execution_date if last_execution_date is not None else details.last_execution_date, + execution_date=execution_date, + ) + + def change_standing_order(self, account: SEPAAccount, standing_order, pain_message, + first_execution_date: datetime.date, time_unit: StandingOrderTimeUnit1, + interval: int, execution_day: int, last_execution_date: datetime.date = None, + execution_date: datetime.date = None): + """ + Custom change of a SEPA standing order (Dauerauftrag). + + :param account: SEPAAccount the standing order belongs to. + :param standing_order: HICDB object of the standing order, as returned by ``get_standing_orders``. + :param pain_message: Complete new SEPA PAIN message (``str`` or ``bytes``). It must use the same schema + as the pain message of the standing order (``standing_order.sepa_descriptor``), and its requested + execution date must be ``1999-01-01``. + :param first_execution_date: Date of the first execution + :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` + :param interval: Execute every ``interval`` months or weeks + :param execution_day: Day of the month (1-31) or of the week (1-7) to execute on + :param last_execution_date: Date of the last execution (defaults to ``None``, i.e. unlimited) + :param execution_date: Execution date from which on the change applies. Only allowed if the bank + supports scheduled changes. If not given, the change applies from the next execution on. + :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse + (with data['task_id'] and data['task_id_old'] set, if available). The bank may assign a new + task id to the changed standing order. Banks may process the change asynchronously, so it can + take a while until it is visible in ``get_standing_orders``; in the meantime, the standing order + may be missing there completely. + """ + with self._get_dialog() as dialog: + hicdns, hkcdn = self._find_highest_supported_command( + HKCDN1, + return_parameter_segment=True + ) + parameter = hicdns.parameter + + if execution_date and not parameter.max_scheduled_changes: + raise FinTSUnsupportedOperation("Scheduled changes not allowed by bank.") + + if isinstance(pain_message, str): + pain_message = pain_message.encode() + + seg = hkcdn( + account=hkcdn._fields['account'].type.from_sepa_account(account), + sepa_descriptor=standing_order.sepa_descriptor, + sepa_pain_message=pain_message, + execution_date=execution_date, + task_id=standing_order.task_id, + standing_order_details=hkcdn._fields['standing_order_details'].type( + first_execution_date=first_execution_date, + time_unit=time_unit, + interval=interval, + execution_day=execution_day, + last_execution_date=last_execution_date, + ), + ) + + # If the bank allows more than one scheduled change, changeable details must only be sent + # if they are changed. + details_changeable = any(( + parameter.first_execution_date_changeable, parameter.time_unit_changeable, + parameter.interval_changeable, parameter.execution_day_changeable, + parameter.last_execution_date_changeable, + )) + old_details = standing_order.standing_order_details + details_changed = any( + getattr(seg.standing_order_details, name) != getattr(old_details, name) + for name in seg.standing_order_details._fields + ) + if parameter.max_scheduled_changes > 1 and details_changeable and not details_changed: + seg.standing_order_details = None + + return self._send_pay_with_possible_retry(dialog, seg, self._continue_change_standing_order) + + def _continue_change_standing_order(self, command_seg, response): + retval = TransactionResponse(response) + + for seg in response.find_segments(HIRMS2): + for resp in seg.responses: + retval.set_status_if_higher(_RESPONSE_STATUS_MAPPING.get(resp.code[0], ResponseStatus.UNKNOWN)) + retval.responses.append(resp) + + for seg in response.find_segments(HICDN1): + if seg.task_id: + retval.data['task_id'] = seg.task_id + if seg.task_id_old: + retval.data['task_id_old'] = seg.task_id_old + + return retval + def get_status_protocol(self): with self._get_dialog() as dialog: hkpro = self._find_highest_supported_command(HKPRO3, HKPRO4) diff --git a/fints/formals.py b/fints/formals.py index a290ace..c52fe21 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -1059,6 +1059,28 @@ class CreateStandingOrderParameter1(DataElementGroup): execution_days_week = DataElementField(type='dig', max_length=7, required=False, _d="Ausführungstage pro Woche") +class ChangeStandingOrderParameter1(DataElementGroup): + """Parameter SEPA-Dauerauftrag ändern, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Data Dictionary """ + max_scheduled_changes = DataElementField(type='num', length=1, _d="Anzahl terminierter Änderungen") + min_advance_notice = DataElementField(type='num', max_length=4, _d="Minimale Vorlaufzeit") + max_advance_notice = DataElementField(type='num', max_length=4, _d="Maximale Vorlaufzeit") + creditor_account_changeable = DataElementField(type='jn', _d="CreditorAccount/Agent änderbar") + creditor_changeable = DataElementField(type='jn', _d="Creditor änderbar") + amount_changeable = DataElementField(type='jn', _d="InstructedAmount änderbar") + purpose_changeable = DataElementField(type='jn', _d="RemittanceInformation änderbar") + first_execution_date_changeable = DataElementField(type='jn', _d="Erstes Ausführungsdatum änderbar") + time_unit_changeable = DataElementField(type='jn', _d="Zeiteinheit änderbar") + interval_changeable = DataElementField(type='jn', _d="Turnus änderbar") + execution_day_changeable = DataElementField(type='jn', _d="Ausführungstag änderbar") + last_execution_date_changeable = DataElementField(type='jn', _d="Letztes Ausführungsdatum änderbar") + months = DataElementField(type='dig', max_length=24, _d="Turnus in Monaten") + execution_days_month = DataElementField(type='dig', max_length=64, _d="Ausführungstage pro Monat") + weeks = DataElementField(type='dig', max_length=104, required=False, _d="Turnus in Wochen") + execution_days_week = DataElementField(type='dig', max_length=7, required=False, _d="Ausführungstage pro Woche") + + class QueryCreditCardStatements2(DataElementGroup): """Parameter Kreditkartenumsätze anfordern, version 2 diff --git a/fints/segments/standing_order.py b/fints/segments/standing_order.py index dda0796..dfda3e9 100644 --- a/fints/segments/standing_order.py +++ b/fints/segments/standing_order.py @@ -1,8 +1,8 @@ from ..fields import DataElementField, DataElementGroupField from ..formals import ( - KTI1, CreateStandingOrderParameter1, DeleteStandingOrderParameter1, - QueryStandingOrdersParameter1, StandingOrderDetails3, - StandingOrderSuspension3, SupportedSEPAPainMessages1, + KTI1, ChangeStandingOrderParameter1, CreateStandingOrderParameter1, + DeleteStandingOrderParameter1, QueryStandingOrdersParameter1, + StandingOrderDetails3, StandingOrderSuspension3, SupportedSEPAPainMessages1, ) from .base import FinTS3Segment, ParameterSegment @@ -81,3 +81,30 @@ class HICDES1(ParameterSegment): Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ parameter = DataElementGroupField(type=CreateStandingOrderParameter1, _d="Parameter SEPA-Dauerauftrag einrichten") + + +class HKCDN1(FinTS3Segment): + """SEPA-Dauerauftrag ändern, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + account = DataElementGroupField(type=KTI1, _d="Kontoverbindung international") + sepa_descriptor = DataElementField(type='an', max_length=256, _d="SEPA Descriptor") + sepa_pain_message = DataElementField(type='bin', _d="SEPA pain message") + execution_date = DataElementField(type='dat', required=False, _d="Ausführungsdatum") + task_id = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation") + standing_order_details = DataElementGroupField(type=StandingOrderDetails3, required=False, _d="Dauerauftragsdetails") + + +class HICDN1(FinTS3Segment): + """SEPA-Dauerauftragsänderung bestätigen, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + task_id = DataElementField(type='an', max_length=99, _d="Auftragsidentifikation") + task_id_old = DataElementField(type='an', max_length=99, required=False, _d="Auftragsidentifikation alt") + + +class HICDNS1(ParameterSegment): + """SEPA-Dauerauftrag ändern Parameter, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle """ + parameter = DataElementGroupField(type=ChangeStandingOrderParameter1, _d="Parameter SEPA-Dauerauftrag ändern") diff --git a/fints/utils.py b/fints/utils.py index f02edb6..dcce5e4 100644 --- a/fints/utils.py +++ b/fints/utils.py @@ -1,4 +1,5 @@ import base64 +import decimal import json import re import threading @@ -11,6 +12,8 @@ from enum import Enum, EnumMeta as EnumType import mt940.models +from lxml import etree +from sepaxml.utils import make_msg_id from .models import Holding @@ -22,6 +25,89 @@ def mt940_to_array(data): return transactions.parse(data) +def modify_sepa_transfer_pain(pain_message, amount=None, reason=None, recipient_name=None, iban=None, bic=None): + """ + Change values of a SEPA pain.001 credit transfer message with exactly one payment, e.g. one returned by + ``get_standing_orders``. + + Only the given values are changed, the schema and all other values of the message are kept. The message + gets a new message id and creation timestamp, and the control sums are updated to a new amount. If the IBAN + is changed without giving a BIC, an existing BIC of the recipient is removed. + + :param pain_message: pain.001 message as ``bytes`` or ``str`` + :param amount: New amount as a ``Decimal`` + :param reason: New transfer reason + :param recipient_name: New recipient name + :param iban: New recipient IBAN + :param bic: New recipient BIC + :return: The changed pain.001 message as ``bytes`` + """ + if isinstance(pain_message, str): + pain_message = pain_message.encode('utf-8') + root = etree.fromstring(pain_message) + namespace = etree.QName(root).namespace + + def find(node, path): + # Namespace-agnostic, since the pain.001 version depends on the bank + return node.xpath('./' + '/'.join('*[local-name()="{}"]'.format(p) for p in path.split('/'))) + + def new_element(parent, tag, index=None): + element = etree.Element('{{{}}}{}'.format(namespace, tag) if namespace else tag) + if index is None: + parent.append(element) + else: + parent.insert(index, element) + return element + + transactions = root.xpath('//*[local-name()="CdtTrfTxInf"]') + if len(transactions) != 1: + raise ValueError("Only pain messages with exactly one payment are supported.") + tx = transactions[0] + + if amount is not None: + amount = "{:.2f}".format(decimal.Decimal(amount)) + find(tx, 'Amt/InstdAmt')[0].text = amount + for ctrl_sum in root.xpath('//*[local-name()="CtrlSum"]'): + ctrl_sum.text = amount + + if reason is not None: + purpose = find(tx, 'RmtInf/Ustrd') + if purpose: + purpose[0].text = reason + else: + new_element(new_element(tx, 'RmtInf'), 'Ustrd').text = reason + + if recipient_name is not None: + find(tx, 'Cdtr/Nm')[0].text = recipient_name + + if iban is not None: + find(tx, 'CdtrAcct/Id/IBAN')[0].text = iban + if bic is None: + for creditor_agent in find(tx, 'CdtrAgt'): + tx.remove(creditor_agent) + + if bic is not None: + creditor_agent = find(tx, 'CdtrAgt') + if creditor_agent: + institution = find(creditor_agent[0], 'FinInstnId')[0] + for element in list(institution): + institution.remove(element) + else: + creditor = find(tx, 'Cdtr')[0] + institution = new_element(new_element(tx, 'CdtrAgt', tx.index(creditor)), 'FinInstnId') + # pain.001.001.03 and the older German schemas use , later versions use + version = re.search(r'pain\.001\.(\d{3})\.(\d{2})', namespace or '') + use_bicfi = version is not None and version.group(1) == '001' and int(version.group(2)) >= 4 + new_element(institution, 'BICFI' if use_bicfi else 'BIC').text = bic + + for message_id in root.xpath('//*[local-name()="GrpHdr"]/*[local-name()="MsgId"]'): + message_id.text = make_msg_id() + for creation_time in root.xpath('//*[local-name()="GrpHdr"]/*[local-name()="CreDtTm"]'): + creation_time.text = datetime.now().strftime('%Y-%m-%dT%H:%M:%S') + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8') + + def classproperty(f): class fx: def __init__(self, getter): diff --git a/tests/conftest.py b/tests/conftest.py index e07b9ab..bf28d64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -208,6 +208,20 @@ def make_answer(self, dialog_id, message): else: result.append("HIRMS::2:{}+9150::Ausführungsdatum darf nicht belegt werden'".format(segno).encode('iso-8859-1')) + hkcdn = re.search(rb"'HKCDN:(\d+):1\+.*@\d+@(.*)\+([^']*)'", message, re.DOTALL) + if hkcdn: + segno = hkcdn.group(1).decode('us-ascii') + pain = hkcdn.group(2).decode('utf-8') + fields = hkcdn.group(3).decode('us-ascii').split('+') + ['', ''] + task_id, details = fields[1], fields[2].replace(':', '?:') + amount = re.search(r"]*>([^<]+)", pain).group(1) + if set(re.findall(r"([^<]+)", pain)) <= {amount}: + result.append("HIRMS::2:{}+0020::SEPA-Dauerauftrag {} geändert {} {}'".format( + segno, task_id, amount, details).encode('iso-8859-1')) + result.append("HICDN::1:{}+{}B+{}'".format(segno, task_id, task_id).encode('us-ascii')) + else: + result.append("HIRMS::2:{}+9010::Auftrag inkonsistent'".format(segno).encode('iso-8859-1')) + hktan = re.search(rb"'HKTAN:(\d+):(\d+)\+2\+\+\+\+([^+]+)\+", message) if hktan: segno = hktan.group(1).decode('us-ascii') diff --git a/tests/test_client.py b/tests/test_client.py index e530313..edfc6b1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -314,3 +314,62 @@ def test_create_standing_order_weekly_not_supported(fints_client): 1, 1, ) + + +def test_change_standing_order(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + + res = fints_client.simple_change_standing_order( + accounts[0], standing_orders[0], amount=Decimal('900'), execution_day=15, + ) + + assert isinstance(res, TransactionResponse) + assert res.status == ResponseStatus.SUCCESS + assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER1 geändert 900.00 20250101:M:1:15' + assert res.data['task_id'] == 'ORDER1B' + assert res.data['task_id_old'] == 'ORDER1' + + +def test_change_standing_order_custom_pain(fints_client): + from fints.utils import modify_sepa_transfer_pain + + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + order = standing_orders[1] + details = order.standing_order_details + + pain = modify_sepa_transfer_pain(order.sepa_pain_message, amount=Decimal('30')).decode() + res = fints_client.change_standing_order( + accounts[0], order, pain, + details.first_execution_date, details.time_unit, details.interval, details.execution_day, + last_execution_date=details.last_execution_date, + ) + + assert res.status == ResponseStatus.SUCCESS + assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER2 geändert 30.00 20250215:M:3:15:20270431' + + +def test_change_standing_order_unchanged_details_omitted(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + # Banks allowing several scheduled changes must not receive unchanged, changeable details + fints_client.bpd.find_segment_first('HICDNS').parameter.max_scheduled_changes = 2 + + res = fints_client.simple_change_standing_order(accounts[0], standing_orders[0], amount=Decimal('900')) + + assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER1 geändert 900.00 ' + + +def test_change_standing_order_scheduled_not_allowed(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + + with pytest.raises(FinTSUnsupportedOperation): + fints_client.simple_change_standing_order( + accounts[0], standing_orders[0], amount=Decimal('900'), execution_date=datetime.date(2030, 1, 1), + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 01fe2bd..afcc34f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,3 +25,56 @@ def test_decode_phototan_image(): assert data["mime_type"] == "image/png" assert data["image"] == b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\xc8\x00\x00\x00\xc8\x08\x06\x00\x00\x00\xadX\xae\x9e\x00\x00\x00\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\x00\x02MIDATx\x9c\xed\xdd\xb1\r\xc40\x0c\x04A\xea\xe1\xfe[\xf6w\xb0\x89\x023\x98\xa9@ \xb0Pxgf\xdeY\xec}W?o\xce9_?!\xb9\xdf\x9d\xdf\xd7\x0f\x80\xcd\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x1e;\xdaw\xdc\xef\xce\xf6\xfb\xf9A \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x08\x04\x82@ \x9c\x99Y=T\xbd}G\xdb\x0e\xf9\x9d\xed\xf7\xf3\x83@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@\x10\x08\x04\x81@x\xech\xdfq\xbf;\xdb\xef\xe7\x07\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81 \x10\x08\x02\x81pff\xf5P\xf5\xf6\x1dm;\xe4w\xb6\xdf\xcf\x0f\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02A \x10\x04\x02\xe1\x0fp-(\x89A#\xd8\xc6\x00\x00\x00\x00IEND\xaeB`\x82' + + +def _make_pain(version, bic='GENODE23X42', payments=1): + import datetime + from sepaxml import SepaTransfer + + sepa = SepaTransfer({'name': 'Test Sender', 'IBAN': 'DE111234567800000001', 'BIC': 'GENODE23X42', + 'batch': False, 'currency': 'EUR'}, version) + for i in range(payments): + payment = {'name': 'Test Receiver', 'IBAN': 'DE111234567800000002', 'amount': 123, + 'execution_date': datetime.date(1999, 1, 1), 'description': 'Old reason'} + if bic: + payment['BIC'] = bic + sepa.add_payment(payment) + return sepa.export() + + +@pytest.mark.parametrize("version, bic_tag", [('pain.001.001.03', 'BIC'), ('pain.001.001.09', 'BICFI')]) +def test_modify_sepa_transfer_pain(version, bic_tag): + from decimal import Decimal + from lxml import etree + from sepaxml.validation import try_valid_xml + from fints.utils import modify_sepa_transfer_pain + + def values(xml, name): + return [e.text for e in etree.fromstring(xml).xpath('//*[local-name()="{}"]'.format(name))] + + original = _make_pain(version) + + # Change amount and reason: control sums follow, message id is renewed, everything else stays + changed = modify_sepa_transfer_pain(original, amount=Decimal('30'), reason='New reason') + try_valid_xml(changed, version) + assert values(changed, 'InstdAmt') == ['30.00'] + assert set(values(changed, 'CtrlSum')) == {'30.00'} + assert values(changed, 'Ustrd') == ['New reason'] + assert values(changed, 'MsgId') != values(original, 'MsgId') + assert values(changed, 'ReqdExctnDt') == values(original, 'ReqdExctnDt') or \ + values(changed, 'Dt') == values(original, 'Dt') + + # New IBAN without BIC removes the old recipient BIC (the debtor BIC stays) + changed = modify_sepa_transfer_pain(original.decode(), iban='DE02120300000000202051') + try_valid_xml(changed, version) + assert 'DE02120300000000202051' in values(changed, 'IBAN') + assert not etree.fromstring(changed).xpath('//*[local-name()="CdtrAgt"]') + assert len(values(changed, bic_tag)) == 1 + + # BIC for a message without recipient BIC: CdtrAgt is created with the tag of the schema version + changed = modify_sepa_transfer_pain(_make_pain(version, bic=None), bic='COBADEFFXXX') + try_valid_xml(changed, version) + assert 'COBADEFFXXX' in values(changed, bic_tag) + + with pytest.raises(ValueError): + modify_sepa_transfer_pain(_make_pain(version, payments=2), amount=Decimal('1')) From 89fe0a110e38e64125938b4766386ef3f2e5b7af Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:18:42 +0200 Subject: [PATCH 07/19] Add standing order operations to FinTSOperations --- fints/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fints/client.py b/fints/client.py index 6043f90..187cfdf 100644 --- a/fints/client.py +++ b/fints/client.py @@ -85,6 +85,10 @@ class FinTSOperations(Enum): SEPA_STANDING_DEBIT_SINGLE_CREATE = ("HKDDE", ) GET_SEPA_STANDING_DEBITS_SINGLE = ("HKDDB", ) SEPA_STANDING_DEBIT_SINGLE_DELETE = ("HKDDL", ) + GET_SEPA_STANDING_ORDERS = ("HKCDB", ) + SEPA_STANDING_ORDER_CREATE = ("HKCDE", ) + SEPA_STANDING_ORDER_CHANGE = ("HKCDN", ) + SEPA_STANDING_ORDER_DELETE = ("HKCDL", ) class NeedRetryResponse(SubclassesMixin, metaclass=ABCMeta): From 4368d1ee0a069bf698191f4b4ba3bfffb86c0b7f Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:20:42 +0200 Subject: [PATCH 08/19] Check weekly support when changing standing orders --- fints/client.py | 3 +++ tests/test_client.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/fints/client.py b/fints/client.py index 187cfdf..4e116c1 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1016,6 +1016,9 @@ def change_standing_order(self, account: SEPAAccount, standing_order, pain_messa if parameter.max_scheduled_changes > 1 and details_changeable and not details_changed: seg.standing_order_details = None + if seg.standing_order_details and seg.standing_order_details.time_unit == StandingOrderTimeUnit1.WEEKLY and not parameter.weeks: + raise FinTSUnsupportedOperation("Weekly standing orders not supported by bank.") + return self._send_pay_with_possible_retry(dialog, seg, self._continue_change_standing_order) def _continue_change_standing_order(self, command_seg, response): diff --git a/tests/test_client.py b/tests/test_client.py index edfc6b1..9ed4d1d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -364,6 +364,17 @@ def test_change_standing_order_unchanged_details_omitted(fints_client): assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER1 geändert 900.00 ' +def test_change_standing_order_weekly_not_supported(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + + with pytest.raises(FinTSUnsupportedOperation): + fints_client.simple_change_standing_order( + accounts[0], standing_orders[0], time_unit=StandingOrderTimeUnit1.WEEKLY, execution_day=1, + ) + + def test_change_standing_order_scheduled_not_allowed(fints_client): with fints_client: accounts = fints_client.get_sepa_accounts() From 3b6658fe119645c1f4eb33b2ee5bb794c6b64aab Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:24:38 +0200 Subject: [PATCH 09/19] Accept bytes as pain message in create_standing_order() --- fints/client.py | 14 +++++++++----- tests/test_client.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/fints/client.py b/fints/client.py index 4e116c1..dadd397 100644 --- a/fints/client.py +++ b/fints/client.py @@ -843,7 +843,7 @@ def simple_create_standing_order(self, account: SEPAAccount, iban: str, bic: str last_execution_date=last_execution_date, pain_descriptor=pain_descriptor, ) - def create_standing_order(self, account: SEPAAccount, pain_message: str, first_execution_date: datetime.date, + def create_standing_order(self, account: SEPAAccount, pain_message: str | bytes, first_execution_date: datetime.date, time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, last_execution_date: datetime.date = None, pain_descriptor='urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'): @@ -851,8 +851,9 @@ def create_standing_order(self, account: SEPAAccount, pain_message: str, first_e Custom SEPA standing order (Dauerauftrag). :param account: SEPAAccount to send the standing order from. - :param pain_message: SEPA PAIN message containing the transfer details. The requested execution date - in the message must be ``1999-01-01``, the schedule is given by the other parameters. + :param pain_message: SEPA PAIN message (``str`` or ``bytes``) containing the transfer details. The + requested execution date in the message must be ``1999-01-01``, the schedule is given by the other + parameters. :param first_execution_date: Date of the first execution :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` :param interval: Execute every ``interval`` months or weeks @@ -870,10 +871,13 @@ def create_standing_order(self, account: SEPAAccount, pain_message: str, first_e return_parameter_segment=True ) + if isinstance(pain_message, str): + pain_message = pain_message.encode() + seg = hkcde( account=hkcde._fields['account'].type.from_sepa_account(account), sepa_descriptor=pain_descriptor, - sepa_pain_message=pain_message.encode(), + sepa_pain_message=pain_message, standing_order_details=hkcde._fields['standing_order_details'].type( first_execution_date=first_execution_date, time_unit=time_unit, @@ -948,7 +952,7 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo execution_date=execution_date, ) - def change_standing_order(self, account: SEPAAccount, standing_order, pain_message, + def change_standing_order(self, account: SEPAAccount, standing_order, pain_message: str | bytes, first_execution_date: datetime.date, time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, last_execution_date: datetime.date = None, execution_date: datetime.date = None): diff --git a/tests/test_client.py b/tests/test_client.py index 9ed4d1d..9c4589e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -296,6 +296,26 @@ def test_create_standing_order(fints_client): assert res.data['task_id'] == 'ORDER3' +def test_create_standing_order_custom_pain(fints_client): + from sepaxml import SepaTransfer + + with fints_client: + accounts = fints_client.get_sepa_accounts() + + sepa = SepaTransfer({'name': 'Test Sender', 'IBAN': accounts[0].iban, 'BIC': accounts[0].bic, + 'batch': False, 'currency': 'EUR'}, 'pain.001.001.03') + sepa.add_payment({'name': 'Test Receiver', 'IBAN': 'DE111234567800000002', 'amount': 123, + 'execution_date': datetime.date(1999, 1, 1), 'description': 'Test standing order'}) + + # sepaxml exports bytes, which are accepted as well as str + res = fints_client.create_standing_order( + accounts[0], sepa.export(), datetime.date(2030, 1, 15), StandingOrderTimeUnit1.MONTHLY, 1, 15, + ) + + assert res.status == ResponseStatus.SUCCESS + assert res.responses[0].text == 'SEPA-Dauerauftrag eingerichtet 20300115:M:1:15' + + def test_create_standing_order_weekly_not_supported(fints_client): with fints_client: accounts = fints_client.get_sepa_accounts() From 00fe20876c0df952f695513550eaf473786eb1f7 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:30:59 +0200 Subject: [PATCH 10/19] Add remove_last_execution_date to simple_change_standing_order() --- docs/transfers.rst | 3 ++- fints/client.py | 12 ++++++++++-- tests/test_client.py | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/transfers.rst b/docs/transfers.rst index 8e9e965..ab73321 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -167,7 +167,8 @@ Changing standing orders ------------------------ To change a SEPA standing order, pass one of the objects returned by ``get_standing_orders`` (see -:ref:`standing-orders`) together with the values to change. All values that are not given are kept: +:ref:`standing-orders`) together with the values to change. All values that are not given are kept. To make a +standing order unlimited, pass ``remove_last_execution_date=True``: .. autoclass:: fints.client.FinTS3Client :members: simple_change_standing_order diff --git a/fints/client.py b/fints/client.py index dadd397..db405b6 100644 --- a/fints/client.py +++ b/fints/client.py @@ -911,7 +911,7 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo bic: str = None, first_execution_date: datetime.date = None, time_unit: StandingOrderTimeUnit1 = None, interval: int = None, execution_day: int = None, last_execution_date: datetime.date = None, - execution_date: datetime.date = None): + remove_last_execution_date: bool = False, execution_date: datetime.date = None): """ Simple change of a SEPA standing order (Dauerauftrag). @@ -931,6 +931,8 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo :param interval: New interval (every ``interval`` months or weeks) :param execution_day: New day of the month (1-31) or of the week (1-7) to execute on :param last_execution_date: New date of the last execution + :param remove_last_execution_date: Remove the date of the last execution, i.e. make the standing order + unlimited (defaults to ``False``). Cannot be combined with ``last_execution_date``. :param execution_date: Execution date from which on the change applies. Only allowed if the bank supports scheduled changes. If not given, the change applies from the next execution on. :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse @@ -938,6 +940,12 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo asynchronously, so it can take a while until it is visible in ``get_standing_orders``. """ details = standing_order.standing_order_details + if remove_last_execution_date: + if last_execution_date is not None: + raise ValueError("last_execution_date and remove_last_execution_date cannot be combined.") + elif last_execution_date is None: + last_execution_date = details.last_execution_date + pain_message = modify_sepa_transfer_pain( standing_order.sepa_pain_message, amount=amount, reason=reason, recipient_name=recipient_name, iban=iban, bic=bic, @@ -948,7 +956,7 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo time_unit if time_unit is not None else details.time_unit, interval if interval is not None else details.interval, execution_day if execution_day is not None else details.execution_day, - last_execution_date=last_execution_date if last_execution_date is not None else details.last_execution_date, + last_execution_date=last_execution_date, execution_date=execution_date, ) diff --git a/tests/test_client.py b/tests/test_client.py index 9c4589e..6d9fc22 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -372,6 +372,23 @@ def test_change_standing_order_custom_pain(fints_client): assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER2 geändert 30.00 20250215:M:3:15:20270431' +def test_change_standing_order_remove_last_execution_date(fints_client): + with fints_client: + accounts = fints_client.get_sepa_accounts() + standing_orders = fints_client.get_standing_orders(accounts[0]) + + res = fints_client.simple_change_standing_order(accounts[0], standing_orders[1], remove_last_execution_date=True) + + assert res.status == ResponseStatus.SUCCESS + assert res.responses[0].text == 'SEPA-Dauerauftrag ORDER2 geändert 25.50 20250215:M:3:15' + + with pytest.raises(ValueError): + fints_client.simple_change_standing_order( + accounts[0], standing_orders[1], last_execution_date=datetime.date(2030, 1, 15), + remove_last_execution_date=True, + ) + + def test_change_standing_order_unchanged_details_omitted(fints_client): with fints_client: accounts = fints_client.get_sepa_accounts() From 4627cea3ef8e237f66ca0dc5222052ddc1f83bbd Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:54 +0200 Subject: [PATCH 11/19] updated supported python version in Readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8363fbb..f74deed 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Limitations * Fetching balances * Fetching holdings * SEPA transfers and debits (only with required TAN and with specific TAN methods) -* Supports Python 3.9+ +* Supports Python 3.11+ Security From 7b8029bb2570c69e2365ec3b84f65d7cadbeb92e Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:34:01 +0200 Subject: [PATCH 12/19] Document VirtualDate for standing order execution dates --- docs/transfers.rst | 4 ++++ fints/client.py | 42 +++++++++++++++++++++++++----------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/transfers.rst b/docs/transfers.rst index ab73321..688a063 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -118,6 +118,10 @@ and the execution day (day of the month or of the week): .. autoclass:: fints.formals.StandingOrderTimeUnit1 :noindex: +The first and last execution dates can be given as ``datetime.date`` or as ``VirtualDate`` (see +:ref:`standing-orders`). A virtual date also allows days beyond the end of the month, e.g. +``VirtualDate(2030, 4, 31)`` as first execution of a standing order executed on the 31st of each month. + If you want to supply your own SEPA XML, use ``create_standing_order``. The requested execution date (``ReqdExctnDt``) in the pain message must be ``1999-01-01``, as for a simple transfer: diff --git a/fints/client.py b/fints/client.py index db405b6..0e9b1d0 100644 --- a/fints/client.py +++ b/fints/client.py @@ -22,7 +22,7 @@ StandingOrderTimeUnit1, ) from .message import FinTSInstituteMessage -from .models import SEPAAccount, Transaction +from .models import SEPAAccount, Transaction, VirtualDate from .parser import FinTS3Serializer from .security import ( PinTanDummyEncryptionMechanism, PinTanOneStepAuthenticationMechanism, @@ -811,9 +811,9 @@ def delete_standing_order(self, account: SEPAAccount, standing_order, execution_ def simple_create_standing_order(self, account: SEPAAccount, iban: str, bic: str, recipient_name: str, amount: Decimal, account_name: str, reason: str, - first_execution_date: datetime.date, + first_execution_date: datetime.date | VirtualDate, time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, - last_execution_date: datetime.date = None, endtoend_id='NOTPROVIDED'): + last_execution_date: datetime.date | VirtualDate = None, endtoend_id='NOTPROVIDED'): """ Simple SEPA standing order (Dauerauftrag). @@ -824,11 +824,13 @@ def simple_create_standing_order(self, account: SEPAAccount, iban: str, bic: str :param amount: Amount as a ``Decimal`` :param account_name: Sender account name :param reason: Transfer reason - :param first_execution_date: Date of the first execution + :param first_execution_date: Date of the first execution (``datetime.date``, or ``VirtualDate`` for days + beyond the end of the month, e.g. 2030-04-31) :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` :param interval: Execute every ``interval`` months or weeks :param execution_day: Day of the month (1-31) or of the week (1-7) to execute on - :param last_execution_date: Date of the last execution (defaults to ``None``, i.e. unlimited) + :param last_execution_date: Date of the last execution (``datetime.date`` or ``VirtualDate``, defaults to + ``None``, i.e. unlimited) :param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``) :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse (with data['task_id'] set, if available). Banks are not required to return the task id @@ -843,9 +845,10 @@ def simple_create_standing_order(self, account: SEPAAccount, iban: str, bic: str last_execution_date=last_execution_date, pain_descriptor=pain_descriptor, ) - def create_standing_order(self, account: SEPAAccount, pain_message: str | bytes, first_execution_date: datetime.date, + def create_standing_order(self, account: SEPAAccount, pain_message: str | bytes, + first_execution_date: datetime.date | VirtualDate, time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, - last_execution_date: datetime.date = None, + last_execution_date: datetime.date | VirtualDate = None, pain_descriptor='urn:iso:std:iso:20022:tech:xsd:pain.001.001.03'): """ Custom SEPA standing order (Dauerauftrag). @@ -854,11 +857,13 @@ def create_standing_order(self, account: SEPAAccount, pain_message: str | bytes, :param pain_message: SEPA PAIN message (``str`` or ``bytes``) containing the transfer details. The requested execution date in the message must be ``1999-01-01``, the schedule is given by the other parameters. - :param first_execution_date: Date of the first execution + :param first_execution_date: Date of the first execution (``datetime.date``, or ``VirtualDate`` for days + beyond the end of the month, e.g. 2030-04-31) :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` :param interval: Execute every ``interval`` months or weeks :param execution_day: Day of the month (1-31) or of the week (1-7) to execute on - :param last_execution_date: Date of the last execution (defaults to ``None``, i.e. unlimited) + :param last_execution_date: Date of the last execution (``datetime.date`` or ``VirtualDate``, defaults to + ``None``, i.e. unlimited) :param pain_descriptor: URN of the PAIN message schema used. :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse (with data['task_id'] set, if available). Banks are not required to return the task id @@ -908,9 +913,9 @@ def _continue_create_standing_order(self, command_seg, response): def simple_change_standing_order(self, account: SEPAAccount, standing_order, amount: Decimal = None, reason: str = None, recipient_name: str = None, iban: str = None, - bic: str = None, first_execution_date: datetime.date = None, + bic: str = None, first_execution_date: datetime.date | VirtualDate = None, time_unit: StandingOrderTimeUnit1 = None, interval: int = None, - execution_day: int = None, last_execution_date: datetime.date = None, + execution_day: int = None, last_execution_date: datetime.date | VirtualDate = None, remove_last_execution_date: bool = False, execution_date: datetime.date = None): """ Simple change of a SEPA standing order (Dauerauftrag). @@ -926,11 +931,12 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo :param recipient_name: New recipient name :param iban: New recipient IBAN. If no ``bic`` is given, an existing recipient BIC is removed. :param bic: New recipient BIC - :param first_execution_date: New date of the first execution + :param first_execution_date: New date of the first execution (``datetime.date``, or ``VirtualDate`` for + days beyond the end of the month, e.g. 2030-04-31) :param time_unit: New time unit, ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` :param interval: New interval (every ``interval`` months or weeks) :param execution_day: New day of the month (1-31) or of the week (1-7) to execute on - :param last_execution_date: New date of the last execution + :param last_execution_date: New date of the last execution (``datetime.date`` or ``VirtualDate``) :param remove_last_execution_date: Remove the date of the last execution, i.e. make the standing order unlimited (defaults to ``False``). Cannot be combined with ``last_execution_date``. :param execution_date: Execution date from which on the change applies. Only allowed if the bank @@ -961,8 +967,8 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo ) def change_standing_order(self, account: SEPAAccount, standing_order, pain_message: str | bytes, - first_execution_date: datetime.date, time_unit: StandingOrderTimeUnit1, - interval: int, execution_day: int, last_execution_date: datetime.date = None, + first_execution_date: datetime.date | VirtualDate, time_unit: StandingOrderTimeUnit1, + interval: int, execution_day: int, last_execution_date: datetime.date | VirtualDate = None, execution_date: datetime.date = None): """ Custom change of a SEPA standing order (Dauerauftrag). @@ -972,11 +978,13 @@ def change_standing_order(self, account: SEPAAccount, standing_order, pain_messa :param pain_message: Complete new SEPA PAIN message (``str`` or ``bytes``). It must use the same schema as the pain message of the standing order (``standing_order.sepa_descriptor``), and its requested execution date must be ``1999-01-01``. - :param first_execution_date: Date of the first execution + :param first_execution_date: Date of the first execution (``datetime.date``, or ``VirtualDate`` for days + beyond the end of the month, e.g. 2030-04-31) :param time_unit: ``StandingOrderTimeUnit1.MONTHLY`` or ``StandingOrderTimeUnit1.WEEKLY`` :param interval: Execute every ``interval`` months or weeks :param execution_day: Day of the month (1-31) or of the week (1-7) to execute on - :param last_execution_date: Date of the last execution (defaults to ``None``, i.e. unlimited) + :param last_execution_date: Date of the last execution (``datetime.date`` or ``VirtualDate``, defaults to + ``None``, i.e. unlimited) :param execution_date: Execution date from which on the change applies. Only allowed if the bank supports scheduled changes. If not given, the change applies from the next execution on. :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse From d8958c2541d1fd488d2af26a117f824750e80482 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:35:30 +0200 Subject: [PATCH 13/19] Align standing order doc examples with existing examples --- docs/transfers.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/transfers.rst b/docs/transfers.rst index 688a063..2efb7b5 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -158,10 +158,10 @@ of the final ``TransactionResponse``. interval=1, execution_day=1, ) - while isinstance(res, (NeedTANResponse, NeedVOPResponse)): + while isinstance(res, NeedTANResponse | NeedVOPResponse): if isinstance(res, NeedTANResponse): res = ask_for_tan(res) - else: + elif isinstance(res, NeedVOPResponse): res = client.approve_vop_response(res) print(res.status, res.data.get('task_id')) @@ -193,13 +193,15 @@ it is returned in ``data['task_id']`` (and the previous one in ``data['task_id_o .. code-block:: python standing_orders = client.get_standing_orders(account) - order = standing_orders[0] + while isinstance(standing_orders, NeedTANResponse): + standing_orders = ask_for_tan(standing_orders) + order = standing_orders[0] res = client.simple_change_standing_order(account, order, amount=Decimal('30.00'), execution_day=15) - while isinstance(res, (NeedTANResponse, NeedVOPResponse)): + while isinstance(res, NeedTANResponse | NeedVOPResponse): if isinstance(res, NeedTANResponse): res = ask_for_tan(res) - else: + elif isinstance(res, NeedVOPResponse): res = client.approve_vop_response(res) print(res.status, res.data.get('task_id')) From f85629cf23e31baaf048338dfc989d2c57357bb0 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:37:42 +0200 Subject: [PATCH 14/19] Add standing orders to README and troubleshooting script --- README.md | 1 + docs/trouble.rst | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index f74deed..07c0354 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Limitations * Fetching balances * Fetching holdings * SEPA transfers and debits (only with required TAN and with specific TAN methods) + * SEPA standing orders (fetching, creating, changing and deleting) * Supports Python 3.11+ diff --git a/docs/trouble.rst b/docs/trouble.rst index 95ab464..db12087 100644 --- a/docs/trouble.rst +++ b/docs/trouble.rst @@ -123,6 +123,7 @@ the problem. "Fetch status protocol", "Make a simple transfer", "Fetch statements as PDF", + "Fetch standing orders", ] print("Choose an operation") @@ -209,5 +210,10 @@ the problem. with open(output_pdf, 'wb') as file: file.write(res.data) print("Written to", output_pdf) + elif choice == 12: + res = f.get_standing_orders(account) + while isinstance(res, NeedTANResponse): + res = ask_for_tan(res) + print(res) except FinTSUnsupportedOperation as e: print("This operation is not supported by this bank:", e) \ No newline at end of file From acd0fa955d24974f48fb5e51ed42e843a3b93dfe Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:41:40 +0200 Subject: [PATCH 15/19] Derive VirtualDateField from DateField --- fints/fields.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/fints/fields.py b/fints/fields.py index fad80c6..a2e6462 100644 --- a/fints/fields.py +++ b/fints/fields.py @@ -276,28 +276,23 @@ def _render_value(self, value): return super()._render_value(val) -class VirtualDateField(FixedLengthMixin, NumericField): +class VirtualDateField(DateField): type = 'vdat' _DOC_TYPE = VirtualDate - _FIXED_LENGTH = [8] def _parse_value(self, value): if isinstance(value, VirtualDate): return value if isinstance(value, datetime.date): return VirtualDate.from_date(value) - val = super()._parse_value(value) + # Skip DateField._parse_value, which only allows existing dates + val = super(DateField, self)._parse_value(value) val = str(val) retval = VirtualDate(int(val[0:4]), int(val[4:6]), int(val[6:8])) if not (1 <= retval.month <= 12 and 1 <= retval.day <= 31): raise ValueError("Invalid virtual date: {!r}".format(value)) return retval - def _render_value(self, value): - val = "{:04d}{:02d}{:02d}".format(value.year, value.month, value.day) - val = int(val) - return super()._render_value(val) - class TimeField(FixedLengthMixin, DigitsField): type = 'tim' # FIXME Need test From b565fb8ace86afad0e745490ba55718121f20437 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:06:46 +0200 Subject: [PATCH 16/19] Add get_standing_order_parameters() to read allowed standing order values --- docs/transfers.rst | 7 ++++++ fints/client.py | 54 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_client.py | 27 ++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/docs/transfers.rst b/docs/transfers.rst index 2efb7b5..65fed3e 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -122,6 +122,13 @@ The first and last execution dates can be given as ``datetime.date`` or as ``Vir :ref:`standing-orders`). A virtual date also allows days beyond the end of the month, e.g. ``VirtualDate(2030, 4, 31)`` as first execution of a standing order executed on the 31st of each month. +Which intervals, execution days and advance notices the bank allows, e.g. to only offer allowed values to the user, +can be found out with ``get_standing_order_parameters``: + +.. autoclass:: fints.client.FinTS3Client + :members: get_standing_order_parameters + :noindex: + If you want to supply your own SEPA XML, use ``create_standing_order``. The requested execution date (``ReqdExctnDt``) in the pain message must be ``1999-01-01``, as for a simple transfer: diff --git a/fints/client.py b/fints/client.py index 0e9b1d0..750efd1 100644 --- a/fints/client.py +++ b/fints/client.py @@ -779,6 +779,60 @@ def get_standing_orders(self, account: SEPAAccount, task_id: str = None): return responses + def get_standing_order_parameters(self): + """ + Return the values the bank allows for creating and changing SEPA standing orders (Daueraufträge), e.g. to + only offer allowed values to the user. The values are checked by the bank, not by this library. + + Note: Can only be filled after the first communication with the bank. + If in doubt, use a construction like:: + + f = FinTS3Client(...) + with f: + parameters = f.get_standing_order_parameters() + + Returns a nested dictionary:: + + create: (None if the bank does not support creating standing orders) + months: Allowed intervals in months, e.g. [1, 3, 6, 12] + weeks: Allowed intervals in weeks, [] if weekly standing orders are not supported + execution_days_month: Allowed days of the month, e.g. [1, 15, 99]. 97, 98 and 99 stand for the + third to last, second to last and last day of the month, the 31st is given as 99. + execution_days_week: Allowed days of the week, 1 (Monday) to 7 (Sunday) + min_advance_notice: Minimum advance notice before the (next) execution in days + max_advance_notice: Maximum advance notice in days + change: (None if the bank does not support changing standing orders) + Same keys as create, the bank may allow different values for changes. + + """ + def allowed_values(value, width, all_values): + # The bank gives the allowed values as a chain of numbers with fixed width, 0 stands for all values + values = [int(value[i:i + width]) for i in range(0, len(value or ''), width)] + return list(all_values) if 0 in values else values + + retval = {} + for key, command_class in (('create', HKCDE1), ('change', HKCDN1)): + try: + parameter_segment, _ = self._find_highest_supported_command( + command_class, + return_parameter_segment=True + ) + except FinTSUnsupportedOperation: + retval[key] = None + continue + + parameter = parameter_segment.parameter + retval[key] = { + 'months': allowed_values(parameter.months, 2, range(1, 13)), + 'weeks': allowed_values(parameter.weeks, 2, range(1, 53)), + 'execution_days_month': allowed_values(parameter.execution_days_month, 2, [*range(1, 31), 97, 98, 99]), + 'execution_days_week': allowed_values(parameter.execution_days_week, 1, range(1, 8)), + 'min_advance_notice': parameter.min_advance_notice, + 'max_advance_notice': parameter.max_advance_notice, + } + + return retval + def delete_standing_order(self, account: SEPAAccount, standing_order, execution_date: datetime.date = None): """ Delete a SEPA standing order (Dauerauftrag). diff --git a/tests/test_client.py b/tests/test_client.py index 6d9fc22..1d76c03 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -248,6 +248,33 @@ def test_get_standing_orders(fints_client): assert standing_orders[1].task_cancelable is True +def test_get_standing_order_parameters(fints_client): + with fints_client: + fints_client.get_sepa_accounts() + + hicdes = fints_client.bpd.find_segment_first('HICDES').parameter + hicdes.months = '0102030612' + hicdes.weeks = '0102' + hicdes.execution_days_month = '01159899' + hicdes.execution_days_week = '12345' + + parameters = fints_client.get_standing_order_parameters() + + assert parameters['create'] == { + 'months': [1, 2, 3, 6, 12], + 'weeks': [1, 2], + 'execution_days_month': [1, 15, 98, 99], + 'execution_days_week': [1, 2, 3, 4, 5], + 'min_advance_notice': 1, + 'max_advance_notice': 3650, + } + # The test bank allows all intervals and days for changes, but no weekly standing orders + assert parameters['change']['months'] == list(range(1, 13)) + assert parameters['change']['execution_days_month'] == list(range(1, 31)) + [97, 98, 99] + assert parameters['change']['weeks'] == [] + assert parameters['change']['execution_days_week'] == [] + + def test_delete_standing_order(fints_client): with fints_client: accounts = fints_client.get_sepa_accounts() From 2e66f335a50d5972106fc4df97f05209821afd42 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:23:05 +0200 Subject: [PATCH 17/19] Add parse_sepa_transfer_pain() to read standing order payments --- docs/reading.rst | 23 ++++++++++------------- fints/models.py | 2 ++ fints/utils.py | 46 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_utils.py | 27 ++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/docs/reading.rst b/docs/reading.rst index d7260b2..dbc4f01 100644 --- a/docs/reading.rst +++ b/docs/reading.rst @@ -90,7 +90,11 @@ You can fetch the SEPA standing orders ("Daueraufträge") of an account with the This method will return a list of ``HICDB1`` segments, one per standing order. The schedule is available in ``standing_order_details`` (see :class:`~fints.formals.StandingOrderDetails3`), a suspension, if any, in ``suspension`` (see :class:`~fints.formals.StandingOrderSuspension3`). Recipient, amount and purpose are contained in -the SEPA pain.001 message in ``sepa_pain_message``, which you can parse e.g. with ``lxml``. +the SEPA pain.001 message in ``sepa_pain_message``, which you can read with ``parse_sepa_transfer_pain``: + +.. autofunction:: fints.utils.parse_sepa_transfer_pain + +.. autoclass:: fints.models.SEPATransferPayment The first and last execution dates are *virtual dates*, which allow 31 days in every month (e.g. 2025-04-31 for a standing order executed on the last day of each month). They are returned as ``VirtualDate`` objects: @@ -102,7 +106,7 @@ Example: .. code-block:: python - from lxml import etree + from fints.utils import parse_sepa_transfer_pain standing_orders = client.get_standing_orders(account) while isinstance(standing_orders, NeedTANResponse): @@ -110,17 +114,10 @@ Example: for order in standing_orders: details = order.standing_order_details + payment = parse_sepa_transfer_pain(order.sepa_pain_message) print("Task ID:", order.task_id) print("First/last execution:", details.first_execution_date, details.last_execution_date) print("Interval:", details.interval, details.time_unit, "on day", details.execution_day) - - # The pain.001 version depends on the bank, so match elements regardless of namespace - pain = etree.fromstring(order.sepa_pain_message) - for tx in pain.xpath('//*[local-name()="CdtTrfTxInf"]'): - def text(path): - found = tx.xpath('.//' + '/'.join('*[local-name()="{}"]'.format(p) for p in path.split('/'))) - return found[0].text if found else None - - print("Recipient:", text('Cdtr/Nm'), text('CdtrAcct/Id/IBAN')) - print("Amount:", text('Amt/InstdAmt')) - print("Purpose:", text('RmtInf/Ustrd')) + print("Recipient:", payment.recipient_name, payment.iban, payment.bic) + print("Amount:", payment.amount.amount, payment.amount.currency) + print("Purpose:", payment.reason) diff --git a/fints/models.py b/fints/models.py index b472274..b1e3642 100644 --- a/fints/models.py +++ b/fints/models.py @@ -13,6 +13,8 @@ Transaction = namedtuple('Transaction', 'data') +SEPATransferPayment = namedtuple('SEPATransferPayment', 'recipient_name iban bic amount reason endtoend_id') + class VirtualDate(namedtuple('VirtualDate', 'year month day')): """Virtual date (FinTS format ``vdat``): Like a date, but every month may have 31 days, diff --git a/fints/utils.py b/fints/utils.py index dcce5e4..d20d1e8 100644 --- a/fints/utils.py +++ b/fints/utils.py @@ -15,7 +15,7 @@ from lxml import etree from sepaxml.utils import make_msg_id -from .models import Holding +from .models import Amount, Holding, SEPATransferPayment def mt940_to_array(data): @@ -108,6 +108,50 @@ def new_element(parent, tag, index=None): return etree.tostring(root, xml_declaration=True, encoding='UTF-8') +def parse_sepa_transfer_pain(pain_message): + """ + Read the payment of a SEPA pain.001 credit transfer message with exactly one payment, e.g. one returned by + ``get_standing_orders``. This is the counterpart to ``modify_sepa_transfer_pain``. + + :param pain_message: pain.001 message as ``bytes`` or ``str`` + :return: ``SEPATransferPayment`` object. Values that are missing in the message are ``None``. + """ + if isinstance(pain_message, str): + pain_message = pain_message.encode('utf-8') + root = etree.fromstring(pain_message) + + def find(node, path): + # Namespace-agnostic, since the pain.001 version depends on the bank + return node.xpath('./' + '/'.join('*[local-name()="{}"]'.format(p) for p in path.split('/'))) + + def text(node, path): + found = find(node, path) + return found[0].text.strip() if found and found[0].text else None + + transactions = root.xpath('//*[local-name()="CdtTrfTxInf"]') + if len(transactions) != 1: + raise ValueError("Only pain messages with exactly one payment are supported.") + tx = transactions[0] + + instructed_amount = find(tx, 'Amt/InstdAmt') + if instructed_amount and instructed_amount[0].text: + amount = Amount(decimal.Decimal(instructed_amount[0].text.strip()), instructed_amount[0].get('Ccy')) + else: + amount = None + + purposes = [e.text.strip() for e in find(tx, 'RmtInf/Ustrd') if e.text] + + return SEPATransferPayment( + recipient_name=text(tx, 'Cdtr/Nm'), + iban=text(tx, 'CdtrAcct/Id/IBAN'), + # pain.001.001.03 and the older German schemas use , later versions use + bic=text(tx, 'CdtrAgt/FinInstnId/BIC') or text(tx, 'CdtrAgt/FinInstnId/BICFI'), + amount=amount, + reason=' '.join(purposes) or None, + endtoend_id=text(tx, 'PmtId/EndToEndId'), + ) + + def classproperty(f): class fx: def __init__(self, getter): diff --git a/tests/test_utils.py b/tests/test_utils.py index afcc34f..e542734 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -78,3 +78,30 @@ def values(xml, name): with pytest.raises(ValueError): modify_sepa_transfer_pain(_make_pain(version, payments=2), amount=Decimal('1')) + + +@pytest.mark.parametrize("version", ['pain.001.001.03', 'pain.001.001.09']) +def test_parse_sepa_transfer_pain(version): + from decimal import Decimal + from fints.models import Amount + from fints.utils import modify_sepa_transfer_pain, parse_sepa_transfer_pain + + payment = parse_sepa_transfer_pain(_make_pain(version)) + assert payment.recipient_name == 'Test Receiver' + assert payment.iban == 'DE111234567800000002' + assert payment.bic == 'GENODE23X42' + assert payment.amount == Amount(Decimal('1.23'), 'EUR') + assert payment.reason == 'Old reason' + assert payment.endtoend_id == 'NOTPROVIDED' + + # Reads back what modify_sepa_transfer_pain changed, also from a str + changed = modify_sepa_transfer_pain(_make_pain(version), amount=Decimal('30'), reason='New reason', bic='COBADEFFXXX') + payment = parse_sepa_transfer_pain(changed.decode()) + assert payment.amount == Amount(Decimal('30.00'), 'EUR') + assert payment.reason == 'New reason' + assert payment.bic == 'COBADEFFXXX' + + assert parse_sepa_transfer_pain(_make_pain(version, bic=None)).bic is None + + with pytest.raises(ValueError): + parse_sepa_transfer_pain(_make_pain(version, payments=2)) From 784f7d626bd688770b65c7fa987092c91b15951c Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:09:06 +0200 Subject: [PATCH 18/19] Add endtoend_id to modify standing order parameters --- fints/client.py | 6 ++++-- fints/utils.py | 7 ++++++- tests/test_utils.py | 8 +++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/fints/client.py b/fints/client.py index 750efd1..15f215c 100644 --- a/fints/client.py +++ b/fints/client.py @@ -970,7 +970,8 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo bic: str = None, first_execution_date: datetime.date | VirtualDate = None, time_unit: StandingOrderTimeUnit1 = None, interval: int = None, execution_day: int = None, last_execution_date: datetime.date | VirtualDate = None, - remove_last_execution_date: bool = False, execution_date: datetime.date = None): + remove_last_execution_date: bool = False, execution_date: datetime.date = None, + endtoend_id: str = None): """ Simple change of a SEPA standing order (Dauerauftrag). @@ -995,6 +996,7 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo unlimited (defaults to ``False``). Cannot be combined with ``last_execution_date``. :param execution_date: Execution date from which on the change applies. Only allowed if the bank supports scheduled changes. If not given, the change applies from the next execution on. + :param endtoend_id: New End-to-end-Id :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse (with data['task_id'] and data['task_id_old'] set, if available). Banks may process the change asynchronously, so it can take a while until it is visible in ``get_standing_orders``. @@ -1008,7 +1010,7 @@ def simple_change_standing_order(self, account: SEPAAccount, standing_order, amo pain_message = modify_sepa_transfer_pain( standing_order.sepa_pain_message, amount=amount, reason=reason, - recipient_name=recipient_name, iban=iban, bic=bic, + recipient_name=recipient_name, iban=iban, bic=bic, endtoend_id=endtoend_id, ) return self.change_standing_order( account, standing_order, pain_message, diff --git a/fints/utils.py b/fints/utils.py index d20d1e8..0a0f412 100644 --- a/fints/utils.py +++ b/fints/utils.py @@ -25,7 +25,8 @@ def mt940_to_array(data): return transactions.parse(data) -def modify_sepa_transfer_pain(pain_message, amount=None, reason=None, recipient_name=None, iban=None, bic=None): +def modify_sepa_transfer_pain(pain_message, amount=None, reason=None, recipient_name=None, iban=None, bic=None, + endtoend_id=None): """ Change values of a SEPA pain.001 credit transfer message with exactly one payment, e.g. one returned by ``get_standing_orders``. @@ -40,6 +41,7 @@ def modify_sepa_transfer_pain(pain_message, amount=None, reason=None, recipient_ :param recipient_name: New recipient name :param iban: New recipient IBAN :param bic: New recipient BIC + :param endtoend_id: New End-to-end-Id :return: The changed pain.001 message as ``bytes`` """ if isinstance(pain_message, str): @@ -100,6 +102,9 @@ def new_element(parent, tag, index=None): use_bicfi = version is not None and version.group(1) == '001' and int(version.group(2)) >= 4 new_element(institution, 'BICFI' if use_bicfi else 'BIC').text = bic + if endtoend_id is not None: + find(tx, 'PmtId/EndToEndId')[0].text = endtoend_id + for message_id in root.xpath('//*[local-name()="GrpHdr"]/*[local-name()="MsgId"]'): message_id.text = make_msg_id() for creation_time in root.xpath('//*[local-name()="GrpHdr"]/*[local-name()="CreDtTm"]'): diff --git a/tests/test_utils.py b/tests/test_utils.py index e542734..1eac213 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -76,6 +76,10 @@ def values(xml, name): try_valid_xml(changed, version) assert 'COBADEFFXXX' in values(changed, bic_tag) + changed = modify_sepa_transfer_pain(original, endtoend_id='NEW-E2E-ID') + try_valid_xml(changed, version) + assert values(changed, 'EndToEndId') == ['NEW-E2E-ID'] + with pytest.raises(ValueError): modify_sepa_transfer_pain(_make_pain(version, payments=2), amount=Decimal('1')) @@ -95,11 +99,13 @@ def test_parse_sepa_transfer_pain(version): assert payment.endtoend_id == 'NOTPROVIDED' # Reads back what modify_sepa_transfer_pain changed, also from a str - changed = modify_sepa_transfer_pain(_make_pain(version), amount=Decimal('30'), reason='New reason', bic='COBADEFFXXX') + changed = modify_sepa_transfer_pain(_make_pain(version), amount=Decimal('30'), reason='New reason', bic='COBADEFFXXX', + endtoend_id='NEW-E2E-ID') payment = parse_sepa_transfer_pain(changed.decode()) assert payment.amount == Amount(Decimal('30.00'), 'EUR') assert payment.reason == 'New reason' assert payment.bic == 'COBADEFFXXX' + assert payment.endtoend_id == 'NEW-E2E-ID' assert parse_sepa_transfer_pain(_make_pain(version, bic=None)).bic is None From 8795916f017e4aa638e513ed7117a113fb3c8755 Mon Sep 17 00:00:00 2001 From: KolSmn <74245816+KolSmn@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:24:57 +0200 Subject: [PATCH 19/19] increase coverage --- tests/test_client.py | 13 +++++++++++++ tests/test_utils.py | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 1d76c03..ce70395 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -275,6 +275,19 @@ def test_get_standing_order_parameters(fints_client): assert parameters['change']['execution_days_week'] == [] +def test_get_standing_order_parameters_unsupported(fints_client): + with fints_client: + fints_client.get_sepa_accounts() + + # Bank does not support changing standing orders + fints_client.bpd.segments = [s for s in fints_client.bpd.segments if s.header.type != 'HICDNS'] + + parameters = fints_client.get_standing_order_parameters() + + assert parameters['create'] is not None + assert parameters['change'] is None + + def test_delete_standing_order(fints_client): with fints_client: accounts = fints_client.get_sepa_accounts() diff --git a/tests/test_utils.py b/tests/test_utils.py index 1eac213..dd1fd01 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -42,6 +42,15 @@ def _make_pain(version, bic='GENODE23X42', payments=1): return sepa.export() +def _remove_from_pain(pain, name): + from lxml import etree + + root = etree.fromstring(pain) + for element in root.xpath('//*[local-name()="{}"]'.format(name)): + element.getparent().remove(element) + return etree.tostring(root, xml_declaration=True, encoding='UTF-8') + + @pytest.mark.parametrize("version, bic_tag", [('pain.001.001.03', 'BIC'), ('pain.001.001.09', 'BICFI')]) def test_modify_sepa_transfer_pain(version, bic_tag): from decimal import Decimal @@ -80,6 +89,18 @@ def values(xml, name): try_valid_xml(changed, version) assert values(changed, 'EndToEndId') == ['NEW-E2E-ID'] + # Recipient name: only the creditor name changes, the debtor name stays + changed = modify_sepa_transfer_pain(original, recipient_name='New Receiver') + try_valid_xml(changed, version) + assert 'New Receiver' in values(changed, 'Nm') + assert 'Test Receiver' not in values(changed, 'Nm') + assert 'Test Sender' in values(changed, 'Nm') + + # Reason for a message without remittance information: RmtInf/Ustrd is created + changed = modify_sepa_transfer_pain(_remove_from_pain(original, 'RmtInf'), reason='New reason') + try_valid_xml(changed, version) + assert values(changed, 'Ustrd') == ['New reason'] + with pytest.raises(ValueError): modify_sepa_transfer_pain(_make_pain(version, payments=2), amount=Decimal('1')) @@ -109,5 +130,10 @@ def test_parse_sepa_transfer_pain(version): assert parse_sepa_transfer_pain(_make_pain(version, bic=None)).bic is None + # Missing values are None + payment = parse_sepa_transfer_pain(_remove_from_pain(_make_pain(version), 'Amt')) + assert payment.amount is None + assert payment.recipient_name == 'Test Receiver' + with pytest.raises(ValueError): parse_sepa_transfer_pain(_make_pain(version, payments=2))