Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion fints/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import logging
import time
from abc import ABCMeta, abstractmethod
from base64 import b64decode
from collections import OrderedDict
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down