|
1 | | -from fints.client import FinTS3PinTanClient |
| 1 | +from fints.client import FinTS3PinTanClient, NeedTANResponse |
2 | 2 | from mt940.models import Date |
3 | | -import os |
| 3 | +import base64 |
| 4 | +import hashlib |
4 | 5 | import json |
| 6 | +import os |
| 7 | +import sys |
| 8 | +import tempfile |
5 | 9 |
|
6 | | -def retrieve_transactions( |
7 | | - account, blz, password, endpoint, selected_account, start, end |
8 | | -): |
9 | | - client = FinTS3PinTanClient(blz, account, password, endpoint, product_id = "EC449295201FA9BE5040B9154") |
10 | | - return TRetriever(client, selected_account).get_hbci_transactions(start, end) |
| 10 | +SIMULATED_TAN_METHODS = [ |
| 11 | + {"id": "900", "name": "Simulated pushTAN"}, |
| 12 | + {"id": "901", "name": "Simulated photoTAN"}, |
| 13 | +] |
| 14 | +SIMULATED_PHOTOTAN_PNG = base64.b64decode( |
| 15 | + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" |
| 16 | +) |
11 | 17 |
|
12 | 18 |
|
13 | | -class TRetriever: |
14 | | - def __init__(self, client, accountnumber): |
15 | | - self.client = client |
16 | | - self.accountnumber = accountnumber |
| 19 | +def send(message_type, **values): |
| 20 | + print(json.dumps({"type": message_type, **values}), flush=True) |
17 | 21 |
|
18 | | - def get_hbci_transactions(self, start_date, end_date): |
19 | | - accounts = self.client.get_sepa_accounts() |
20 | 22 |
|
21 | | - account = self.__find_matching_account(accounts, self.accountnumber) |
| 23 | +def receive(expected_type): |
| 24 | + line = sys.stdin.readline() |
| 25 | + if not line: |
| 26 | + raise RuntimeError("The fints2ledger process closed the protocol input") |
| 27 | + message = json.loads(line) |
| 28 | + if message.get("type") != expected_type: |
| 29 | + raise RuntimeError( |
| 30 | + f"Expected protocol message '{expected_type}', got {message.get('type')!r}" |
| 31 | + ) |
| 32 | + return message |
| 33 | + |
| 34 | + |
| 35 | +def state_path(args): |
| 36 | + identity = "\0".join((args["endpoint"], args["blz"], args["account"])) |
| 37 | + digest = hashlib.sha256(identity.encode()).hexdigest()[:24] |
| 38 | + return os.path.join(args["stateDirectory"], f"fints-state-{digest}.bin") |
| 39 | + |
| 40 | + |
| 41 | +def load_state(path): |
| 42 | + try: |
| 43 | + with open(path, "rb") as reader: |
| 44 | + return reader.read() |
| 45 | + except FileNotFoundError: |
| 46 | + return None |
| 47 | + |
| 48 | + |
| 49 | +def store_state(path, data): |
| 50 | + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) |
| 51 | + fd, temporary_path = tempfile.mkstemp(dir=os.path.dirname(path)) |
| 52 | + try: |
| 53 | + os.fchmod(fd, 0o600) |
| 54 | + with os.fdopen(fd, "wb") as writer: |
| 55 | + writer.write(data) |
| 56 | + os.replace(temporary_path, path) |
| 57 | + os.chmod(path, 0o600) |
| 58 | + except BaseException: |
| 59 | + try: |
| 60 | + os.unlink(temporary_path) |
| 61 | + except FileNotFoundError: |
| 62 | + pass |
| 63 | + raise |
| 64 | + |
| 65 | + |
| 66 | +def choose_tan_settings(client): |
| 67 | + if os.environ.get("FINTS2LEDGER_SIMULATE_TAN") == "1": |
| 68 | + send("tan_methods", methods=SIMULATED_TAN_METHODS) |
| 69 | + selected = receive("tan_method")["id"] |
| 70 | + if selected not in {method["id"] for method in SIMULATED_TAN_METHODS}: |
| 71 | + raise RuntimeError(f"Unknown simulated TAN method selected: {selected!r}") |
| 72 | + if selected == "901": |
| 73 | + send( |
| 74 | + "tan_challenge", |
| 75 | + challenge="Simulated photoTAN challenge", |
| 76 | + decoupled=False, |
| 77 | + hhduc=None, |
| 78 | + matrix={ |
| 79 | + "mimeType": "image/png", |
| 80 | + "data": base64.b16encode(SIMULATED_PHOTOTAN_PNG).decode("ascii"), |
| 81 | + "base64Data": base64.b64encode(SIMULATED_PHOTOTAN_PNG).decode( |
| 82 | + "ascii" |
| 83 | + ), |
| 84 | + }, |
| 85 | + ) |
| 86 | + receive("tan") |
| 87 | + # The simulated method is deliberately not passed to pyfints: the bank |
| 88 | + # did not advertise it and would reject it. Continue as a TAN-less bank. |
| 89 | + return |
| 90 | + |
| 91 | + if not client.get_current_tan_mechanism(): |
| 92 | + client.fetch_tan_mechanisms() |
| 93 | + mechanisms = list(client.get_tan_mechanisms().items()) |
| 94 | + if not mechanisms: |
| 95 | + # TAN-less banks do not advertise any mechanisms. There is no |
| 96 | + # mechanism or medium to select, so continue with the dialog. |
| 97 | + return |
| 98 | + if len(mechanisms) == 1: |
| 99 | + client.set_tan_mechanism(mechanisms[0][0]) |
| 100 | + else: |
| 101 | + send( |
| 102 | + "tan_methods", |
| 103 | + methods=[ |
| 104 | + {"id": identifier, "name": mechanism.name} |
| 105 | + for identifier, mechanism in mechanisms |
| 106 | + ], |
| 107 | + ) |
| 108 | + selected = receive("tan_method")["id"] |
| 109 | + if selected not in dict(mechanisms): |
| 110 | + raise RuntimeError(f"Unknown TAN method selected: {selected!r}") |
| 111 | + client.set_tan_mechanism(selected) |
| 112 | + |
| 113 | + if client.selected_tan_medium is None and client.is_tan_media_required(): |
| 114 | + media = client.get_tan_media()[1] |
| 115 | + if len(media) == 1: |
| 116 | + client.set_tan_medium(media[0]) |
| 117 | + elif len(media) == 0: |
| 118 | + # Some banks demand a medium field but return no selectable media. |
| 119 | + client.selected_tan_medium = "" |
| 120 | + else: |
| 121 | + send( |
| 122 | + "tan_media", |
| 123 | + media=[ |
| 124 | + { |
| 125 | + "index": index, |
| 126 | + "name": medium.tan_medium_name, |
| 127 | + "mobile": medium.mobile_number_masked, |
| 128 | + } |
| 129 | + for index, medium in enumerate(media) |
| 130 | + ], |
| 131 | + ) |
| 132 | + selected = receive("tan_medium")["index"] |
| 133 | + if not isinstance(selected, int) or not 0 <= selected < len(media): |
| 134 | + raise RuntimeError(f"Unknown TAN medium selected: {selected!r}") |
| 135 | + client.set_tan_medium(media[selected]) |
| 136 | + |
| 137 | + |
| 138 | +def send_tan(client, response): |
| 139 | + while isinstance(response, NeedTANResponse): |
| 140 | + matrix = None |
| 141 | + if response.challenge_matrix: |
| 142 | + mime_type, image_data = response.challenge_matrix |
| 143 | + matrix = { |
| 144 | + "mimeType": mime_type, |
| 145 | + "data": base64.b16encode(image_data).decode("ascii"), |
| 146 | + "base64Data": base64.b64encode(image_data).decode("ascii"), |
| 147 | + } |
| 148 | + |
| 149 | + send( |
| 150 | + "tan_challenge", |
| 151 | + challenge=response.challenge or "A TAN is required", |
| 152 | + decoupled=bool(response.decoupled), |
| 153 | + hhduc=response.challenge_hhduc, |
| 154 | + matrix=matrix, |
| 155 | + ) |
| 156 | + tan = receive("tan")["value"] |
| 157 | + if not isinstance(tan, str): |
| 158 | + raise RuntimeError("The TAN must be a string") |
| 159 | + response = client.send_tan(response, tan) |
| 160 | + return response |
22 | 161 |
|
| 162 | + |
| 163 | +class TransactionRetriever: |
| 164 | + def __init__(self, client, account_number): |
| 165 | + self.client = client |
| 166 | + self.account_number = account_number |
| 167 | + |
| 168 | + def get_transactions(self, start_date, end_date): |
| 169 | + accounts = self.client.get_sepa_accounts() |
| 170 | + account = self.find_matching_account(accounts) |
23 | 171 | return self.client.get_transactions(account, start_date, end_date) |
24 | 172 |
|
25 | | - def __find_matching_account(self, accounts, accountnumber): |
| 173 | + def find_matching_account(self, accounts): |
26 | 174 | for account in accounts: |
27 | | - if account.accountnumber == accountnumber: |
| 175 | + if account.accountnumber == self.account_number: |
28 | 176 | return account |
29 | | - raise Exception( |
30 | | - f"Could not find a matching account for account number '{accountnumber}'. Possible accounts: {accounts}" |
| 177 | + raise RuntimeError( |
| 178 | + f"Could not find account '{self.account_number}'. Possible accounts: {accounts}" |
31 | 179 | ) |
32 | 180 |
|
33 | | -date_format="%Y/%m/%d" |
| 181 | + |
| 182 | +DATE_FORMAT = "%Y/%m/%d" |
| 183 | + |
| 184 | + |
34 | 185 | def transaction_to_object(transaction): |
35 | 186 | hbci_data = transaction.data |
36 | | - |
37 | | - date = hbci_data["date"].strftime(date_format) |
38 | | - amount = str(hbci_data["amount"].amount) |
39 | | - currency = hbci_data["amount"].currency |
40 | | - # GLS bank provides no "posting_text", "AdditionalEntryInformation" seems to be equivalent |
41 | | - posting_text = hbci_data.get("posting_text", hbci_data.get("AdditionalEntryInformation", None)) |
42 | | - applicant_name = hbci_data["applicant_name"] |
43 | | - purpose = hbci_data["purpose"] |
| 187 | + posting_text = hbci_data.get( |
| 188 | + "posting_text", hbci_data.get("AdditionalEntryInformation") |
| 189 | + ) |
44 | 190 | return { |
45 | | - "date": date, |
46 | | - "amount": amount, |
47 | | - "currency": currency, |
| 191 | + "date": hbci_data["date"].strftime(DATE_FORMAT), |
| 192 | + "amount": str(hbci_data["amount"].amount), |
| 193 | + "currency": hbci_data["amount"].currency, |
48 | 194 | "posting": (posting_text or "").strip(), |
49 | | - "payee": (applicant_name or "").strip(), |
50 | | - "purpose": (purpose or "").strip(), |
| 195 | + "payee": (hbci_data["applicant_name"] or "").strip(), |
| 196 | + "purpose": (hbci_data["purpose"] or "").strip(), |
51 | 197 | } |
52 | 198 |
|
53 | 199 |
|
54 | | -def main(): |
55 | | - args = json.loads(os.environ["FINTS2LEDGER_ARGS"]) |
56 | | - transactions = retrieve_transactions( |
57 | | - account=args["account"], |
58 | | - blz=args["blz"], |
59 | | - password=args["password"], |
60 | | - endpoint=args["endpoint"], |
61 | | - selected_account=args["selectedAccount"], |
62 | | - start=date_string_to_mt940_date(args["start"]), |
63 | | - end=date_string_to_mt940_date(args["end"]), |
| 200 | +def date_string_to_mt940_date(date_string): |
| 201 | + year, month, day = date_string.split("/") |
| 202 | + return Date(year=year, month=month, day=day) |
| 203 | + |
| 204 | + |
| 205 | +def run(args): |
| 206 | + path = state_path(args) |
| 207 | + client = FinTS3PinTanClient( |
| 208 | + args["blz"], |
| 209 | + args["account"], |
| 210 | + args["password"], |
| 211 | + args["endpoint"], |
| 212 | + product_id="EC449295201FA9BE5040B9154", |
| 213 | + from_data=load_state(path), |
64 | 214 | ) |
65 | | - converted = json.dumps(list(map(transaction_to_object, transactions))) |
66 | | - print(converted) |
| 215 | + choose_tan_settings(client) |
67 | 216 |
|
| 217 | + with client: |
| 218 | + client.init_tan_response = send_tan(client, client.init_tan_response) |
| 219 | + result = TransactionRetriever(client, args["selectedAccount"]).get_transactions( |
| 220 | + date_string_to_mt940_date(args["start"]), |
| 221 | + date_string_to_mt940_date(args["end"]), |
| 222 | + ) |
| 223 | + result = send_tan(client, result) |
| 224 | + transactions = [transaction_to_object(transaction) for transaction in result] |
68 | 225 |
|
69 | | -def date_string_to_mt940_date(date_string): |
70 | | - parts = date_string.split("/") |
71 | | - return Date(year=parts[0], month=parts[1], day=parts[2]) |
| 226 | + store_state(path, client.deconstruct(including_private=True)) |
| 227 | + send("transactions", transactions=transactions) |
| 228 | + |
| 229 | + |
| 230 | +def main(): |
| 231 | + try: |
| 232 | + run(receive("start")["arguments"]) |
| 233 | + except Exception as exception: |
| 234 | + send("error", message=str(exception)) |
| 235 | + raise |
72 | 236 |
|
73 | 237 |
|
74 | 238 | if __name__ == "__main__": |
|
0 commit comments