From 26f92495ff44aa5607ad7005a83dfe8249bb6ff5 Mon Sep 17 00:00:00 2001 From: rhn Date: Mon, 10 Nov 2025 19:57:32 +0100 Subject: [PATCH 1/6] WIP: Add VoP segments --- fints/client.py | 3 +- fints/fields.py | 13 +++++++++ fints/segments/auth.py | 63 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/fints/client.py b/fints/client.py index 3fcb3b7..2e905de 100644 --- a/fints/client.py +++ b/fints/client.py @@ -793,6 +793,7 @@ def _find_supported_sepa_version(self, candidate_versions): return candidate_versions[0] bank_supported = list(hispas.parameter.supported_sepa_formats) + print(hispas) for candidate in candidate_versions: if "urn:iso:std:iso:20022:tech:xsd:{}".format(candidate) in bank_supported: @@ -827,7 +828,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.03', 'pain.001.003.03']) + version = self._find_supported_sepa_version(['pain.001.001.03']) sepa = SepaTransfer(config, version) payment = { "name": recipient_name, diff --git a/fints/fields.py b/fints/fields.py index 39dcc33..d9da806 100644 --- a/fints/fields.py +++ b/fints/fields.py @@ -291,6 +291,19 @@ def _render_value(self, value): return super()._render_value(val) +# FIXME: stub +class TimestampField(DataElementField): + type = 'tsp' + _DOC_TYPE = bytes + + def _render_value(self, value): + retval = bytes(value) + self._check_value_length(retval) + + return retval + + def _parse_value(self, value): return bytes(value) + class PasswordField(AlphanumericField): type = '' _DOC_TYPE = Password diff --git a/fints/segments/auth.py b/fints/segments/auth.py index 57f665f..ddedd76 100644 --- a/fints/segments/auth.py +++ b/fints/segments/auth.py @@ -97,6 +97,68 @@ class HKTAN6(FinTS3Segment): response_hhd_uc = DataElementGroupField(type=ResponseHHDUC, required=False, _d="Antwort HHD_UC") +class PSRD1(DataElementGroup): + """Unterstütze Payment Status Reports + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Messages -- Multibankfähige Geschäftsvorfälle, version 3.0 Rel 2022, FV """ + psrd = DataElementField(type='an', max_length=256, required=True, _d="Payment Status Report Descriptor", max_count=99) + # urn:iso:std:iso:20022:tech:xsd:pain.002.001.14 + +class HKVPP(FinTS3Segment): + """Namensabgleich Prüfauftrag, version 1 + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" + supported_reports = DataElementField(type=PSRD1, required=True, _d="Unterstützte Payment Status Reports") + polling_id = DataElementField(type='bin', required=False, _d="Polling-ID") + max_queries = DataElementField(type='num', max_length=4, required=False, _d="Maximale Anzahl Einträge") + aufsetzpunkt = DataElementField(type='an', max_length=35, required=False, _d="Aufsetzpunkt") + + +class EVPE(DataElementGroup): + """Ergebnis VOP-Prüfung Einzeltransaktion + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" + recipient_IBAN = DataElementField(type='an', max_length=34, required=False, _d="IBAN Empfänger") + info_IBAN = DataElementField(type='an', max_length=140, required=False, _d="IBAN-Zusatzinformationen") + close_match_name = DataElementField(type='an', max_length=140, required=False, _d="Abweichender Empfängername") + other_identification = DataElementField(type='an', max_length=256, required=False, _d="Anderes Identifikationmerkmal") + # RVMC, RCVC, RVNM, RVNA, PDNG + result = DataElementField(type='code', length=4, required=False, _d="VOP-Prüfergebnis") + na_reason = DataElementField(type='an', max_length=256, required=False, _d="Grund RVNA") + + +class HIVPP(FinTS3Segment): + """Namensabgleich Namensabgleich Prüfergebnis, version 1 + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" + vop_id = DataElementField(type='bin', required=False, _d="VOP-ID") + vop_id_valid_until = DataElementField(type='tsp', required=False, _d="VOP-ID gültig bis") + polling_id = DataElementField(type='bin', required=False, _d="Polling-ID") + payment_status_report_descriptor = DataElementField(type='an', max_length=256, required=False, _d="Payment Status Report Descriptor") + payment_status_report = DataElementField(type='bin', required=False, _d="Payment Status Report") + # Only for a single transaction. Mutually exclusive with payment status report. + vop_single_result = DataElementField(type=EVPE, required=False, _d="Ergebnis VOP-Prüfung Einzeltransaktion") + manual_authorization_notice = DataElementField(type='an', max_length=65535, required=False, _d="Aufklärungstext Autorisierung trotz Abweichung") + wait_for_seconds = DataElementField(type='num', length=1, required=False, _d="Wartezeit vor nächster Abfrage") + +class ParameterVoP(DataElementGroup): + max_trans = DataElementField(type='num', max_length=7, required=False, _d="Maximale Anzahl CreditTransferTransactionInformation OptIn") + notice_is_structured = DataElementField(type='jn', required=False, _d="Aufklärungstext strukturiert") + # complete: V, piecemeal: S + report_complete = DataElementField(type='code', length=1, required=False, _d="Art der Lieferung Payment Status Report") + batch_payment-allowed = DataElementField(type='jn', required=False, _d="Sammelzahlungen mit einem Auftrag erlaubt") + multiple_allowed = DataElementField(type='jn', required=False, _d="Eingabe Anzahl Einträge erlaubt") + supported_report_formats = DataElementField(type='an', max_length=1024, required=False, _d="Unterstützte Payment Status Report Daten-formate") + # FIXME: count is "n" + payment_order_segment = DataElementField(type='an', max_length=6, required=False, _d="VOP-pflichtiger Zahlungsverkehrsauftrag") + + +class HIVPPS(ParameterSegment): + """Namensabgleich Prüfauftrag Parameter, version 1 + + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" + parameter = DataElementGroupField(type=ParameterVoP, _d="Parameter Namensabgleich Prüfauftrag") + + + class HKTAN7(FinTS3Segment): """Zwei-Schritt-TAN-Einreichung, version 7 @@ -114,7 +176,6 @@ class HKTAN7(FinTS3Segment): tan_medium_name = DataElementField(type='an', max_length=32, required=False, _d="Bezeichnung des TAN-Mediums") response_hhd_uc = DataElementGroupField(type=ResponseHHDUC, required=False, _d="Antwort HHD_UC") - class HITAN2(FinTS3Segment): """Zwei-Schritt-TAN-Einreichung Rückmeldung, version 2 From d09962c3ec1f1f33722745b3732a3301eacd0632 Mon Sep 17 00:00:00 2001 From: rhn Date: Thu, 13 Nov 2025 18:42:02 +0100 Subject: [PATCH 2/6] VoP generally works --- fints/client.py | 77 ++++++++++++++++++++++++++++++++++++++++-- fints/segments/auth.py | 27 +++++++++------ fints/types.py | 2 +- 3 files changed, 92 insertions(+), 14 deletions(-) diff --git a/fints/client.py b/fints/client.py index 2e905de..5785093 100644 --- a/fints/client.py +++ b/fints/client.py @@ -27,7 +27,7 @@ PinTanTwoStepAuthenticationMechanism, ) from .segments.accounts import HISPA1, HKSPA1 -from .segments.auth import HIPINS1, HKTAB4, HKTAB5, HKTAN2, HKTAN3, HKTAN5, HKTAN6, HKTAN7 +from .segments.auth import HIPINS1, HKTAB4, HKTAB5, HKTAN2, HKTAN3, HKTAN5, HKTAN6, HKTAN7, HIVPPS1, HIVPP1, PSRD1, HKVPA1 from .segments.bank import HIBPA3, HIUPA4, HKKOM4 from .segments.debit import ( HKDBS1, HKDBS2, HKDMB1, HKDMC1, HKDME1, HKDME2, @@ -225,6 +225,10 @@ def process_response_message(self, dialog, message: FinTSInstituteMessage, inter message.find_segments('HIUPD') ) + vpps = message.find_segment_first(HIVPPS1) + if vpps: + self.vpps = vpps + for seg in message.find_segments(HIRMG2): for response in seg.responses: if not internal_send: @@ -828,7 +832,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.03']) + version = self._find_supported_sepa_version(['pain.001.001.03', 'pain.001.003.03']) sepa = SepaTransfer(config, version) payment = { "name": recipient_name, @@ -893,7 +897,7 @@ def sepa_transfer(self, account: SEPAAccount, pain_message: str, multiple=False, if book_as_single: seg.request_single_booking = True - return self._send_with_possible_retry(dialog, seg, self._continue_sepa_transfer) + return self._send_pay_with_possible_retry(dialog, seg, self._continue_sepa_transfer) def _continue_sepa_transfer(self, command_seg, response): retval = TransactionResponse(response) @@ -1300,6 +1304,19 @@ def _get_tan_segment(self, orig_seg, tan_process, tan_seg=None): return seg + def _find_vop_format_for_segment(self, seg): + needed = str(seg.header.type) in list(self.vpps.parameter.payment_order_segment) + + if not needed: + return + + bank_supported = str(self.vpps.parameter.supported_report_formats) + + if "sepade.pain.002.001.10.xsd" != bank_supported: + logger.warning("No common supported SEPA version. Defaulting to what bank supports and hoping for the best: %s.", bank_supported) + + return bank_supported + def _need_twostep_tan_for_segment(self, seg): if not self.selected_security_function or self.selected_security_function == '999': return False @@ -1336,6 +1353,60 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func): response = dialog.send(command_seg) return resume_func(command_seg, response) + + def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): + vop_seg = [] + vop_standard = self._find_vop_format_for_segment(command_seg) + if vop_standard: + from .segments.auth import HKVPP1 + print(self.vpps) + vop_seg = [HKVPP1(supported_reports=PSRD1(psrd=[vop_standard]))] + with dialog: + if self._need_twostep_tan_for_segment(command_seg): + tan_seg = self._get_tan_segment(command_seg, '4') + segments = vop_seg + [command_seg, tan_seg] + + response = dialog.send(*segments) + + hivpp = response.find_segment_first(HIVPP1) + + if not hivpp: + raise Exception("Mising VoP reponse") + vop_result = hivpp.vop_single_result + print(hivpp) + if vop_result.result == 'RVNA': + # TODO: let the user decide if they want to proceed by displaying a warning with TAN + print(vop_result.na_reason) + vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] + segments = vop_seg + [command_seg, tan_seg] + response = dialog.send(*segments) + elif vop_result.result == 'RVNM': + vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] + segments = vop_seg + [command_seg, tan_seg] + response = dialog.send(*segments) + print("WARNING! Recipient name does not match.") + elif vop_result.result == 'RVMC': + vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] + segments = vop_seg + [command_seg, tan_seg] + response = dialog.send(*segments) + print("WARNING! Recipient name differs:", vop_result.close_match_name) + + print(vop_result.result) + for resp in response.responses(tan_seg): + if resp.code in ('0030', '3955'): + return NeedTANResponse( + command_seg, + response.find_segment_first('HITAN'), + resume_func, + self.is_challenge_structured(), + resp.code == '3955', + ) + if resp.code.startswith('9'): + raise Exception("Error response: {!r}".format(response)) + else: + response = dialog.send(command_seg) + + return resume_func(command_seg, response) def is_challenge_structured(self): param = self.get_tan_mechanisms()[self.get_current_tan_mechanism()] diff --git a/fints/segments/auth.py b/fints/segments/auth.py index ddedd76..2ae69d8 100644 --- a/fints/segments/auth.py +++ b/fints/segments/auth.py @@ -1,5 +1,6 @@ from fints.fields import CodeField, DataElementField, DataElementGroupField from fints.formals import ( + DataElementGroup, KTI1, BankIdentifier, ChallengeValidUntil, Language2, ParameterChallengeClass, ParameterPinTan, ParameterTwostepTAN1, ParameterTwostepTAN2, ParameterTwostepTAN3, ParameterTwostepTAN4, @@ -104,15 +105,18 @@ class PSRD1(DataElementGroup): psrd = DataElementField(type='an', max_length=256, required=True, _d="Payment Status Report Descriptor", max_count=99) # urn:iso:std:iso:20022:tech:xsd:pain.002.001.14 -class HKVPP(FinTS3Segment): +class HKVPP1(FinTS3Segment): """Namensabgleich Prüfauftrag, version 1 Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" - supported_reports = DataElementField(type=PSRD1, required=True, _d="Unterstützte Payment Status Reports") + supported_reports = DataElementGroupField(type=PSRD1, required=True, _d="Unterstützte Payment Status Reports") polling_id = DataElementField(type='bin', required=False, _d="Polling-ID") max_queries = DataElementField(type='num', max_length=4, required=False, _d="Maximale Anzahl Einträge") aufsetzpunkt = DataElementField(type='an', max_length=35, required=False, _d="Aufsetzpunkt") +a = HKVPP1(polling_id=b"fdf") +HKVPP1(polling_id=a.polling_id, aufsetzpunkt="sas") + class EVPE(DataElementGroup): """Ergebnis VOP-Prüfung Einzeltransaktion @@ -126,16 +130,16 @@ class EVPE(DataElementGroup): na_reason = DataElementField(type='an', max_length=256, required=False, _d="Grund RVNA") -class HIVPP(FinTS3Segment): +class HIVPP1(FinTS3Segment): """Namensabgleich Namensabgleich Prüfergebnis, version 1 Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" vop_id = DataElementField(type='bin', required=False, _d="VOP-ID") - vop_id_valid_until = DataElementField(type='tsp', required=False, _d="VOP-ID gültig bis") + vop_id_valid_until = DataElementGroupField(type=ChallengeValidUntil, required=False, _d="VOP-ID gültig bis") polling_id = DataElementField(type='bin', required=False, _d="Polling-ID") payment_status_report_descriptor = DataElementField(type='an', max_length=256, required=False, _d="Payment Status Report Descriptor") payment_status_report = DataElementField(type='bin', required=False, _d="Payment Status Report") # Only for a single transaction. Mutually exclusive with payment status report. - vop_single_result = DataElementField(type=EVPE, required=False, _d="Ergebnis VOP-Prüfung Einzeltransaktion") + vop_single_result = DataElementGroupField(type=EVPE, required=False, _d="Ergebnis VOP-Prüfung Einzeltransaktion") manual_authorization_notice = DataElementField(type='an', max_length=65535, required=False, _d="Aufklärungstext Autorisierung trotz Abweichung") wait_for_seconds = DataElementField(type='num', length=1, required=False, _d="Wartezeit vor nächster Abfrage") @@ -144,20 +148,24 @@ class ParameterVoP(DataElementGroup): notice_is_structured = DataElementField(type='jn', required=False, _d="Aufklärungstext strukturiert") # complete: V, piecemeal: S report_complete = DataElementField(type='code', length=1, required=False, _d="Art der Lieferung Payment Status Report") - batch_payment-allowed = DataElementField(type='jn', required=False, _d="Sammelzahlungen mit einem Auftrag erlaubt") + batch_payment_allowed = DataElementField(type='jn', required=False, _d="Sammelzahlungen mit einem Auftrag erlaubt") multiple_allowed = DataElementField(type='jn', required=False, _d="Eingabe Anzahl Einträge erlaubt") supported_report_formats = DataElementField(type='an', max_length=1024, required=False, _d="Unterstützte Payment Status Report Daten-formate") - # FIXME: count is "n" - payment_order_segment = DataElementField(type='an', max_length=6, required=False, _d="VOP-pflichtiger Zahlungsverkehrsauftrag") + payment_order_segment = DataElementField(type='an', min_count=1, max_length=6, required=False, _d="VOP-pflichtiger Zahlungsverkehrsauftrag") -class HIVPPS(ParameterSegment): +class HIVPPS1(ParameterSegment): """Namensabgleich Prüfauftrag Parameter, version 1 Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" parameter = DataElementGroupField(type=ParameterVoP, _d="Parameter Namensabgleich Prüfauftrag") +class HKVPA1(FinTS3Segment): + """Namensabgleich Namensabgleich Ausführungsauftrag, version 1 + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" + vop_id = DataElementField(type='bin', required=False, _d="VOP-ID") + class HKTAN7(FinTS3Segment): """Zwei-Schritt-TAN-Einreichung, version 7 @@ -252,7 +260,6 @@ class HKTAB4(FinTS3Segment): tan_media_type = CodeField(enum=TANMediaType2, _d="TAN-Medium-Art") tan_media_class = CodeField(enum=TANMediaClass3, _d="TAN-Medium-Klasse") - class HITAB4(FinTS3Segment): """TAN-Generator/Liste anzeigen Bestand Rückmeldung, version 4 diff --git a/fints/types.py b/fints/types.py index aa75a54..2518597 100644 --- a/fints/types.py +++ b/fints/types.py @@ -214,7 +214,7 @@ def __init__(self, segments=None): if isinstance(segments, bytes): from .parser import FinTS3Parser parser = FinTS3Parser() - data = parser.explode_segments(segments) + data = list(parser.explode_segments(segments)) segments = [parser.parse_segment(segment) for segment in data] self.segments = list(segments) if segments else [] From 6620546856699b02c44fb27ff558caa3890611a9 Mon Sep 17 00:00:00 2001 From: rhn Date: Thu, 13 Nov 2025 19:55:33 +0100 Subject: [PATCH 3/6] works also with full match --- fints/client.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/fints/client.py b/fints/client.py index 5785093..e1255d0 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1070,12 +1070,14 @@ class NeedTANResponse(NeedRetryResponse): challenge_hhduc = None #: HHD_UC challenge to be transmitted to the TAN generator challenge_matrix = None #: Matrix code challenge: tuple(mime_type, data) decoupled = None #: Use decoupled process + vop_result = None # VoC result to either send an accept reply with TAN (on full match) or display a warning (otherwise; reply already sent) - def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False): + def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False, vop_result=None): self.command_seg = command_seg self.tan_request = tan_request self.tan_request_structured = tan_request_structured self.decoupled = decoupled + self.vop_result = vop_result if hasattr(resume_method, '__func__'): self.resume_method = resume_method.__func__.__name__ else: @@ -1390,7 +1392,6 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): segments = vop_seg + [command_seg, tan_seg] response = dialog.send(*segments) print("WARNING! Recipient name differs:", vop_result.close_match_name) - print(vop_result.result) for resp in response.responses(tan_seg): if resp.code in ('0030', '3955'): @@ -1400,6 +1401,7 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): resume_func, self.is_challenge_structured(), resp.code == '3955', + hivpp, ) if resp.code.startswith('9'): raise Exception("Error response: {!r}".format(response)) @@ -1434,7 +1436,12 @@ def send_tan(self, challenge: NeedTANResponse, tan: str): tan_seg = self._get_tan_segment(challenge.command_seg, '2', challenge.tan_request) self._pending_tan = tan - response = dialog.send(tan_seg) + vop_seg = [] + print(challenge.vop_result) + if challenge.vop_result and challenge.vop_result.vop_single_result.result == 'RCVC': + vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)] + segments = vop_seg + [tan_seg] + response = dialog.send(*segments) if challenge.decoupled: # TAN process = S From e7a718201d6c79b751f09827f786474d77459624 Mon Sep 17 00:00:00 2001 From: rhn Date: Thu, 13 Nov 2025 20:18:35 +0100 Subject: [PATCH 4/6] document a bit --- fints/client.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/fints/client.py b/fints/client.py index e1255d0..b8c9dd6 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1357,11 +1357,43 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func): return resume_func(command_seg, response) def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): + """This adds VoP under the assumption that TAN will be sent, + There is no VoP flow without sending any authentication that I could distinguish. + + There are really 2 VoP flows: with a full match and otherwise. All of them return the HIVPP response in NeedTANResponse. + + On a full match, it's only needed to copy the vop_id alongside the TAN response. + + In all other cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result. + + The kind of response is in resp.vop_result.single_vop_result.result: + - 'RCVC' - full match + - 'RVMC' - partial match, extra info in single_vop_result.close_match_name and .other_identification. + - 'RVNM' - no match, no extra info seen + - 'RVNA' - check not available, reason in single_vop_result.na_reason + - 'PDNG' - pending, seems related to something not implemented right now. + + Simple untested example. + + ``` + def process_tan(client, challenge, prompt): + if challenge.vop_result and not challenge.vop_result.vop_single_result.result == 'RCVC': + input("WARNING!!! Recipient name don't match:", challenge.vop_result.single_vop_result.close_match_name) + print("A TAN is required:", challenge.challenge) + hitan6 = challenge.tan_request + ... get TAN from user + return client.send_tan(challenge, tan) + ``` + + This gives the user a chance to slam ctrl+C before adding TAN. + + Internally, the library always sends a positive confirmation of transaction because the user only really acknowledges it by sending the TAN. + Even if the legal liability moves on receiving the accepting message, there's no liability to be had before TAN goes through. Amirite? + """ vop_seg = [] vop_standard = self._find_vop_format_for_segment(command_seg) if vop_standard: from .segments.auth import HKVPP1 - print(self.vpps) vop_seg = [HKVPP1(supported_reports=PSRD1(psrd=[vop_standard]))] with dialog: if self._need_twostep_tan_for_segment(command_seg): @@ -1377,7 +1409,6 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): vop_result = hivpp.vop_single_result print(hivpp) if vop_result.result == 'RVNA': - # TODO: let the user decide if they want to proceed by displaying a warning with TAN print(vop_result.na_reason) vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] segments = vop_seg + [command_seg, tan_seg] @@ -1386,12 +1417,10 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] segments = vop_seg + [command_seg, tan_seg] response = dialog.send(*segments) - print("WARNING! Recipient name does not match.") elif vop_result.result == 'RVMC': vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] segments = vop_seg + [command_seg, tan_seg] response = dialog.send(*segments) - print("WARNING! Recipient name differs:", vop_result.close_match_name) print(vop_result.result) for resp in response.responses(tan_seg): if resp.code in ('0030', '3955'): From 0cc1649ad6b9e68e12b842c30dff72b4746a9dec Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 27 Nov 2025 19:27:11 +0100 Subject: [PATCH 5/6] Improved implementation --- docs/registration.rst | 11 ++++ docs/transfers.rst | 53 +++++++++++---- docs/trouble.rst | 25 ++++++- fints/client.py | 143 +++++++++++++++++++++++++---------------- fints/fields.py | 8 ++- fints/segments/auth.py | 9 +-- 6 files changed, 171 insertions(+), 78 deletions(-) create mode 100644 docs/registration.rst diff --git a/docs/registration.rst b/docs/registration.rst new file mode 100644 index 0000000..7757bd6 --- /dev/null +++ b/docs/registration.rst @@ -0,0 +1,11 @@ +Registration necessary +====================== + +As of September 14th, 2019, all FinTS programs need to be registered with the ZKA or +banks will block access. You need to fill out a PDF form and will be assigned a +product ID that you can pass above. + +Click here to read more about the `registration process`_. + + +.. _registration process: https://www.hbci-zka.de/register/prod_register.htm diff --git a/docs/transfers.rst b/docs/transfers.rst index 787a9ca..343cf83 100644 --- a/docs/transfers.rst +++ b/docs/transfers.rst @@ -12,8 +12,16 @@ You can create a simple SEPA transfer using this convenient client method: :members: simple_sepa_transfer :noindex: +The return value may be a `NeedVOPResponse` in which case you need to call `approve_vop_response` to proceed. + +At any point, you might receive a `NeedTANResponse`. You should then enter a TAN, read our chapter :ref:`tans` to find out more. +.. autoclass:: fints.client.FinTS3PinTanClient + :members: approve_vop_response + :noindex: + + Advanced mode ------------- @@ -55,20 +63,37 @@ Full example endtoend_id='NOTPROVIDED', ) - while isinstance(res, NeedTANResponse): - print("A TAN is required", res.challenge) - - if getattr(res, 'challenge_hhduc', None): - try: - terminal_flicker_unix(res.challenge_hhduc) - except KeyboardInterrupt: - pass - - if result.decoupled: - tan = input('Please press enter after confirming the transaction in your app:') - else: - tan = input('Please enter TAN:') - res = client.send_tan(res, tan) + while isinstance(res, NeedTANResponse | NeedVOPResponse): + if isinstance(res, NeedTANResponse): + print("A TAN is required", res.challenge) + + if getattr(res, 'challenge_hhduc', None): + try: + terminal_flicker_unix(res.challenge_hhduc) + except KeyboardInterrupt: + pass + + if result.decoupled: + tan = input('Please press enter after confirming the transaction in your app:') + else: + tan = input('Please enter TAN:') + res = client.send_tan(res, tan) + elif isinstance(res, NeedVOPResponse): + if res.vop_result.vop_single_result.result == "RVMC": + print("Payee name is a close match") + print("Name retrieved by bank:", res.vop_result.vop_single_result.close_match_name) + if res.vop_result.vop_single_result.other_identification: + print("Other info retrieved by bank:", res.vop_result.vop_single_result.other_identification) + elif res.vop_result.vop_single_result.result == "RVNM": + print("Payee name does not match match") + elif res.vop_result.vop_single_result.result == "RVNA": + print("Payee name could not be verified") + print("Reason:", res.vop_result.vop_single_result.na_reason) + elif res.vop_result.vop_single_result.result == "PDNG": + print("Payee name could not be verified (pending state, can't be handled by this library)") + print("Do you want to continue? Your bank will not be liable if the money ends up in the wrong place.") + input('Please press enter to confirm or Ctrl+C to cancel') + res = client.approve_vop_response(res) print(res.status) print(res.responses) diff --git a/docs/trouble.rst b/docs/trouble.rst index 0a4a9c7..95ab464 100644 --- a/docs/trouble.rst +++ b/docs/trouble.rst @@ -65,6 +65,24 @@ the problem. return f.send_tan(response, tan) + def ask_for_vop(response: NeedVOPResponse): + if response.vop_result.vop_single_result.result == "RVMC": + print("Payee name is a close match") + print("Name retrieved by bank:", response.vop_result.vop_single_result.close_match_name) + if response.vop_result.vop_single_result.other_identification: + print("Other info retrieved by bank:", response.vop_result.vop_single_result.other_identification) + elif response.vop_result.vop_single_result.result == "RVNM": + print("Payee name does not match match") + elif response.vop_result.vop_single_result.result == "RVNA": + print("Payee name could not be verified") + print("Reason:", response.vop_result.vop_single_result.na_reason) + elif response.vop_result.vop_single_result.result == "PDNG": + print("Payee name could not be verified (pending state, can't be handled by this library)") + print("Do you want to continue? Your bank will not be liable if the money ends up in the wrong place.") + input('Please press enter to confirm or Ctrl+C to cancel') + return f.approve_vop_response(response) + + # Open the actual dialog with f: # Since PSD2, a TAN might be needed for dialog initialization. Let's check if there is one required @@ -172,8 +190,11 @@ the problem. endtoend_id='NOTPROVIDED', ) - while isinstance(res, NeedTANResponse): - res = ask_for_tan(res) + while isinstance(res, NeedTANResponse | NeedVOPResponse): + if isinstance(res, NeedTANResponse): + res = ask_for_tan(res) + elif isinstance(res, NeedVOPResponse): + res = ask_for_vop(res) elif choice == 11: print("Select statement") statements = f.get_statements(account) diff --git a/fints/client.py b/fints/client.py index b8c9dd6..32bcef4 100644 --- a/fints/client.py +++ b/fints/client.py @@ -225,10 +225,6 @@ def process_response_message(self, dialog, message: FinTSInstituteMessage, inter message.find_segments('HIUPD') ) - vpps = message.find_segment_first(HIVPPS1) - if vpps: - self.vpps = vpps - for seg in message.find_segments(HIRMG2): for response in seg.responses: if not internal_send: @@ -797,7 +793,6 @@ def _find_supported_sepa_version(self, candidate_versions): return candidate_versions[0] bank_supported = list(hispas.parameter.supported_sepa_formats) - print(hispas) for candidate in candidate_versions: if "urn:iso:std:iso:20022:tech:xsd:{}".format(candidate) in bank_supported: @@ -823,7 +818,7 @@ def simple_sepa_transfer(self, account: SEPAAccount, iban: str, bic: str, :param reason: Transfer reason :param instant_payment: Whether to use instant payment (defaults to ``False``) :param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``) - :return: Returns either a NeedRetryResponse or TransactionResponse + :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse """ config = { "name": account_name, @@ -1063,6 +1058,41 @@ def resume_dialog(self, dialog_data): self._standing_dialog = None +class NeedVOPResponse(NeedRetryResponse): + + def __init__(self, vop_result, command_seg, resume_method=None): + self.vop_result = vop_result + self.command_seg = command_seg + if hasattr(resume_method, '__func__'): + self.resume_method = resume_method.__func__.__name__ + else: + self.resume_method = resume_method + + def __repr__(self): + return ''.format(o=self) + + @classmethod + def _from_data_v1(cls, data): + if data["version"] == 1: + segs = SegmentSequence(data['segments_bin']).segments + return cls(segs[0], segs[1], resume_method=data['resume_method']) + + raise Exception("Wrong blob data version") + + def get_data(self) -> bytes: + """Return a compressed datablob representing this object. + + To restore the object, use :func:`fints.client.NeedRetryResponse.from_data`. + """ + data = { + "_class_name": self.__class__.__name__, + "version": 1, + "segments_bin": SegmentSequence([self.vop_result, self.command_seg]).render_bytes(), + "resume_method": self.resume_method, + } + return compress_datablob(DATA_BLOB_MAGIC_RETRY, 1, data) + + class NeedTANResponse(NeedRetryResponse): challenge_raw = None #: Raw challenge as received by the bank challenge = None #: Textual challenge to be displayed to the user @@ -1070,7 +1100,7 @@ class NeedTANResponse(NeedRetryResponse): challenge_hhduc = None #: HHD_UC challenge to be transmitted to the TAN generator challenge_matrix = None #: Matrix code challenge: tuple(mime_type, data) decoupled = None #: Use decoupled process - vop_result = None # VoC result to either send an accept reply with TAN (on full match) or display a warning (otherwise; reply already sent) + vop_result = None #: VoP result def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False, vop_result=None): self.command_seg = command_seg @@ -1307,12 +1337,16 @@ def _get_tan_segment(self, orig_seg, tan_process, tan_seg=None): return seg def _find_vop_format_for_segment(self, seg): - needed = str(seg.header.type) in list(self.vpps.parameter.payment_order_segment) + vpps = self.bpd.find_segment_first('HIVPPS') + if not vpps: + return + + needed = str(seg.header.type) in list(vpps.parameter.payment_order_segment) if not needed: return - bank_supported = str(self.vpps.parameter.supported_report_formats) + bank_supported = str(vpps.parameter.supported_report_formats) if "sepade.pain.002.001.10.xsd" != bank_supported: logger.warning("No common supported SEPA version. Defaulting to what bank supports and hoping for the best: %s.", bank_supported) @@ -1357,14 +1391,13 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func): return resume_func(command_seg, response) def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): - """This adds VoP under the assumption that TAN will be sent, - There is no VoP flow without sending any authentication that I could distinguish. - - There are really 2 VoP flows: with a full match and otherwise. All of them return the HIVPP response in NeedTANResponse. - - On a full match, it's only needed to copy the vop_id alongside the TAN response. + """ + This adds VoP under the assumption that TAN will be sent, + There appears to be no VoP flow without sending any authentication. - In all other cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result. + There are really 2 VoP flows: with a full match and otherwise. + The second flow returns a NeedVOPResponse as intended by the specification flowcharts. + In this case cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result. The kind of response is in resp.vop_result.single_vop_result.result: - 'RCVC' - full match @@ -1372,29 +1405,13 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): - 'RVNM' - no match, no extra info seen - 'RVNA' - check not available, reason in single_vop_result.na_reason - 'PDNG' - pending, seems related to something not implemented right now. - - Simple untested example. - - ``` - def process_tan(client, challenge, prompt): - if challenge.vop_result and not challenge.vop_result.vop_single_result.result == 'RCVC': - input("WARNING!!! Recipient name don't match:", challenge.vop_result.single_vop_result.close_match_name) - print("A TAN is required:", challenge.challenge) - hitan6 = challenge.tan_request - ... get TAN from user - return client.send_tan(challenge, tan) - ``` - - This gives the user a chance to slam ctrl+C before adding TAN. - - Internally, the library always sends a positive confirmation of transaction because the user only really acknowledges it by sending the TAN. - Even if the legal liability moves on receiving the accepting message, there's no liability to be had before TAN goes through. Amirite? """ vop_seg = [] vop_standard = self._find_vop_format_for_segment(command_seg) if vop_standard: from .segments.auth import HKVPP1 vop_seg = [HKVPP1(supported_reports=PSRD1(psrd=[vop_standard]))] + with dialog: if self._need_twostep_tan_for_segment(command_seg): tan_seg = self._get_tan_segment(command_seg, '4') @@ -1402,26 +1419,17 @@ def process_tan(client, challenge, prompt): response = dialog.send(*segments) - hivpp = response.find_segment_first(HIVPP1) - - if not hivpp: - raise Exception("Mising VoP reponse") - vop_result = hivpp.vop_single_result - print(hivpp) - if vop_result.result == 'RVNA': - print(vop_result.na_reason) - vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] - segments = vop_seg + [command_seg, tan_seg] - response = dialog.send(*segments) - elif vop_result.result == 'RVNM': - vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] - segments = vop_seg + [command_seg, tan_seg] - response = dialog.send(*segments) - elif vop_result.result == 'RVMC': - vop_seg = [HKVPA1(vop_id=hivpp.vop_id)] - segments = vop_seg + [command_seg, tan_seg] - response = dialog.send(*segments) - print(vop_result.result) + if vop_standard: + hivpp = response.find_segment_first(HIVPP1, throw=True) + + vop_result = hivpp.vop_single_result + if vop_result.result in ('RVNA', 'RVNM', 'RVMC'): # Not Applicable, No Match, Close Match + return NeedVOPResponse( + vop_result=hivpp, + command_seg=command_seg, + resume_method=resume_func, + ) + for resp in response.responses(tan_seg): if resp.code in ('0030', '3955'): return NeedTANResponse( @@ -1445,6 +1453,33 @@ def is_challenge_structured(self): return param.challenge_structured return False + def approve_vop_response(self, challenge: NeedVOPResponse): + """ + Approves an operation that had a non-match VoP (verification of payee) response. + + :param challenge: NeedVOPResponse to respond to + :return: New response after sending VOP response + """ + with self._get_dialog() as dialog: + vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)] + tan_seg = self._get_tan_segment(challenge.command_seg, '4') + segments = vop_seg + [challenge.command_seg, tan_seg] + response = dialog.send(*segments) + + for resp in response.responses(tan_seg): + if resp.code in ('0030', '3955'): + return NeedTANResponse( + challenge.command_seg, + response.find_segment_first('HITAN'), + challenge.resume_method, + self.is_challenge_structured(), + resp.code == '3955', + challenge.vop_result, + ) + + resume_func = getattr(self, challenge.resume_method) + return resume_func(challenge.command_seg, response) + def send_tan(self, challenge: NeedTANResponse, tan: str): """ Sends a TAN to confirm a pending operation. @@ -1457,7 +1492,6 @@ def send_tan(self, challenge: NeedTANResponse, tan: str): :param tan: TAN value :return: New response after sending TAN """ - with self._get_dialog() as dialog: if challenge.decoupled: tan_seg = self._get_tan_segment(challenge.command_seg, 'S', challenge.tan_request) @@ -1466,7 +1500,6 @@ def send_tan(self, challenge: NeedTANResponse, tan: str): self._pending_tan = tan vop_seg = [] - print(challenge.vop_result) if challenge.vop_result and challenge.vop_result.vop_single_result.result == 'RCVC': vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)] segments = vop_seg + [tan_seg] diff --git a/fints/fields.py b/fints/fields.py index d9da806..7f8d6e3 100644 --- a/fints/fields.py +++ b/fints/fields.py @@ -291,18 +291,20 @@ def _render_value(self, value): return super()._render_value(val) -# FIXME: stub class TimestampField(DataElementField): + # Defined in the VoP standard, but missing in the Formals document. We just treat it as + # opaque bytes. type = 'tsp' _DOC_TYPE = bytes def _render_value(self, value): retval = bytes(value) self._check_value_length(retval) - return retval - def _parse_value(self, value): return bytes(value) + def _parse_value(self, value): + return bytes(value) + class PasswordField(AlphanumericField): type = '' diff --git a/fints/segments/auth.py b/fints/segments/auth.py index 2ae69d8..9dcdd2e 100644 --- a/fints/segments/auth.py +++ b/fints/segments/auth.py @@ -105,8 +105,10 @@ class PSRD1(DataElementGroup): psrd = DataElementField(type='an', max_length=256, required=True, _d="Payment Status Report Descriptor", max_count=99) # urn:iso:std:iso:20022:tech:xsd:pain.002.001.14 + class HKVPP1(FinTS3Segment): """Namensabgleich Prüfauftrag, version 1 + Source: FinTS Financial Transaction Services, Schnittstellenspezifikation, Verification of Payee""" supported_reports = DataElementGroupField(type=PSRD1, required=True, _d="Unterstützte Payment Status Reports") polling_id = DataElementField(type='bin', required=False, _d="Polling-ID") @@ -114,9 +116,6 @@ class HKVPP1(FinTS3Segment): aufsetzpunkt = DataElementField(type='an', max_length=35, required=False, _d="Aufsetzpunkt") -a = HKVPP1(polling_id=b"fdf") -HKVPP1(polling_id=a.polling_id, aufsetzpunkt="sas") - class EVPE(DataElementGroup): """Ergebnis VOP-Prüfung Einzeltransaktion @@ -143,6 +142,7 @@ class HIVPP1(FinTS3Segment): manual_authorization_notice = DataElementField(type='an', max_length=65535, required=False, _d="Aufklärungstext Autorisierung trotz Abweichung") wait_for_seconds = DataElementField(type='num', length=1, required=False, _d="Wartezeit vor nächster Abfrage") + class ParameterVoP(DataElementGroup): max_trans = DataElementField(type='num', max_length=7, required=False, _d="Maximale Anzahl CreditTransferTransactionInformation OptIn") notice_is_structured = DataElementField(type='jn', required=False, _d="Aufklärungstext strukturiert") @@ -152,7 +152,7 @@ class ParameterVoP(DataElementGroup): multiple_allowed = DataElementField(type='jn', required=False, _d="Eingabe Anzahl Einträge erlaubt") supported_report_formats = DataElementField(type='an', max_length=1024, required=False, _d="Unterstützte Payment Status Report Daten-formate") payment_order_segment = DataElementField(type='an', min_count=1, max_length=6, required=False, _d="VOP-pflichtiger Zahlungsverkehrsauftrag") - + class HIVPPS1(ParameterSegment): """Namensabgleich Prüfauftrag Parameter, version 1 @@ -184,6 +184,7 @@ class HKTAN7(FinTS3Segment): tan_medium_name = DataElementField(type='an', max_length=32, required=False, _d="Bezeichnung des TAN-Mediums") response_hhd_uc = DataElementGroupField(type=ResponseHHDUC, required=False, _d="Antwort HHD_UC") + class HITAN2(FinTS3Segment): """Zwei-Schritt-TAN-Einreichung Rückmeldung, version 2 From 54f250850e8d6a92149d9df23018dd6562abf24d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 27 Nov 2025 19:29:29 +0100 Subject: [PATCH 6/6] Fix failing tests --- fints/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fints/client.py b/fints/client.py index 32bcef4..07f87b0 100644 --- a/fints/client.py +++ b/fints/client.py @@ -1429,6 +1429,8 @@ def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func): command_seg=command_seg, resume_method=resume_func, ) + else: + hivpp = None for resp in response.responses(tan_seg): if resp.code in ('0030', '3955'):