diff --git a/README.md b/README.md index 8363fbb..07c0354 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Limitations * Fetching balances * Fetching holdings * SEPA transfers and debits (only with required TAN and with specific TAN methods) -* Supports Python 3.9+ + * SEPA standing orders (fetching, creating, changing and deleting) +* Supports Python 3.11+ Security 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..dbc4f01 100644 --- a/docs/reading.rst +++ b/docs/reading.rst @@ -74,3 +74,50 @@ 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 + + +.. _standing-orders: + +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 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: + +.. autoclass:: fints.models.VirtualDate + :members: to_date + +Example: + +.. code-block:: python + + from fints.utils import parse_sepa_transfer_pain + + 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 + 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) + print("Recipient:", payment.recipient_name, payment.iban, payment.bic) + print("Amount:", payment.amount.amount, payment.amount.currency) + print("Purpose:", payment.reason) diff --git a/docs/transfers.rst b/docs/transfers.rst index c2e20c2..65fed3e 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -102,3 +102,151 @@ Full example print(res.status) print(res.responses) + + +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: + +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. + +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: + +.. 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. As with changes (see + below), it may take a while until the new standing order shows up there. + +.. 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) + elif isinstance(res, NeedVOPResponse): + res = client.approve_vop_response(res) + + 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. To make a +standing order unlimited, pass ``remove_last_execution_date=True``: + +.. 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) + 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): + if isinstance(res, NeedTANResponse): + res = ask_for_tan(res) + elif isinstance(res, NeedVOPResponse): + 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 +------------------------ + +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/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 diff --git a/fints/client.py b/fints/client.py index 213972a..750efd1 100644 --- a/fints/client.py +++ b/fints/client.py @@ -18,10 +18,11 @@ 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 +from .models import SEPAAccount, Transaction, VirtualDate from .parser import FinTS3Serializer from .security import ( PinTanDummyEncryptionMechanism, PinTanOneStepAuthenticationMechanism, @@ -38,12 +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, 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__) @@ -83,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): @@ -741,6 +747,370 @@ 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_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). + + :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 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 | VirtualDate, + time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, + last_execution_date: datetime.date | VirtualDate = 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 (``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 (``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 + (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 | bytes, + first_execution_date: datetime.date | VirtualDate, + time_unit: StandingOrderTimeUnit1, interval: int, execution_day: int, + 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). + + :param account: SEPAAccount to send the standing order from. + :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 (``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 (``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 + (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 + ) + + 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, + 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 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 | 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): + """ + 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 (``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 (``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 + 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 + 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, + ) + 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, + execution_date=execution_date, + ) + + def change_standing_order(self, account: SEPAAccount, standing_order, pain_message: str | bytes, + 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). + + :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 (``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 (``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 + (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 + + 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): + 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) @@ -854,6 +1224,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, @@ -861,7 +1239,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' @@ -880,7 +1258,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/fields.py b/fints/fields.py index 7f8d6e3..a2e6462 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,24 @@ def _render_value(self, value): return super()._render_value(val) +class VirtualDateField(DateField): + type = 'vdat' + _DOC_TYPE = VirtualDate + + def _parse_value(self, value): + if isinstance(value, VirtualDate): + return value + if isinstance(value, datetime.date): + return VirtualDate.from_date(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 + + class TimeField(FixedLengthMixin, DigitsField): type = 'tim' # FIXME Need test _DOC_TYPE = datetime.time diff --git a/fints/formals.py b/fints/formals.py index 2d9d907..c52fe21 100644 --- a/fints/formals.py +++ b/fints/formals.py @@ -1001,6 +1001,86 @@ 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='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='vdat', 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 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 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 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/models.py b/fints/models.py index 7ed43ce..b1e3642 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,23 @@ Amount = namedtuple('Amount', 'amount currency') 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, + 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/fints/segments/standing_order.py b/fints/segments/standing_order.py new file mode 100644 index 0000000..dfda3e9 --- /dev/null +++ b/fints/segments/standing_order.py @@ -0,0 +1,110 @@ +from ..fields import DataElementField, DataElementGroupField +from ..formals import ( + KTI1, ChangeStandingOrderParameter1, CreateStandingOrderParameter1, + DeleteStandingOrderParameter1, 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") + + +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") + + +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") + + +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..d20d1e8 100644 --- a/fints/utils.py +++ b/fints/utils.py @@ -1,4 +1,5 @@ import base64 +import decimal import json import re import threading @@ -11,8 +12,10 @@ 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 +from .models import Amount, Holding, SEPATransferPayment def mt940_to_array(data): @@ -22,6 +25,133 @@ 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 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/conftest.py b/tests/conftest.py index d584e8f..bf28d64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,6 +167,61 @@ 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:20270431+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') + ) + + 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')) + + 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')) + + 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 f602e4f..1d76c03 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,9 @@ from fints.client import FinTS3PinTanClient, TransactionResponse, NeedTANResponse, ResponseStatus, NeedRetryResponse -from fints.exceptions import FinTSClientPINError, FinTSClientTemporaryAuthError +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 @@ -227,3 +230,221 @@ 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 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 + + +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() + 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)) + + +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_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() + + 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, + ) + + +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_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() + 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_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() + 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_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 diff --git a/tests/test_utils.py b/tests/test_utils.py index 01fe2bd..e542734 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,3 +25,83 @@ 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')) + + +@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))