From 9f1ddb66ae5da4ac6be3c629c9ed1aa3bb6e8b8c Mon Sep 17 00:00:00 2001 From: Keven Hall Date: Tue, 25 Aug 2026 16:17:25 +0200 Subject: [PATCH] Poll for asynchronous VoP results (Atruvia banks) Some banks do not return the Verification of Payee result inline. Their HIVPP segment carries neither vop_id nor vop_single_result, only a polling_id and wait_for_seconds, accompanied by response codes 3905 ("no challenge created") and 3040 with the touchdown point (Aufsetzpunkt). python-fints only evaluates inline results and never polls, so these transfers inevitably end in 3945 ("approval without VoP confirmation not possible") and can never be executed. This affects all Atruvia-hosted banks (Volksbank, GLS, VR), see #220. The data structures were already present (HKVPP1.polling_id, HKVPP1.aufsetzpunkt, HIVPP1.polling_id) -- only the client-side loop was missing. Adds: - _find_vop_aufsetzpunkt(): reads the touchdown point from HIRMS code 3040. - _poll_vop_result(): re-sends HKVPP with polling_id AND aufsetzpunkt until a result arrives. Both are mandatory; sending only one yields 9210. Two deliberate design choices: - The loop terminates on the presence of vop_id/payment_status_report rather than on a specific response code. Depending on the server release, Atruvia completes the check with either 3090 or 0020/0025, so checking for a code would loop forever on the other variant. - On timeout it raises FinTSClientError instead of falling through. Without a result there is nothing to approve and the order was not executed; a caller receiving a warnings-only TransactionResponse could mistake it for success. Backwards compatibility is handled by flow type, not by the presence of vop_id: only when polling actually happened does the code return NeedVOPResponse early. Banks answering inline -- Sparkassen, which already fire the pushTAN in step 1 and reject a separate HKVPA with 9010 -- fall through to the unchanged branch and keep their TAN detection. Verified against Berliner Volksbank (BLZ 10090000, Atruvia). The polled result arrived on the first attempt and the transfer was executed: 3945 Freigabe ohne VOP-Bestaetigung nicht moeglich -> VoP polling attempt 1 (aufsetzpunkt='staticscrollref', wait=2s) -> 0025 Keine Namensabweichung, vop_id set -> 0020 Ausfuehrungsbestaetigung nach Namensabgleich erhalten -> 0020 SEPA-Einzelueberweisung erfolgreich Note: that test was a same-account transfer, which the bank waived SCA for (3076). The TAN path for third-party payees is not yet covered. --- fints/client.py | 114 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/fints/client.py b/fints/client.py index 213972a..5123091 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1,5 +1,6 @@ import datetime import logging +import time from abc import ABCMeta, abstractmethod from base64 import b64decode from collections import OrderedDict @@ -1438,6 +1439,85 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func): return resume_func(command_seg, response) + def _find_vop_aufsetzpunkt(self, response): + """Extract the touchdown point (Aufsetzpunkt) from a HIRMS 3040 response. + + Banks return it as the first parameter of response code 3040 + ("Es liegen weitere Informationen vor"). + """ + for hirms_seg in response.find_segments(HIRMS2): + for resp in hirms_seg.responses: + if resp.code == '3040' and resp.parameters: + return resp.parameters[0] + return None + + def _poll_vop_result(self, dialog, response, hivpp, vop_standard, timeout_seconds=60): + """Poll for an asynchronously produced VoP result (FinTS spec E.8.3.1). + + Some banks (Atruvia: Volksbank, GLS, VR) do not return the VoP result + inline. Their HIVPP carries neither ``vop_id`` nor ``vop_single_result``, + only a ``polling_id`` and ``wait_for_seconds``, accompanied by response + codes 3905 ("no challenge created") and 3040 with the touchdown point. + + This re-sends HKVPP with ``polling_id`` **and** ``aufsetzpunkt`` until the + bank returns a result. Both are mandatory -- sending only one yields 9210. + + The loop terminates on the presence of ``vop_id`` or + ``payment_status_report`` rather than on a specific response code: + depending on the server release, Atruvia completes the check with either + 3090 or 0020/0025, so checking for a code would loop forever on the other + variant. + + The wait interval is taken from each response anew (``wait_for_seconds``) + instead of using a fixed delay. + + Raises: + FinTSClientError: if no result is available within ``timeout_seconds``. + Deliberately an exception rather than falling through: without a + result there is nothing to approve and the order was not executed, + so a caller receiving a warnings-only response could mistake it + for success. + """ + from .segments.auth import HKVPP1 + + aufsetzpunkt = self._find_vop_aufsetzpunkt(response) + deadline = time.monotonic() + timeout_seconds + attempt = 0 + + while True: + attempt += 1 + wait_seconds = int(hivpp.wait_for_seconds) if hivpp.wait_for_seconds else 2 + + if time.monotonic() + wait_seconds > deadline: + raise FinTSClientError( + "VoP result not available after {} attempt(s) within {}s " + "(polling_id={!r}). The transfer was NOT executed.".format( + attempt - 1, timeout_seconds, hivpp.polling_id)) + + logger.info("VoP polling attempt %d (polling_id=%r, aufsetzpunkt=%r, wait=%ds)", + attempt, hivpp.polling_id, aufsetzpunkt, wait_seconds) + time.sleep(wait_seconds) + + # HKVPP only -- no payment order, no HKTAN. The original order is held + # by the bank awaiting approval; re-sending it starts a NEW VoP check. + poll_response = dialog.send(HKVPP1( + supported_reports=PSRD1(psrd=[vop_standard]), + polling_id=hivpp.polling_id, + aufsetzpunkt=aufsetzpunkt, + )) + hivpp = poll_response.find_segment_first(HIVPP1, throw=True) + + if hivpp.vop_id or hivpp.payment_status_report: + logger.info("VoP result available after %d attempt(s) (vop_id=%r)", + attempt, hivpp.vop_id) + return hivpp + + # Not ready yet: the bank may supply a new touchdown point. The + # previous one stays valid as long as it does not. + next_aufsetzpunkt = self._find_vop_aufsetzpunkt(poll_response) + if next_aufsetzpunkt: + aufsetzpunkt = next_aufsetzpunkt + def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): """ This adds VoP under the assumption that TAN will be sent, @@ -1470,9 +1550,41 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): if vop_standard: hivpp = response.find_segment_first(HIVPP1, throw=True) + # Asynchronous VoP flow: the bank has not checked the payee yet + # and hands us a polling_id instead of a result. Without polling + # this inevitably ends in 3945 ("approval without VoP confirmation + # not possible") and the transfer can never be executed. + # + # `polled` records which flow we are in. This is what keeps the + # change backwards compatible: banks answering inline (e.g. + # Sparkassen, which already fire the pushTAN in step 1) fall + # through to the unchanged branch below and keep their TAN + # detection. Branching on the presence of `vop_id` alone would + # take that path away from them. + polled = False + if not hivpp.vop_id and hivpp.polling_id: + hivpp = self._poll_vop_result(dialog, response, hivpp, vop_standard) + polled = True + vop_result = hivpp.vop_single_result + # On polled responses vop_single_result is empty (result=None); + # the outcome lives in payment_status_report (pain.002). Read + # defensively instead of dereferencing. + result_code = getattr(vop_result, 'result', None) if vop_result is not None else None + + # Asynchronous flow: the bank produced its result and now awaits + # confirmation via HKVPA. Hand the caller the HIVPP carrying + # vop_id and the pain.002 so it can decide whether to approve -- + # which is the entire point of VoP. + if polled: + return NeedVOPResponse( + vop_result=hivpp, + command_seg=command_seg, + resume_method=resume_func, + ) + # Not Applicable, No Match, Close Match, or exact match but still requires confirmation - if vop_result.result in ('RVNA', 'RVNM', 'RVMC') or (vop_result.result == 'RCVC' and '3945' in [res.code for res in response.responses(tan_seg)]): + if result_code in ('RVNA', 'RVNM', 'RVMC') or (result_code == 'RCVC' and '3945' in [res.code for res in response.responses(tan_seg)]): return NeedVOPResponse( vop_result=hivpp, command_seg=command_seg,