Skip to content

Commit 0cc1649

Browse files
committed
Improved implementation
1 parent e7a7182 commit 0cc1649

6 files changed

Lines changed: 171 additions & 78 deletions

File tree

docs/registration.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Registration necessary
2+
======================
3+
4+
As of September 14th, 2019, all FinTS programs need to be registered with the ZKA or
5+
banks will block access. You need to fill out a PDF form and will be assigned a
6+
product ID that you can pass above.
7+
8+
Click here to read more about the `registration process`_.
9+
10+
11+
.. _registration process: https://www.hbci-zka.de/register/prod_register.htm

docs/transfers.rst

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,16 @@ You can create a simple SEPA transfer using this convenient client method:
1212
:members: simple_sepa_transfer
1313
:noindex:
1414

15+
The return value may be a `NeedVOPResponse` in which case you need to call `approve_vop_response` to proceed.
16+
17+
At any point, you might receive a `NeedTANResponse`.
1518
You should then enter a TAN, read our chapter :ref:`tans` to find out more.
1619

20+
.. autoclass:: fints.client.FinTS3PinTanClient
21+
:members: approve_vop_response
22+
:noindex:
23+
24+
1725
Advanced mode
1826
-------------
1927

@@ -55,20 +63,37 @@ Full example
5563
endtoend_id='NOTPROVIDED',
5664
)
5765
58-
while isinstance(res, NeedTANResponse):
59-
print("A TAN is required", res.challenge)
60-
61-
if getattr(res, 'challenge_hhduc', None):
62-
try:
63-
terminal_flicker_unix(res.challenge_hhduc)
64-
except KeyboardInterrupt:
65-
pass
66-
67-
if result.decoupled:
68-
tan = input('Please press enter after confirming the transaction in your app:')
69-
else:
70-
tan = input('Please enter TAN:')
71-
res = client.send_tan(res, tan)
66+
while isinstance(res, NeedTANResponse | NeedVOPResponse):
67+
if isinstance(res, NeedTANResponse):
68+
print("A TAN is required", res.challenge)
69+
70+
if getattr(res, 'challenge_hhduc', None):
71+
try:
72+
terminal_flicker_unix(res.challenge_hhduc)
73+
except KeyboardInterrupt:
74+
pass
75+
76+
if result.decoupled:
77+
tan = input('Please press enter after confirming the transaction in your app:')
78+
else:
79+
tan = input('Please enter TAN:')
80+
res = client.send_tan(res, tan)
81+
elif isinstance(res, NeedVOPResponse):
82+
if res.vop_result.vop_single_result.result == "RVMC":
83+
print("Payee name is a close match")
84+
print("Name retrieved by bank:", res.vop_result.vop_single_result.close_match_name)
85+
if res.vop_result.vop_single_result.other_identification:
86+
print("Other info retrieved by bank:", res.vop_result.vop_single_result.other_identification)
87+
elif res.vop_result.vop_single_result.result == "RVNM":
88+
print("Payee name does not match match")
89+
elif res.vop_result.vop_single_result.result == "RVNA":
90+
print("Payee name could not be verified")
91+
print("Reason:", res.vop_result.vop_single_result.na_reason)
92+
elif res.vop_result.vop_single_result.result == "PDNG":
93+
print("Payee name could not be verified (pending state, can't be handled by this library)")
94+
print("Do you want to continue? Your bank will not be liable if the money ends up in the wrong place.")
95+
input('Please press enter to confirm or Ctrl+C to cancel')
96+
res = client.approve_vop_response(res)
7297
7398
print(res.status)
7499
print(res.responses)

docs/trouble.rst

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ the problem.
6565
return f.send_tan(response, tan)
6666
6767
68+
def ask_for_vop(response: NeedVOPResponse):
69+
if response.vop_result.vop_single_result.result == "RVMC":
70+
print("Payee name is a close match")
71+
print("Name retrieved by bank:", response.vop_result.vop_single_result.close_match_name)
72+
if response.vop_result.vop_single_result.other_identification:
73+
print("Other info retrieved by bank:", response.vop_result.vop_single_result.other_identification)
74+
elif response.vop_result.vop_single_result.result == "RVNM":
75+
print("Payee name does not match match")
76+
elif response.vop_result.vop_single_result.result == "RVNA":
77+
print("Payee name could not be verified")
78+
print("Reason:", response.vop_result.vop_single_result.na_reason)
79+
elif response.vop_result.vop_single_result.result == "PDNG":
80+
print("Payee name could not be verified (pending state, can't be handled by this library)")
81+
print("Do you want to continue? Your bank will not be liable if the money ends up in the wrong place.")
82+
input('Please press enter to confirm or Ctrl+C to cancel')
83+
return f.approve_vop_response(response)
84+
85+
6886
# Open the actual dialog
6987
with f:
7088
# Since PSD2, a TAN might be needed for dialog initialization. Let's check if there is one required
@@ -172,8 +190,11 @@ the problem.
172190
endtoend_id='NOTPROVIDED',
173191
)
174192
175-
while isinstance(res, NeedTANResponse):
176-
res = ask_for_tan(res)
193+
while isinstance(res, NeedTANResponse | NeedVOPResponse):
194+
if isinstance(res, NeedTANResponse):
195+
res = ask_for_tan(res)
196+
elif isinstance(res, NeedVOPResponse):
197+
res = ask_for_vop(res)
177198
elif choice == 11:
178199
print("Select statement")
179200
statements = f.get_statements(account)

fints/client.py

Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,6 @@ def process_response_message(self, dialog, message: FinTSInstituteMessage, inter
225225
message.find_segments('HIUPD')
226226
)
227227

228-
vpps = message.find_segment_first(HIVPPS1)
229-
if vpps:
230-
self.vpps = vpps
231-
232228
for seg in message.find_segments(HIRMG2):
233229
for response in seg.responses:
234230
if not internal_send:
@@ -797,7 +793,6 @@ def _find_supported_sepa_version(self, candidate_versions):
797793
return candidate_versions[0]
798794

799795
bank_supported = list(hispas.parameter.supported_sepa_formats)
800-
print(hispas)
801796

802797
for candidate in candidate_versions:
803798
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,
823818
:param reason: Transfer reason
824819
:param instant_payment: Whether to use instant payment (defaults to ``False``)
825820
:param endtoend_id: End-to-end-Id (defaults to ``NOTPROVIDED``)
826-
:return: Returns either a NeedRetryResponse or TransactionResponse
821+
:return: Returns either a NeedRetryResponse or NeedVOPResponse or TransactionResponse
827822
"""
828823
config = {
829824
"name": account_name,
@@ -1063,14 +1058,49 @@ def resume_dialog(self, dialog_data):
10631058
self._standing_dialog = None
10641059

10651060

1061+
class NeedVOPResponse(NeedRetryResponse):
1062+
1063+
def __init__(self, vop_result, command_seg, resume_method=None):
1064+
self.vop_result = vop_result
1065+
self.command_seg = command_seg
1066+
if hasattr(resume_method, '__func__'):
1067+
self.resume_method = resume_method.__func__.__name__
1068+
else:
1069+
self.resume_method = resume_method
1070+
1071+
def __repr__(self):
1072+
return '<o.__class__.__name__(vop_result={o.vop_result!r})>'.format(o=self)
1073+
1074+
@classmethod
1075+
def _from_data_v1(cls, data):
1076+
if data["version"] == 1:
1077+
segs = SegmentSequence(data['segments_bin']).segments
1078+
return cls(segs[0], segs[1], resume_method=data['resume_method'])
1079+
1080+
raise Exception("Wrong blob data version")
1081+
1082+
def get_data(self) -> bytes:
1083+
"""Return a compressed datablob representing this object.
1084+
1085+
To restore the object, use :func:`fints.client.NeedRetryResponse.from_data`.
1086+
"""
1087+
data = {
1088+
"_class_name": self.__class__.__name__,
1089+
"version": 1,
1090+
"segments_bin": SegmentSequence([self.vop_result, self.command_seg]).render_bytes(),
1091+
"resume_method": self.resume_method,
1092+
}
1093+
return compress_datablob(DATA_BLOB_MAGIC_RETRY, 1, data)
1094+
1095+
10661096
class NeedTANResponse(NeedRetryResponse):
10671097
challenge_raw = None #: Raw challenge as received by the bank
10681098
challenge = None #: Textual challenge to be displayed to the user
10691099
challenge_html = None #: HTML-safe challenge text, possibly with formatting
10701100
challenge_hhduc = None #: HHD_UC challenge to be transmitted to the TAN generator
10711101
challenge_matrix = None #: Matrix code challenge: tuple(mime_type, data)
10721102
decoupled = None #: Use decoupled process
1073-
vop_result = None # VoC result to either send an accept reply with TAN (on full match) or display a warning (otherwise; reply already sent)
1103+
vop_result = None #: VoP result
10741104

10751105
def __init__(self, command_seg, tan_request, resume_method=None, tan_request_structured=False, decoupled=False, vop_result=None):
10761106
self.command_seg = command_seg
@@ -1307,12 +1337,16 @@ def _get_tan_segment(self, orig_seg, tan_process, tan_seg=None):
13071337
return seg
13081338

13091339
def _find_vop_format_for_segment(self, seg):
1310-
needed = str(seg.header.type) in list(self.vpps.parameter.payment_order_segment)
1340+
vpps = self.bpd.find_segment_first('HIVPPS')
1341+
if not vpps:
1342+
return
1343+
1344+
needed = str(seg.header.type) in list(vpps.parameter.payment_order_segment)
13111345

13121346
if not needed:
13131347
return
13141348

1315-
bank_supported = str(self.vpps.parameter.supported_report_formats)
1349+
bank_supported = str(vpps.parameter.supported_report_formats)
13161350

13171351
if "sepade.pain.002.001.10.xsd" != bank_supported:
13181352
logger.warning("No common supported SEPA version. Defaulting to what bank supports and hoping for the best: %s.", bank_supported)
@@ -1357,71 +1391,45 @@ def _send_with_possible_retry(self, dialog, command_seg, resume_func):
13571391
return resume_func(command_seg, response)
13581392

13591393
def _send_pay_with_possible_retry(self, dialog, command_seg, resume_func):
1360-
"""This adds VoP under the assumption that TAN will be sent,
1361-
There is no VoP flow without sending any authentication that I could distinguish.
1362-
1363-
There are really 2 VoP flows: with a full match and otherwise. All of them return the HIVPP response in NeedTANResponse.
1364-
1365-
On a full match, it's only needed to copy the vop_id alongside the TAN response.
1394+
"""
1395+
This adds VoP under the assumption that TAN will be sent,
1396+
There appears to be no VoP flow without sending any authentication.
13661397
1367-
In all other cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result.
1398+
There are really 2 VoP flows: with a full match and otherwise.
1399+
The second flow returns a NeedVOPResponse as intended by the specification flowcharts.
1400+
In this case cases, the application should ask the user for confirmation based on HIVPP data in resp.vop_result.
13681401
13691402
The kind of response is in resp.vop_result.single_vop_result.result:
13701403
- 'RCVC' - full match
13711404
- 'RVMC' - partial match, extra info in single_vop_result.close_match_name and .other_identification.
13721405
- 'RVNM' - no match, no extra info seen
13731406
- 'RVNA' - check not available, reason in single_vop_result.na_reason
13741407
- 'PDNG' - pending, seems related to something not implemented right now.
1375-
1376-
Simple untested example.
1377-
1378-
```
1379-
def process_tan(client, challenge, prompt):
1380-
if challenge.vop_result and not challenge.vop_result.vop_single_result.result == 'RCVC':
1381-
input("WARNING!!! Recipient name don't match:", challenge.vop_result.single_vop_result.close_match_name)
1382-
print("A TAN is required:", challenge.challenge)
1383-
hitan6 = challenge.tan_request
1384-
... get TAN from user
1385-
return client.send_tan(challenge, tan)
1386-
```
1387-
1388-
This gives the user a chance to slam ctrl+C before adding TAN.
1389-
1390-
Internally, the library always sends a positive confirmation of transaction because the user only really acknowledges it by sending the TAN.
1391-
Even if the legal liability moves on receiving the accepting message, there's no liability to be had before TAN goes through. Amirite?
13921408
"""
13931409
vop_seg = []
13941410
vop_standard = self._find_vop_format_for_segment(command_seg)
13951411
if vop_standard:
13961412
from .segments.auth import HKVPP1
13971413
vop_seg = [HKVPP1(supported_reports=PSRD1(psrd=[vop_standard]))]
1414+
13981415
with dialog:
13991416
if self._need_twostep_tan_for_segment(command_seg):
14001417
tan_seg = self._get_tan_segment(command_seg, '4')
14011418
segments = vop_seg + [command_seg, tan_seg]
14021419

14031420
response = dialog.send(*segments)
14041421

1405-
hivpp = response.find_segment_first(HIVPP1)
1406-
1407-
if not hivpp:
1408-
raise Exception("Mising VoP reponse")
1409-
vop_result = hivpp.vop_single_result
1410-
print(hivpp)
1411-
if vop_result.result == 'RVNA':
1412-
print(vop_result.na_reason)
1413-
vop_seg = [HKVPA1(vop_id=hivpp.vop_id)]
1414-
segments = vop_seg + [command_seg, tan_seg]
1415-
response = dialog.send(*segments)
1416-
elif vop_result.result == 'RVNM':
1417-
vop_seg = [HKVPA1(vop_id=hivpp.vop_id)]
1418-
segments = vop_seg + [command_seg, tan_seg]
1419-
response = dialog.send(*segments)
1420-
elif vop_result.result == 'RVMC':
1421-
vop_seg = [HKVPA1(vop_id=hivpp.vop_id)]
1422-
segments = vop_seg + [command_seg, tan_seg]
1423-
response = dialog.send(*segments)
1424-
print(vop_result.result)
1422+
if vop_standard:
1423+
hivpp = response.find_segment_first(HIVPP1, throw=True)
1424+
1425+
vop_result = hivpp.vop_single_result
1426+
if vop_result.result in ('RVNA', 'RVNM', 'RVMC'): # Not Applicable, No Match, Close Match
1427+
return NeedVOPResponse(
1428+
vop_result=hivpp,
1429+
command_seg=command_seg,
1430+
resume_method=resume_func,
1431+
)
1432+
14251433
for resp in response.responses(tan_seg):
14261434
if resp.code in ('0030', '3955'):
14271435
return NeedTANResponse(
@@ -1445,6 +1453,33 @@ def is_challenge_structured(self):
14451453
return param.challenge_structured
14461454
return False
14471455

1456+
def approve_vop_response(self, challenge: NeedVOPResponse):
1457+
"""
1458+
Approves an operation that had a non-match VoP (verification of payee) response.
1459+
1460+
:param challenge: NeedVOPResponse to respond to
1461+
:return: New response after sending VOP response
1462+
"""
1463+
with self._get_dialog() as dialog:
1464+
vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)]
1465+
tan_seg = self._get_tan_segment(challenge.command_seg, '4')
1466+
segments = vop_seg + [challenge.command_seg, tan_seg]
1467+
response = dialog.send(*segments)
1468+
1469+
for resp in response.responses(tan_seg):
1470+
if resp.code in ('0030', '3955'):
1471+
return NeedTANResponse(
1472+
challenge.command_seg,
1473+
response.find_segment_first('HITAN'),
1474+
challenge.resume_method,
1475+
self.is_challenge_structured(),
1476+
resp.code == '3955',
1477+
challenge.vop_result,
1478+
)
1479+
1480+
resume_func = getattr(self, challenge.resume_method)
1481+
return resume_func(challenge.command_seg, response)
1482+
14481483
def send_tan(self, challenge: NeedTANResponse, tan: str):
14491484
"""
14501485
Sends a TAN to confirm a pending operation.
@@ -1457,7 +1492,6 @@ def send_tan(self, challenge: NeedTANResponse, tan: str):
14571492
:param tan: TAN value
14581493
:return: New response after sending TAN
14591494
"""
1460-
14611495
with self._get_dialog() as dialog:
14621496
if challenge.decoupled:
14631497
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):
14661500
self._pending_tan = tan
14671501

14681502
vop_seg = []
1469-
print(challenge.vop_result)
14701503
if challenge.vop_result and challenge.vop_result.vop_single_result.result == 'RCVC':
14711504
vop_seg = [HKVPA1(vop_id=challenge.vop_result.vop_id)]
14721505
segments = vop_seg + [tan_seg]

fints/fields.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,18 +291,20 @@ def _render_value(self, value):
291291
return super()._render_value(val)
292292

293293

294-
# FIXME: stub
295294
class TimestampField(DataElementField):
295+
# Defined in the VoP standard, but missing in the Formals document. We just treat it as
296+
# opaque bytes.
296297
type = 'tsp'
297298
_DOC_TYPE = bytes
298299

299300
def _render_value(self, value):
300301
retval = bytes(value)
301302
self._check_value_length(retval)
302-
303303
return retval
304304

305-
def _parse_value(self, value): return bytes(value)
305+
def _parse_value(self, value):
306+
return bytes(value)
307+
306308

307309
class PasswordField(AlphanumericField):
308310
type = ''

0 commit comments

Comments
 (0)