2727 PinTanTwoStepAuthenticationMechanism ,
2828)
2929from .segments .accounts import HISPA1 , HKSPA1
30- from .segments .auth import HIPINS1 , HKTAB4 , HKTAB5 , HKTAN2 , HKTAN3 , HKTAN5 , HKTAN6 , HKTAN7
30+ from .segments .auth import HIPINS1 , HKTAB4 , HKTAB5 , HKTAN2 , HKTAN3 , HKTAN5 , HKTAN6 , HKTAN7 , HIVPPS1 , HIVPP1 , PSRD1 , HKVPA1
3131from .segments .bank import HIBPA3 , HIUPA4 , HKKOM4
3232from .segments .debit import (
3333 HKDBS1 , HKDBS2 , HKDMB1 , HKDMC1 , HKDME1 , HKDME2 ,
@@ -828,7 +828,7 @@ def simple_sepa_transfer(self, account: SEPAAccount, iban: str, bic: str,
828828 :param reason: Transfer reason
829829 :param instant_payment: Whether to use instant payment (defaults to ``False``)
830830 :param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``)
831- :return: Returns either a NeedRetryResponse or TransactionResponse
831+ :return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse
832832 """
833833 config = {
834834 "name" : account_name ,
@@ -908,7 +908,7 @@ def sepa_transfer(self, account: SEPAAccount, pain_message: str, multiple=False,
908908 if book_as_single :
909909 seg .request_single_booking = True
910910
911- return self ._send_with_possible_retry (dialog , seg , self ._continue_sepa_transfer )
911+ return self ._send_pay_with_possible_retry (dialog , seg , self ._continue_sepa_transfer )
912912
913913 def _continue_sepa_transfer (self , command_seg , response ):
914914 retval = TransactionResponse (response )
@@ -1074,19 +1074,56 @@ def resume_dialog(self, dialog_data):
10741074 self ._standing_dialog = None
10751075
10761076
1077+ class NeedVOPResponse (NeedRetryResponse ):
1078+
1079+ def __init__ (self , vop_result , command_seg , resume_method = None ):
1080+ self .vop_result = vop_result
1081+ self .command_seg = command_seg
1082+ if hasattr (resume_method , '__func__' ):
1083+ self .resume_method = resume_method .__func__ .__name__
1084+ else :
1085+ self .resume_method = resume_method
1086+
1087+ def __repr__ (self ):
1088+ return '<o.__class__.__name__(vop_result={o.vop_result!r})>' .format (o = self )
1089+
1090+ @classmethod
1091+ def _from_data_v1 (cls , data ):
1092+ if data ["version" ] == 1 :
1093+ segs = SegmentSequence (data ['segments_bin' ]).segments
1094+ return cls (segs [0 ], segs [1 ], resume_method = data ['resume_method' ])
1095+
1096+ raise Exception ("Wrong blob data version" )
1097+
1098+ def get_data (self ) -> bytes :
1099+ """Return a compressed datablob representing this object.
1100+
1101+ To restore the object, use :func:`fints.client.NeedRetryResponse.from_data`.
1102+ """
1103+ data = {
1104+ "_class_name" : self .__class__ .__name__ ,
1105+ "version" : 1 ,
1106+ "segments_bin" : SegmentSequence ([self .vop_result , self .command_seg ]).render_bytes (),
1107+ "resume_method" : self .resume_method ,
1108+ }
1109+ return compress_datablob (DATA_BLOB_MAGIC_RETRY , 1 , data )
1110+
1111+
10771112class NeedTANResponse (NeedRetryResponse ):
10781113 challenge_raw = None #: Raw challenge as received by the bank
10791114 challenge = None #: Textual challenge to be displayed to the user
10801115 challenge_html = None #: HTML-safe challenge text, possibly with formatting
10811116 challenge_hhduc = None #: HHD_UC challenge to be transmitted to the TAN generator
10821117 challenge_matrix = None #: Matrix code challenge: tuple(mime_type, data)
10831118 decoupled = None #: Use decoupled process
1119+ vop_result = None #: VoP result
10841120
1085- def __init__ (self , command_seg , tan_request , resume_method = None , tan_request_structured = False , decoupled = False ):
1121+ def __init__ (self , command_seg , tan_request , resume_method = None , tan_request_structured = False , decoupled = False , vop_result = None ):
10861122 self .command_seg = command_seg
10871123 self .tan_request = tan_request
10881124 self .tan_request_structured = tan_request_structured
10891125 self .decoupled = decoupled
1126+ self .vop_result = vop_result
10901127 if hasattr (resume_method , '__func__' ):
10911128 self .resume_method = resume_method .__func__ .__name__
10921129 else :
@@ -1315,6 +1352,23 @@ def _get_tan_segment(self, orig_seg, tan_process, tan_seg=None):
13151352
13161353 return seg
13171354
1355+ def _find_vop_format_for_segment (self , seg ):
1356+ vpps = self .bpd .find_segment_first ('HIVPPS' )
1357+ if not vpps :
1358+ return
1359+
1360+ needed = str (seg .header .type ) in list (vpps .parameter .payment_order_segment )
1361+
1362+ if not needed :
1363+ return
1364+
1365+ bank_supported = str (vpps .parameter .supported_report_formats )
1366+
1367+ if "sepade.pain.002.001.10.xsd" != bank_supported :
1368+ logger .warning ("No common supported SEPA version. Defaulting to what bank supports and hoping for the best: %s." , bank_supported )
1369+
1370+ return bank_supported
1371+
13181372 def _need_twostep_tan_for_segment (self , seg ):
13191373 if not self .selected_security_function or self .selected_security_function == '999' :
13201374 return False
@@ -1351,13 +1405,99 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func):
13511405 response = dialog .send (command_seg )
13521406
13531407 return resume_func (command_seg , response )
1408+
1409+ def _send_pay_with_possible_retry (self , dialog , command_seg , resume_func ):
1410+ """
1411+ This adds VoP under the assumption that TAN will be sent,
1412+ There appears to be no VoP flow without sending any authentication.
1413+
1414+ There are really 2 VoP flows: with a full match and otherwise.
1415+ The second flow returns a NeedVOPResponse as intended by the specification flowcharts.
1416+ In this case cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result.
1417+
1418+ The kind of response is in resp.vop_result.single_vop_result.result:
1419+ - 'RCVC' - full match
1420+ - 'RVMC' - partial match, extra info in single_vop_result.close_match_name and .other_identification.
1421+ - 'RVNM' - no match, no extra info seen
1422+ - 'RVNA' - check not available, reason in single_vop_result.na_reason
1423+ - 'PDNG' - pending, seems related to something not implemented right now.
1424+ """
1425+ vop_seg = []
1426+ vop_standard = self ._find_vop_format_for_segment (command_seg )
1427+ if vop_standard :
1428+ from .segments .auth import HKVPP1
1429+ vop_seg = [HKVPP1 (supported_reports = PSRD1 (psrd = [vop_standard ]))]
1430+
1431+ with dialog :
1432+ if self ._need_twostep_tan_for_segment (command_seg ):
1433+ tan_seg = self ._get_tan_segment (command_seg , '4' )
1434+ segments = vop_seg + [command_seg , tan_seg ]
1435+
1436+ response = dialog .send (* segments )
1437+
1438+ if vop_standard :
1439+ hivpp = response .find_segment_first (HIVPP1 , throw = True )
1440+
1441+ vop_result = hivpp .vop_single_result
1442+ if vop_result .result in ('RVNA' , 'RVNM' , 'RVMC' ): # Not Applicable, No Match, Close Match
1443+ return NeedVOPResponse (
1444+ vop_result = hivpp ,
1445+ command_seg = command_seg ,
1446+ resume_method = resume_func ,
1447+ )
1448+ else :
1449+ hivpp = None
1450+
1451+ for resp in response .responses (tan_seg ):
1452+ if resp .code in ('0030' , '3955' ):
1453+ return NeedTANResponse (
1454+ command_seg ,
1455+ response .find_segment_first ('HITAN' ),
1456+ resume_func ,
1457+ self .is_challenge_structured (),
1458+ resp .code == '3955' ,
1459+ hivpp ,
1460+ )
1461+ if resp .code .startswith ('9' ):
1462+ raise Exception ("Error response: {!r}" .format (response ))
1463+ else :
1464+ response = dialog .send (command_seg )
1465+
1466+ return resume_func (command_seg , response )
13541467
13551468 def is_challenge_structured (self ):
13561469 param = self .get_tan_mechanisms ()[self .get_current_tan_mechanism ()]
13571470 if hasattr (param , 'challenge_structured' ):
13581471 return param .challenge_structured
13591472 return False
13601473
1474+ def approve_vop_response (self , challenge : NeedVOPResponse ):
1475+ """
1476+ Approves an operation that had a non-match VoP (verification of payee) response.
1477+
1478+ :param challenge: NeedVOPResponse to respond to
1479+ :return: New response after sending VOP response
1480+ """
1481+ with self ._get_dialog () as dialog :
1482+ vop_seg = [HKVPA1 (vop_id = challenge .vop_result .vop_id )]
1483+ tan_seg = self ._get_tan_segment (challenge .command_seg , '4' )
1484+ segments = vop_seg + [challenge .command_seg , tan_seg ]
1485+ response = dialog .send (* segments )
1486+
1487+ for resp in response .responses (tan_seg ):
1488+ if resp .code in ('0030' , '3955' ):
1489+ return NeedTANResponse (
1490+ challenge .command_seg ,
1491+ response .find_segment_first ('HITAN' ),
1492+ challenge .resume_method ,
1493+ self .is_challenge_structured (),
1494+ resp .code == '3955' ,
1495+ challenge .vop_result ,
1496+ )
1497+
1498+ resume_func = getattr (self , challenge .resume_method )
1499+ return resume_func (challenge .command_seg , response )
1500+
13611501 def send_tan (self , challenge : NeedTANResponse , tan : str ):
13621502 """
13631503 Sends a TAN to confirm a pending operation.
@@ -1370,15 +1510,18 @@ def send_tan(self, challenge: NeedTANResponse, tan: str):
13701510 :param tan: TAN value
13711511 :return: New response after sending TAN
13721512 """
1373-
13741513 with self ._get_dialog () as dialog :
13751514 if challenge .decoupled :
13761515 tan_seg = self ._get_tan_segment (challenge .command_seg , 'S' , challenge .tan_request )
13771516 else :
13781517 tan_seg = self ._get_tan_segment (challenge .command_seg , '2' , challenge .tan_request )
13791518 self ._pending_tan = tan
13801519
1381- response = dialog .send (tan_seg )
1520+ vop_seg = []
1521+ if challenge .vop_result and challenge .vop_result .vop_single_result .result == 'RCVC' :
1522+ vop_seg = [HKVPA1 (vop_id = challenge .vop_result .vop_id )]
1523+ segments = vop_seg + [tan_seg ]
1524+ response = dialog .send (* segments )
13821525
13831526 if challenge .decoupled :
13841527 # TAN process = S
0 commit comments