Skip to content

Poll for asynchronous VoP results (Atruvia banks) - #223

Open
untouch wants to merge 1 commit into
raphaelm:masterfrom
untouch:vop-async-polling
Open

Poll for asynchronous VoP results (Atruvia banks)#223
untouch wants to merge 1 commit into
raphaelm:masterfrom
untouch:vop-async-polling

Conversation

@untouch

@untouch untouch commented Aug 26, 2026

Copy link
Copy Markdown

Fixes the asynchronous VoP flow reported in #220.

Summary

Some banks answer a VoP check later, not inline. They send a polling_id and expect
the client to ask again. python-fints never asks again, so on those banks every transfer
fails
with 3945 and can never be executed.

This adds the missing "ask again" loop — about 70 lines, confined to the VoP path.

Verified on both bank types:

Bank type Result
Atruvia (Berliner Volksbank) — asynchronous Transfer now executes. Previously impossible.
Sparkasse (Hamburger Sparkasse) — inline Unchanged. New code path is never entered.

The problem

On an affected bank, HIVPP comes back like this — no vop_id, no vop_single_result,
just a ticket number and "ask me again in 2 seconds":

HIVPP1(
    polling_id       = b'<uuid>',
    wait_for_seconds = 2,
)

alongside 3905 ("no challenge created"), 3040 with the touchdown point, and 3945
("approval without VoP confirmation not possible").

_send_pay_with_possible_retry() only looks at hivpp.vop_single_result, which is empty
here, so it falls through and the order is never approved. Users in #220 report this for
Berliner Volksbank, Volksbank Dresden-Bautzen and Volksbank Bonn Rhein-Sieg.

The segment fields for polling already exist (HKVPP1.polling_id,
HKVPP1.aufsetzpunkt, HIVPP1.polling_id) — only the client-side loop was missing.

The fix

Two new methods:

Why this cannot break banks that answer inline

This was my main concern while writing it, since @payne1979 pointed out in #220 that
Sparkassen answer inline, fire the pushTAN in step 1, and reject a separate HKVPA with
9010"Any fix that assumes one of the two will break the other."

The branch is therefore selected by flow type, not by the presence of vop_id:

polled = False
if not hivpp.vop_id and hivpp.polling_id:      # only the asynchronous case
    hivpp = self._poll_vop_result(...)
    polled = True
...
if polled:                                      # only if we actually polled
    return NeedVOPResponse(...)

A bank that answers inline never has polling_id without vop_id, so polled stays
False and execution continues into the existing branch untouched — including the
0030/3955 TAN detection.

Measured, not assumed. A transfer through a Hamburger Sparkasse account after this
change produced:

3091 VOP-Ausführungsauftrag nicht benötigt.
0010 Der Auftrag wurde entgegengenommen.
3076 Starke Kundenauthentifizierung nicht notwendig.

with no polling log line at all — the new code was never entered, and the transfer went
through as before.

Verification on the asynchronous side

Berliner Volksbank (BLZ 10090000, Atruvia). The polled result arrived on the first attempt
and the transfer executed:

3945 Freigabe ohne VOP-Bestätigung nicht möglich
-> VoP polling attempt 1 (polling_id=b'<uuid>', aufsetzpunkt='staticscrollref', wait=2s)
-> 0020 Auftrag ausgeführt.
-> 0025 Keine Namensabweichung.
-> VoP result available after 1 attempt(s) (vop_id=b'<uuid>')
-> 0020 Ausführungsbestätigung nach Namensabgleich erhalten.
-> 0020 *SEPA-Einzelüberweisung erfolgreich

Bank parameters for that institution:

HIVPPS1.parameter.supported_report_formats = 'urn:iso:std:iso:20022:tech:xsd:pain.002.001.10'
HIVPPS1.parameter.payment_order_segment    = ['HKCCS', ...]
Aufsetzpunkt (3040 parameter)              = 'staticscrollref'

Two design decisions worth a look

Termination on data, not on a response code. The loop stops when vop_id or
payment_status_report appears. Depending on the server release, Atruvia finishes with
either 3090 or 0020/0025 — both are reported in #220 — so waiting for one specific
code hangs on the other variant.

Timeout raises instead of falling through. Without a result there is nothing to approve
and the order was not executed. Falling through would hand the caller a
TransactionResponse carrying only warnings, which reads like success — note that
_RESPONSE_STATUS_MAPPING maps 3945 to WARNING, not ERROR.

Scope and limitations

  • Both verifications were transfers that the banks did not require a TAN for (3076).
    The TAN path is therefore not covered by them. Worth flagging for a follow-up:
    send_tan() attaches HKVPA only when vop_single_result.result == 'RCVC', and that
    field is empty on a polled result. Not touched by this PR.
  • pain.002 is passed through untouched, not parsed. Callers receive the HIVPP and can
    inspect the report before approving.
  • No changes outside the VoP path.

Relation to #211

#211 targets the same issue and was a useful starting point — credit to @ArlindNocaj for
the polling idea. This PR differs in three ways:

  1. it loops with a timeout instead of polling exactly once,
  2. it selects the branch by flow type rather than by vop_id, which keeps the inline path
    reachable (see above),
  3. it is limited to the VoP flow — no changes to security.py or formals.py.

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 raphaelm#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.
@untouch

untouch commented Aug 26, 2026

Copy link
Copy Markdown
Author

@raphaelm — I've completed the half of the verification that was missing when I opened this,
since "does it break the banks that already work?" is the obvious question for a change in
this area.

A transfer through a Hamburger Sparkasse account (inline VoP) on the patched version:

3091 VOP-Ausführungsauftrag nicht benötigt.
0010 Der Auftrag wurde entgegengenommen.
3076 Starke Kundenauthentifizierung nicht notwendig.

No polling log line was emitted at all, so the new branch was never entered and the transfer
behaved exactly as before. That is the case @payne1979 warned about in #220 — the inline
banks that fire the pushTAN in step 1 and reject a separate HKVPA with 9010.

The branch is selected by flow type (polling_id present and vop_id absent) rather than
by vop_id alone, which is what keeps that path reachable. Description updated with both
verifications side by side.

One observation from the same run, unrelated to this PR but possibly useful: that successful
Sparkasse transfer reported no 0020 — only 0010. Anything downstream that treats
0020 as the success marker would misread it as a failure.

Happy to adjust anything, split it up, or add tests if that helps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant