Skip to content

Fix VoP handling for banks that do not report the result inline - #223

Open
untouch wants to merge 3 commits into
raphaelm:masterfrom
untouch:vop-async-polling
Open

Fix VoP handling for banks that do not report the result inline#223
untouch wants to merge 3 commits into
raphaelm:masterfrom
untouch:vop-async-polling

Conversation

@untouch

@untouch untouch commented Aug 26, 2026

Copy link
Copy Markdown

Fixes the VoP handling reported in #220.

Summary

Banks report Verification of Payee results in three different shapes. python-fints
currently recognises only one of them, so on the other two every transfer to a
third-party payee fails
— with 3945 Freigabe ohne VOP-Bestätigung nicht möglich
— and can never be executed.

Bank Where the VoP result appears Before After
Sparkasse inline in vop_single_result works unchanged
Atruvia (Volksbank, GLS, VR) asynchronous, behind a polling_id always 3945 executes
Commerzbank only inside payment_status_report always 3945 executes

All three verified against real bank dialogs.

The three cases

1. Asynchronous result (Atruvia)HIVPP carries neither vop_id nor
vop_single_result, only a polling_id and wait_for_seconds, accompanied by 3905
("no challenge created") and 3040 with a touchdown point. The client has to ask
again. It never did.

2. 3945 attached elsewhere (Commerzbank) — the existing check looked for the
code only in responses tied to the TAN segment:

'3945' in [res.code for res in response.responses(tan_seg)]

Some banks attach it to a different segment, so the condition never matched.

3. Result only in the status report (Commerzbank) — this bank omits
vop_single_result entirely and reports the outcome solely inside
payment_status_report, a pain.002 document:

HIVPP1(
    vop_id                = b'...',
    vop_id_valid_until    = ...,
    payment_status_report = <pain.002 with GrpSts RCVC>,
    manual_authorization_notice = '...'
)
HITAN6(challenge = 'Dies ist Ihre photoTAN Challenge.')

With vop_single_result absent, result_code is None and neither existing
branch can match — regardless of how widely 3945 is searched. No NeedVOPResponse
is produced, HKVPA is never sent, and the order is rejected after the TAN.

Changes

Two design decisions worth review:

Termination on data, not on a response code. The polling 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.

Why inline banks keep working

Each new branch is guarded by a condition that inline banks cannot satisfy:

  • polling requires polling_id present and vop_id absent,
  • the status-report branch requires result_code to be None.

For a bank that fills vop_single_result, both are false and execution continues into
the unchanged code — including the 0030/3955 TAN detection.

That distinction matters. As @payne1979 notes in #220, Sparkassen answer inline, fire
the pushTAN in step 1, and reject a separately sent HKVPA with 9010"Any fix
that assumes one of the two will break the other."

Measured, not assumed. A transfer through a Hamburger Sparkasse account after
these changes 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.

Verification

Berliner Volksbank (BLZ 10090000, Atruvia). Polled result on the first attempt:

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

Commerzbank (BLZ 10080000). Previously 3945 on every attempt; now:

0010 Auftrag entgegengenommen. Ausführung erfolgt vorbehaltlich positiver Disposition.

Transfer confirmed in online banking the next morning.

Hamburger Sparkasse. Unchanged, see above.

Scope and limitations

  • pain.002 is passed through untouched, not parsed. Callers receive the HIVPP and
    can inspect the report before approving — which is the point of VoP.
  • No changes outside the VoP path; security.py and formals.py are untouched.

Relation to #211

#211 targets the same area and was a useful starting point — credit to @ArlindNocaj
for the polling idea. This PR differs in three ways: it loops with a timeout instead
of polling once, it selects branches by flow shape rather than by vop_id alone
(which keeps the inline path reachable), and it stays inside the VoP flow.

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.

Commerzbank rejects every transfer to a third-party payee with
"3945 Freigabe ohne VOP-Bestaetigung nicht moeglich", even though HIVPPS is
present, HKVPP is sent and HIVPP comes back with a vop_id.

The reason is where the bank attaches 3945. The existing condition only looked at
responses tied to the TAN segment:

    '3945' in [res.code for res in response.responses(tan_seg)]

Commerzbank attaches it elsewhere, so the condition never matched, no
NeedVOPResponse was produced, the flow fell through to TAN handling, and HKVPA was
never sent -- leaving the order permanently rejected.

Observed order on that bank: order submitted -> TAN requested -> TAN submitted ->
3945. The VoP branch is skipped entirely; no polling occurs (vop_id is inline).

Now scanned across all HIRMG/HIRMS segments. Same idea as in raphaelm#211, which describes
this as "some banks attach it to different segments".

Verified against Commerzbank (BLZ 10080000). Atruvia and Sparkasse paths are
unaffected: for them the code was already found at the TAN segment, so the wider
search returns the same result.
Commerzbank rejects every transfer to a third-party payee with 3945, even though
HIVPPS is advertised, HKVPP is sent and HIVPP comes back with a valid vop_id.

The reason is that Commerzbank omits vop_single_result entirely. The outcome is
reported only inside payment_status_report -- a pain.002 document carrying
<GrpSts>RCVC</GrpSts> ("the payee name matches the IBAN"). Observed segment:

    HIVPP1(
        vop_id                = b'...',
        vop_id_valid_until    = ...,
        payment_status_report = <pain.002 with GrpSts RCVC>,
        manual_authorization_notice = '...'
    )
    HITAN6(challenge = 'Dies ist Ihre photoTAN Challenge.')

With vop_single_result absent, result_code is None and neither existing branch can
match, so no NeedVOPResponse is produced, HKVPA is never sent, and the order is
rejected with 3945 once the TAN has been submitted.

A present vop_id together with a status report means the bank finished its check and
awaits approval; that case now returns NeedVOPResponse.

Banks that do fill vop_single_result are unaffected -- result_code is set for them and
the new branch is skipped. That matters for Sparkassen, where a separately sent HKVPA
is answered with 9010 (see raphaelm#220).

Verified against Commerzbank (BLZ 10080000).
@untouch untouch changed the title Poll for asynchronous VoP results (Atruvia banks) Fix VoP handling for banks that do not report the result inline Sep 4, 2026
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