diff --git a/README.md b/README.md index e954c872..4ee2d24e 100644 --- a/README.md +++ b/README.md @@ -20,34 +20,14 @@ Check out the [Banking Wiki](https://github.com/alyf-de/banking/wiki) for a step ## Country and Bank Coverage -Currently, we [support more than 15.000 banks from the following countries](https://portal.openbanking.klarna.com/bank-matrix). + + +We use the EBICS protocol which is widely supported by banks in the following countries: - 🇦🇹 Austria -- 🇧🇪 Belgium -- 🇭🇷 Croatia -- 🇨🇿 Czech Republic -- 🇩🇰 Denmark -- 🇪🇪 Estonia -- 🇫🇮 Finland - 🇫🇷 France - 🇩🇪 Germany -- 🇭🇺 Hungary -- 🇮🇪 Ireland -- 🇮🇹 Italy -- 🇱🇻 Latvia -- 🇱🇹 Lithuania -- 🇱🇺 Luxembourg -- 🇲🇹 Malta -- 🇳🇱 Netherlands -- 🇳🇴 Norway -- 🇵🇱 Poland -- 🇵🇹 Portugal -- 🇷🇴 Romania -- 🇸🇰 Slovakia -- 🇪🇸 Spain -- 🇸🇪 Sweden - 🇨🇭 Switzerland -- 🇬🇧 United Kingdom ## Installation @@ -57,3 +37,13 @@ Install [via Frappe Cloud](https://frappecloud.com/marketplace/apps/banking) or bench get-app https://github.com/alyf-de/banking.git bench --site install-app banking ``` + +If you want to use ebics on Apple Silicon, the runtime library must be signed manually: + +```bash +# python3.11 +sudo codesign --force --deep --sign - env/lib/python3.11/site-packages/fintech/runtime/darwin/aarch64/pyarmor_runtime.so + +# python3.10 +sudo codesign --force --deep --sign - env/lib/python3.10/site-packages/fintech/pytransform/platforms/darwin/aarch64/_pytransform.dylib +``` diff --git a/banking/connectors/admin_request.py b/banking/connectors/admin_request.py index b501060d..8009940d 100644 --- a/banking/connectors/admin_request.py +++ b/banking/connectors/admin_request.py @@ -136,3 +136,29 @@ def fetch_subscription(self): def get_customer_portal(self): method = "banking_admin.api.get_customer_portal" return requests.get(url=self.url + method) + + def get_fintech_license(self): + method = "banking_admin.ebics_api.get_fintech_license" + return requests.post( + url=self.url + method, headers=self.headers, json=self.data.copy() + ) + + def register_ebics_user(self, host_id: str, partner_id: str, user_id: str): + data = self.data + data.update({"host_id": host_id, "partner_id": partner_id, "user_id": user_id}) + method = "banking_admin.ebics_api.register_ebics_user" + return requests.post( + url=self.url + method, + headers=self.headers, + json=data, + ) + + def remove_ebics_user(self, host_id: str, partner_id: str, user_id: str): + data = self.data + data.update({"host_id": host_id, "partner_id": partner_id, "user_id": user_id}) + method = "banking_admin.ebics_api.remove_ebics_user" + return requests.post( + url=self.url + method, + headers=self.headers, + json=data, + ) diff --git a/banking/ebics/__init__.py b/banking/ebics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/banking/ebics/doctype/__init__.py b/banking/ebics/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/banking/ebics/doctype/ebics_user/__init__.py b/banking/ebics/doctype/ebics_user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/banking/ebics/doctype/ebics_user/ebics_user.js b/banking/ebics/doctype/ebics_user/ebics_user.js new file mode 100644 index 00000000..b4ed676e --- /dev/null +++ b/banking/ebics/doctype/ebics_user/ebics_user.js @@ -0,0 +1,177 @@ +// Copyright (c) 2024, ALYF GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on("EBICS User", { + refresh(frm) { + if (frm.doc.initialized && !frm.doc.bank_keys_activated) { + frm.dashboard.set_headline( + __("Please print the attached INI letter, send it to your bank and wait for confirmation. Then verify the bank keys.") + ); + } + + if (!frm.doc.initialized || frappe.boot.developer_mode) { + frm.add_custom_button( + __("Initialize"), + () => { + frappe.prompt( + [ + { + fieldname: "passphrase", + label: __("Passphrase"), + fieldtype: "Password", + description: __("Set a new password for downloading bank statements from your bank.") + }, + { + fieldname: "store_passphrase", + label: __("Store Passphrase"), + fieldtype: "Check", + default: 1, + description: __("Store the passphrase in the ERPNext database to enable automated, regular download of bank statements.") + }, + { + fieldname: "signature_passphrase", + label: __("Signature Passphrase"), + fieldtype: "Password", + description: __("Set a new password for uploading transactions to your bank.") + }, + { + fieldname: "info", + fieldtype: "HTML", + options: __( + "Note: When you lose these passwords, you will have to go through the initialization process with your bank again." + ) + } + ], + (values) => { + frappe.call({ + method: "banking.ebics.doctype.ebics_user.ebics_user.initialize", + args: { ebics_user: frm.doc.name, ...values }, + freeze: true, + freeze_message: __("Initializing..."), + callback: () => frm.reload_doc(), + }); + }, + __("Initialize EBICS User") + ); + }, + frm.doc.initialized ? __("Actions") : null + ); + } + + if (frm.doc.initialized && (!frm.doc.bank_keys_activated || frappe.boot.developer_mode)) { + frm.add_custom_button( + __("Verify Bank Keys"), + async () => { + bank_keys = await get_bank_keys(frm.doc.name); + if (!bank_keys) { + return; + } + + message = __( + "Please confirm that the following keys are identical to the ones mentioned on your bank's letter:" + ); + frappe.confirm( + `

${message}

+
${bank_keys}
`, + async () => { + await confirm_bank_keys(frm.doc.name); + frm.reload_doc(); + } + ); + }, + frm.doc.bank_keys_activated ? __("Actions") : null + ); + } + + if (frm.doc.initialized && frm.doc.bank_keys_activated) { + frm.add_custom_button(__("Download Bank Statements"), () => { + download_bank_statements(frm.doc.name, !frm.doc.passphrase); + }); + } + }, +}); + +async function get_bank_keys(ebics_user) { + try { + return await frappe.xcall( + "banking.ebics.doctype.ebics_user.ebics_user.download_bank_keys", + { ebics_user: ebics_user } + ); + } catch (e) { + frappe.show_alert({ + message: e || __("An error occurred"), + indicator: "red", + }); + } +} + +async function confirm_bank_keys(ebics_user) { + try { + await frappe.xcall( + "banking.ebics.doctype.ebics_user.ebics_user.confirm_bank_keys", + { ebics_user: ebics_user } + ); + frappe.show_alert({ + message: __("Bank keys confirmed"), + indicator: "green", + }); + } catch (e) { + frappe.show_alert({ + message: e || __("An error occurred"), + indicator: "red", + }); + } +} + +function download_bank_statements(ebics_user, needs_passphrase) { + const fields = [ + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.now_date(), + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.now_date(), + }, + ]; + + if (needs_passphrase) { + fields.push({ + fieldname: "passphrase", + label: __("Passphrase"), + fieldtype: "Password", + reqd: true + }); + } + + frappe.prompt( + fields, + async (values) => { + try { + await frappe.xcall( + "banking.ebics.doctype.ebics_user.ebics_user.download_bank_statements", + { + ebics_user: ebics_user, + from_date: values.from_date, + to_date: values.to_date, + passphrase: values.passphrase, + } + ); + frappe.show_alert({ + message: __("Bank statements are being downloaded in the background."), + indicator: "blue", + }); + } catch (e) { + frappe.show_alert({ + message: e || __("An error occurred"), + indicator: "red", + }); + } + }, + __("Download Bank Statements") + ); +} diff --git a/banking/ebics/doctype/ebics_user/ebics_user.json b/banking/ebics/doctype/ebics_user/ebics_user.json new file mode 100644 index 00000000..d1461624 --- /dev/null +++ b/banking/ebics/doctype/ebics_user/ebics_user.json @@ -0,0 +1,161 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-06 16:28:04.926015", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "full_name", + "start_date", + "initialized", + "bank_keys_activated", + "column_break_hlxa", + "company", + "country", + "bank", + "section_break_qjlc", + "partner_id", + "user_id", + "needs_certificates", + "section_break_juzm", + "passphrase", + "keyring" + ], + "fields": [ + { + "fieldname": "full_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Full Name", + "mandatory_depends_on": "needs_certificates" + }, + { + "fieldname": "column_break_hlxa", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "mandatory_depends_on": "needs_certificates", + "options": "Company" + }, + { + "fieldname": "country", + "fieldtype": "Link", + "label": "Country", + "mandatory_depends_on": "needs_certificates", + "options": "Country" + }, + { + "description": "Please enter the values provided by your bank.", + "fieldname": "section_break_qjlc", + "fieldtype": "Section Break" + }, + { + "fieldname": "partner_id", + "fieldtype": "Data", + "label": "Partner ID" + }, + { + "fieldname": "user_id", + "fieldtype": "Data", + "label": "User ID" + }, + { + "fieldname": "bank", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Bank", + "mandatory_depends_on": "needs_certificates", + "options": "Bank" + }, + { + "default": "0", + "description": "Enable this for EBICS accounts whose key management is based on certificates (eg. French banks).", + "fieldname": "needs_certificates", + "fieldtype": "Check", + "label": "Needs Certificate" + }, + { + "default": "0", + "fieldname": "initialized", + "fieldtype": "Check", + "label": "Initialized", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "fieldname": "bank_keys_activated", + "fieldtype": "Check", + "label": "Bank Keys Activated", + "no_copy": 1, + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_juzm", + "fieldtype": "Section Break", + "label": "Credentials" + }, + { + "fieldname": "keyring", + "fieldtype": "Code", + "label": "Keyring", + "no_copy": 1, + "read_only": 1 + }, + { + "description": "Enter your password to enable automated, regular syncing. Leave blank if you prefer to sync manually.", + "fieldname": "passphrase", + "fieldtype": "Password", + "label": "Passphrase", + "no_copy": 1 + }, + { + "description": "Bank transactions that happened before this date must not be imported.", + "fieldname": "start_date", + "fieldtype": "Date", + "label": "Start Date" + } + ], + "links": [], + "modified": "2024-11-13 23:08:45.591377", + "modified_by": "Administrator", + "module": "EBICS", + "name": "EBICS User", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "full_name,bank,company", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "full_name" +} \ No newline at end of file diff --git a/banking/ebics/doctype/ebics_user/ebics_user.py b/banking/ebics/doctype/ebics_user/ebics_user.py new file mode 100644 index 00000000..cc6c5ae0 --- /dev/null +++ b/banking/ebics/doctype/ebics_user/ebics_user.py @@ -0,0 +1,175 @@ +# Copyright (c) 2024, ALYF GmbH and contributors +# For license information, please see license.txt +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import get_link_to_form + +from banking.ebics.utils import get_ebics_manager, sync_ebics_transactions +from banking.klarna_kosma_integration.admin import Admin +from requests import HTTPError + + +class EBICSUser(Document): + def validate(self): + if self.country: + self.validate_country_code() + + if self.bank: + self.validate_bank() + + def before_insert(self): + self.register_user() + + def on_update(self): + self.register_user() + + def on_trash(self): + self.remove_user() + + def register_user(self): + """Indempotent method to register the user with the admin backend.""" + host_id = frappe.db.get_value("Bank", self.bank, "ebics_host_id") + try: + r = Admin().request.register_ebics_user(host_id, self.partner_id, self.user_id) + r.raise_for_status() + except HTTPError as e: + if e.response.status_code == 402: + # User already exists for this customer + return + elif e.response.status_code == 403: + title = _("Banking Error") + msg = _("EBICS User limit exceeded.") + frappe.log_error(title=_("Banking Error"), message=msg) + frappe.throw(title=title, msg=msg) + elif e.response.status_code == 409: + title = _("Banking Error") + msg = _("User ID not available.") + frappe.log_error(title=_("Banking Error"), message=msg) + frappe.throw(title=title, msg=msg) + + def remove_user(self): + """Indempotent method to remove the user from the admin backend.""" + host_id = frappe.db.get_value("Bank", self.bank, "ebics_host_id") + try: + r = Admin().request.remove_ebics_user(host_id, self.partner_id, self.user_id) + r.raise_for_status() + except HTTPError: + title = _("Failed to remove EBICS user registration.") + frappe.log_error(title=title) + frappe.throw(title) + + def validate_country_code(self): + country_code = frappe.db.get_value("Country", self.country, "code") + if not country_code or len(country_code) != 2: + frappe.throw( + _("Please add a two-letter country code to country {0}").format( + get_link_to_form("Country", self.country) + ) + ) + + def validate_bank(self): + host_id, url = frappe.db.get_value("Bank", self.bank, ["ebics_host_id", "ebics_url"]) + if not host_id or not url: + frappe.throw( + _("Please add EBICS Host ID and URL to bank {0}").format( + get_link_to_form("Bank", self.bank) + ) + ) + + def attach_ini_letter(self, pdf_bytes: bytes): + file = frappe.new_doc("File") + file.file_name = f"ini_letter_{self.name}.pdf" + file.attached_to_doctype = self.doctype + file.attached_to_name = self.name + file.is_private = 1 + file.content = pdf_bytes + file.save() + + def store_keyring(self, keys: dict): + self.db_set("keyring", json.dumps(keys, indent=2)) + + def get_keyring(self) -> dict: + return json.loads(self.keyring) if self.keyring else {} + + +def on_doctype_update(): + frappe.db.add_unique( + "EBICS User", ["bank", "partner_id", "user_id"], constraint_name="unique_ebics_user" + ) + + +@frappe.whitelist() +def initialize( + ebics_user: str, passphrase: str, signature_passphrase: str, store_passphrase: int +): + user = frappe.get_doc("EBICS User", ebics_user) + user.check_permission("write") + + if store_passphrase: + user.passphrase = passphrase + user.save() + + manager = get_ebics_manager( + ebics_user=user, passphrase=passphrase, sig_passphrase=signature_passphrase + ) + + try: + manager.create_user_keys() + except RuntimeError as e: + if e.args[0] != "keys already present": + raise e + + if user.needs_certificates: + country_code = frappe.db.get_value("Country", user.country, "code") + manager.create_user_certificates(user.full_name, user.company, country_code.upper()) + + manager.send_keys_to_bank() + + bank_name = frappe.db.get_value("Bank", user.bank, "bank_name") + ini_bytes = manager.create_ini_letter(bank_name, language=frappe.local.lang) + user.attach_ini_letter(ini_bytes) + user.db_set("initialized", 1) + + +@frappe.whitelist() +def download_bank_keys(ebics_user: str): + user = frappe.get_doc("EBICS User", ebics_user) + user.check_permission("write") + + manager = get_ebics_manager(user) + + return manager.download_bank_keys() + + +@frappe.whitelist() +def confirm_bank_keys(ebics_user: str): + user = frappe.get_doc("EBICS User", ebics_user) + user.check_permission("write") + + manager = get_ebics_manager(user) + manager.activate_bank_keys() + user.db_set("bank_keys_activated", 1) + + +@frappe.whitelist() +def download_bank_statements( + ebics_user: str, + from_date: str | None = None, + to_date: str | None = None, + passphrase: str | None = None, +): + frappe.has_permission("Bank Transaction", "create", throw=True) + + user = frappe.get_doc("EBICS User", ebics_user) + user.check_permission("read") + + frappe.enqueue( + sync_ebics_transactions, + ebics_user=ebics_user, + start_date=from_date, + end_date=to_date, + passphrase=passphrase, + ) diff --git a/banking/ebics/doctype/ebics_user/test_ebics_user.py b/banking/ebics/doctype/ebics_user/test_ebics_user.py new file mode 100644 index 00000000..f5403b7c --- /dev/null +++ b/banking/ebics/doctype/ebics_user/test_ebics_user.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, ALYF GmbH and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestEBICSUser(FrappeTestCase): + pass diff --git a/banking/ebics/manager.py b/banking/ebics/manager.py new file mode 100644 index 00000000..10b06932 --- /dev/null +++ b/banking/ebics/manager.py @@ -0,0 +1,113 @@ +import fintech +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Iterator, Callable + from banking.ebics.types import ( + EbicsKeyRing, + EbicsUser, + EbicsBank, + EbicsClient, + CAMTDocument, + ) + + +class EBICSManager: + __slots__ = ["keyring", "user", "bank"] + + def __init__( + self, + license_name: str, + license_key: str, + ): + try: + fintech.register( + name=license_name, + keycode=license_key, + ) + except RuntimeError as e: + if e.args[0] != "'register' can be called only once": + raise e + + def set_keyring( + self, keys: dict, save_to_db: "Callable", sig_passphrase: str, passphrase: str | None + ): + from fintech.ebics import EbicsKeyRing + + class CustomKeyRing(EbicsKeyRing): + def _write(self, keydict): + save_to_db(keydict) + + self.keyring: "EbicsKeyRing" = CustomKeyRing( + keys=keys, + passphrase=passphrase, + sig_passphrase=sig_passphrase, + ) + + def set_user(self, partner_id: str, user_id: str): + from fintech.ebics import EbicsUser + + self.user: "EbicsUser" = EbicsUser( + keyring=self.keyring, partnerid=partner_id, userid=user_id, transport_only=True + ) + + def set_bank(self, host_id: str, url: str): + from fintech.ebics import EbicsBank + + self.bank: "EbicsBank" = EbicsBank(keyring=self.keyring, hostid=host_id, url=url) + + def create_user_keys(self): + self.user.create_keys(keyversion="A005", bitlength=2048) + + def create_user_certificates( + self, user_name: str, organization_name: str, country_code: str + ): + self.user.create_certificates( + commonName=user_name, + organizationName=organization_name, + countryName=country_code, + ) + + def get_client(self) -> "EbicsClient": + from fintech.ebics import EbicsClient + + return EbicsClient(self.bank, self.user) + + def send_keys_to_bank(self): + client = self.get_client() + # Send the public electronic signature key to the bank. + client.INI() + # Send the public authentication and encryption keys to the bank. + client.HIA() + + def create_ini_letter(self, bank_name: str, language: str | None = None) -> bytes: + """Return the PDF data as byte string.""" + return self.user.create_ini_letter( + bankname=bank_name, + lang=language, + ) + + def download_bank_keys(self): + client = self.get_client() + return client.HPB() + + def activate_bank_keys(self) -> None: + self.bank.activate_keys() + + def download_bank_statements( + self, start_date: str | None = None, end_date: str | None = None + ) -> "Iterator[CAMTDocument]": + """Yield an iterator over CAMTDocument objects for the given date range.""" + from fintech.sepa import CAMTDocument + + client = self.get_client() + camt53 = client.C53(start_date, end_date) + try: + camt54 = client.C54(start_date, end_date) + except fintech.ebics.EbicsTechnicalError as e: + camt54 = None + + for name in sorted(camt53): + yield CAMTDocument(xml=camt53[name], camt54=camt54) + + client.confirm_download(success=True) diff --git a/banking/ebics/prompt.md b/banking/ebics/prompt.md new file mode 100644 index 00000000..278c7e06 --- /dev/null +++ b/banking/ebics/prompt.md @@ -0,0 +1,37 @@ +Please use the following documentation to create a python class, with "..." as the implementation of each method/property. Please add type hints for each parameter, if available. Please add the docstring for each method/property. Use the original wording and do not omit any details. Please use modern python types, in the style of `list[str]`, or `str | dict`, etc. Do not use Union, List, Optional, etc. + +For example: + +```python +class EbicsClient: + """Main EBICS client class.""" + def __init__(self, bank: 'EbicsBank', user: 'EbicsUser' | list['EbicsUser']): + """Initializes the EBICS client instance. + + Parameters: + * bank - An instance of EbicsBank. + * user – An instance of EbicsUser. + """ + ... + + def BTD(self, btf: 'BusinessTransactionFormat', start: str | date | None = None, end: str | date | None = None, **params): + """Downloads data with EBICS protocol version 3.0 (H005). + + Parameters: + * btf - Instance of BusinessTransactionFormat. + * start - Start date of requested transactions, either as a date object or ISO8601 string. + * end - End date of requested transactions, either as a date object or ISO8601 string. + * params: Additional custom order parameters for the request. + + Returns: The requested file data.""" + ... + + @property + def last_trans_id(self) -> str: + """This attribute stores the transaction id of the last download process (read-only).""" + ... +``` + +--- + +[PASTE DOCUMENTATION HERE] diff --git a/banking/ebics/types.py b/banking/ebics/types.py new file mode 100644 index 00000000..6db53f4e --- /dev/null +++ b/banking/ebics/types.py @@ -0,0 +1,1786 @@ +"""With friendly permission of joonis new media, the official documentation at +https://www.joonis.de/en/fintech/doc/ was used to create the docstrings for this file. + +Copyright © 2024 by joonis new media - All rights reserved. +""" + +from datetime import date +from decimal import Decimal +from typing import Any, Iterator + + +class Amount: + """The Amount class with an integrated currency converter. + + Arithmetic operations can be performed directly on this object. + """ + + default_currency: str = "EUR" + exchange_rates: dict[str, Decimal] = {} + + def __init__(self, value: Decimal, currency: str | None = None): + """Initializes the Amount instance. + + Parameters: + * value - The amount value. + * currency - An ISO-4217 currency code. If not specified, it is set to the value of the class attribute default_currency which is initially set to EUR. + """ + ... + + def convert(self, currency: str) -> "Amount": + """Converts the amount to another currency on the basis of the current exchange rates provided by the European Central Bank. The exchange rates are automatically updated once a day and cached in memory for further usage. + + Parameters: + * currency - The ISO-4217 code of the target currency. + + Returns: An Amount object in the requested currency. + """ + ... + + @property + def currency(self) -> str: + """The ISO-4217 currency code.""" + ... + + @property + def decimals(self) -> int: + """The number of decimal places (at least 2). Use the built-in round to adjust the decimal places.""" + ... + + @classmethod + def update_exchange_rates(cls) -> bool: + """Updates the exchange rates based on the data provided by the European Central Bank and stores it in the class attribute exchange_rates. Usually it is not required to call this method directly, since it is called automatically by the method convert(). + + Returns: A boolean flag whether updated exchange rates were available or not. + """ + ... + + @property + def value(self) -> Decimal: + """The amount value of type decimal.Decimal.""" + ... + + +class Mandate: + """SEPA mandate class.""" + + def __init__(self, path: str): + """Initializes the SEPA mandate instance. + + Parameters: + * path - The path to a SEPA PDF file. + """ + ... + + @property + def b2b(self) -> bool: + """Flag if it is a B2B mandate (read-only).""" + ... + + @property + def cid(self) -> str: + """The creditor id (read-only).""" + ... + + @property + def closed(self) -> bool: + """Flag if the mandate is closed (read-only).""" + ... + + @property + def created(self) -> str: + """The creation date (read-only).""" + ... + + @property + def creditor(self) -> str | dict: + """The creditor account (read-only).""" + ... + + @property + def debtor(self) -> str | dict: + """The debtor account (read-only).""" + ... + + @property + def executed(self) -> str | None: + """The last execution date (read-only).""" + ... + + def is_valid(self) -> bool: + """Checks if this SEPA mandate is still valid.""" + ... + + @property + def modified(self) -> str: + """The last modification date (read-only).""" + ... + + @property + def mref(self) -> str: + """The mandate reference (read-only).""" + ... + + @property + def pdf_path(self) -> str: + """The path to the PDF file (read-only).""" + ... + + @property + def recurrent(self) -> bool: + """Flag whether this mandate is recurrent or not.""" + ... + + @property + def signed(self) -> str: + """The date of signature (read-only).""" + ... + + +class Account: + """Account class for managing SEPA-related account information.""" + + def __init__( + self, + iban: str | tuple[str, str], + name: str, + country: str | None = None, + city: str | None = None, + postcode: str | None = None, + street: str | None = None, + ): + """Initializes the account instance. + + Parameters: + * iban - Either the IBAN or a 2-tuple in the form of either (IBAN, BIC) or (ACCOUNT_NUMBER, BANK_CODE). The latter will be converted to the corresponding IBAN automatically. An IBAN is checked for validity. + * name - The name of the account holder. + * country - The country (ISO-3166 ALPHA 2) of the account holder (optional). + * city - The city of the account holder (optional). + * postcode - The postcode of the account holder (optional). + * street - The street of the account holder (optional). + """ + ... + + @property + def address(self) -> tuple[str, ...]: + """Tuple of unstructured address lines (read-only).""" + ... + + @property + def bic(self) -> str: + """The BIC of this account (read-only).""" + ... + + @property + def cid(self) -> str: + """The creditor id of the account holder (readonly).""" + ... + + @property + def city(self) -> str: + """The city of the account holder (read-only).""" + ... + + @property + def country(self) -> str: + """The country of the account holder (read-only).""" + ... + + @property + def cuc(self) -> str: + """The CBI unique code (CUC) of the account holder (readonly).""" + ... + + @property + def iban(self) -> str: + """The IBAN of this account (read-only).""" + ... + + def is_sepa(self) -> bool: + """Checks if this account seems to be valid within the Single Euro Payments Area.""" + ... + + @property + def mandate(self) -> Mandate: + """The assigned mandate (read-only).""" + ... + + @property + def name(self) -> str: + """The name of the account holder (read-only).""" + ... + + @property + def postcode(self) -> str: + """The postcode of the account holder (read-only).""" + ... + + def set_mandate( + self, mref: str, signed: str | date, recurrent: bool = False + ) -> Mandate: + """Sets the SEPA mandate for this account. + + Parameters: + * mref - The mandate reference. + * signed - The date of signature. Can be a date object or an ISO8601 formatted string. + * recurrent - Flag whether this is a recurrent mandate or not. + + Returns: A Mandate object. + """ + ... + + def set_originator_id(self, cid: str | None = None, cuc: str | None = None) -> None: + """Sets the originator id of the account holder. + + Parameters: + * cid - The SEPA creditor id. Required for direct debits and in some countries also for credit transfers. + * cuc - The CBI unique code (only required in Italy). + """ + ... + + def set_ultimate_name(self, name: str) -> None: + """Sets the ultimate name used for SEPA transactions and by the MandateManager. + + Parameters: + * name - The ultimate name for SEPA transactions. + """ + ... + + @property + def street(self) -> str: + """The street of the account holder (read-only).""" + ... + + @property + def ultimate_name(self) -> str: + """The ultimate name used for SEPA transactions.""" + ... + + +class SEPATransaction: + """The SEPATransaction class. + + This class cannot be instantiated directly. An instance is returned by the method + add_transaction() of a SEPA document instance or by the iterator of a CAMTDocument instance. + + If it is a batch of other transactions, the instance can be treated as an iterable + over all underlying transactions. + """ + + @property + def address(self) -> tuple[str, str]: + """A tuple which holds the address of the remote account holder.""" + ... + + @property + def amount(self) -> "Amount": + """The transaction amount of type Amount. Debits are always signed negative.""" + ... + + @property + def bank_reference(self) -> str: + """The bank reference, used to uniquely identify a transaction.""" + ... + + @property + def batch(self) -> bool: + """Flag which indicates a batch transaction.""" + ... + + @property + def bic(self) -> str: + """The BIC of the remote account (BIC).""" + ... + + @property + def camt_reference(self) -> str: + """The reference to a CAMT file.""" + ... + + @property + def cheque(self) -> str: + """The cheque number.""" + ... + + @property + def classification(self) -> str | tuple[str, str, str, str]: + """The transaction classification. + + For German banks it is a tuple in the form of (SWIFTCODE, GVC, PRIMANOTA, TEXTKEY), + for French banks a tuple in the form of (DOMAINCODE, FAMILYCODE, SUBFAMILYCODE, TRANSACTIONCODE), + otherwise a plain string. + """ + ... + + @property + def country(self) -> str: + """The country of the remote account holder.""" + ... + + @property + def date(self) -> str | date: + """The booking date or appointed due date.""" + ... + + @property + def eref(self) -> str: + """The end-to-end reference (EREF).""" + ... + + def get_account(self) -> Account: + """Returns an Account instance of the remote account.""" + ... + + @property + def iban(self) -> str: + """The IBAN of the remote account (IBAN).""" + ... + + @property + def info(self) -> str: + """The transaction information (BOOKINGTEXT).""" + ... + + @property + def kref(self) -> str: + """The id of the logical PAIN file (KREF).""" + ... + + @property + def mref(self) -> str: + """The mandate reference (MREF).""" + ... + + @property + def msgid(self) -> str: + """The message id of the physical PAIN file.""" + ... + + @property + def name(self) -> str: + """The name of the remote account holder.""" + ... + + @property + def originator_id(self) -> str: + """The creditor or debtor id of the remote account (CRED/DEBT).""" + ... + + @property + def purpose(self) -> tuple[str, ...]: + """A tuple of the transaction purpose (SVWZ).""" + ... + + @property + def purpose_code(self) -> str: + """The external purpose code (PURP).""" + ... + + @property + def return_info(self) -> tuple[str, str]: + """A tuple of return code and reason.""" + ... + + @property + def reversal(self) -> bool: + """The reversal indicator.""" + ... + + @property + def status(self) -> str: + """The transaction status. A value of INFO, PDNG or BOOK.""" + ... + + @property + def ultimate_name(self) -> str: + """The ultimate name of the remote account (ABWA/ABWE).""" + ... + + @property + def valuta(self) -> str | date: + """The value date.""" + ... + + +class SEPACreditTransfer: + """ + SEPACreditTransfer class for creating SEPA credit transfer documents. + """ + + def __init__( + self, + account: Account, + type: str = "NORM", + cutoff: int = 14, + batch: bool = True, + cat_purpose: str | None = None, + scheme: str | None = None, + currency: str | None = None, + ): + """Initializes the SEPA credit transfer instance. + + Parameters: + * account – The local debtor account. + * type – The credit transfer priority ('NORM', 'HIGH', 'URGP', or 'INST'). + * cutoff – The cut-off time of the debtor’s bank. + * batch – Flag whether SEPA batch mode is enabled. + * cat_purpose – The SEPA category purpose code (optional). + * scheme – The PAIN scheme of the document (optional). + * currency – The ISO-4217 code of the currency to use (optional). + """ + ... + + @property + def account(self) -> Account: + """The local account (read-only).""" + ... + + def add_transaction( + self, + account: Account, + amount: float | "Amount", + purpose: str | tuple[str, str], + eref: str | None = None, + ext_purpose: str | None = None, + due_date: str | int | "date" | None = None, + ) -> SEPATransaction: + """Adds a transaction to the SEPACreditTransfer document. + + Parameters: + * account – The remote creditor account. + * amount – The transaction amount as a floating point number or an instance of Amount. + * purpose – The transaction purpose text or structured reference. + * eref – The end-to-end reference (optional). + * ext_purpose – The SEPA external purpose code (optional). + * due_date – The due date for the transaction (optional). + + Returns: A SEPATransaction instance. + """ + ... + + @property + def batch(self) -> bool: + """Flag if batch mode is enabled (read-only).""" + ... + + @property + def cat_purpose(self) -> str | None: + """The category purpose (read-only).""" + ... + + @property + def currency(self) -> str: + """The ISO-4217 currency code (read-only).""" + ... + + @property + def cutoff(self) -> int: + """The cut-off time of the local bank (read-only).""" + ... + + @property + def message_id(self) -> str: + """The message id of this document (read-only).""" + ... + + def new_batch(self, kref: str | None = None) -> None: + """Adds additional transactions to a new batch (PmtInf block). + + Parameters: + * kref – Custom KREF (PmtInfId) for the new batch (optional). + """ + ... + + def render(self) -> str: + """Renders the SEPACreditTransfer document and returns it as XML. + + Returns: The SEPACreditTransfer document in XML format. + """ + ... + + @property + def scheme(self) -> str: + """The document scheme version (read-only).""" + ... + + @property + def scl_check(self) -> bool: + """Flag whether remote accounts should be verified against the SEPA Clearing Directory (read-only).""" + ... + + def send(self, ebics_client: "EbicsClient", use_ful: bool | None = None) -> str: + """Sends the SEPA document using the passed EBICS instance. + + Parameters: + * ebics_client – The fintech.ebics.EbicsClient instance. + * use_ful – Flag for using the fintech.ebics.EbicsClient.FUL() order type for uploading (optional). + + Returns: The EBICS order id. + """ + ... + + @property + def type(self) -> str: + """The credit transfer priority type (read-only).""" + ... + + +class SEPADirectDebit: + """SEPADirectDebit class""" + + def __init__( + self, + account: str, + type: str = "CORE", + cutoff: int = 36, + batch: bool = True, + cat_purpose: str | None = None, + scheme: str | None = None, + currency: str | None = None, + ): + """ + Initializes the SEPA direct debit instance. + + Parameters: + * account – The local creditor account with an appointed creditor id. + * type – The direct debit type (CORE or B2B). + * cutoff – The cut-off time of the creditor’s bank. + * batch – Flag if SEPA batch mode is enabled or not. + * cat_purpose – The SEPA category purpose code. This code is used for special treatments by the local bank and is not forwarded to the remote bank. See module attribute CATEGORY_PURPOSE_CODES for possible values. + * scheme – The PAIN scheme of the document. If not specified, the scheme is set to pain.008.001.02. In Switzerland it is set to pain.008.001.02.ch.01, in Italy to CBISDDReqLogMsg.00.01.00. + * currency – The ISO-4217 code of the currency to use. It must match with the currency of the local account. If not specified, it defaults to the currency of the country the local IBAN belongs to. + """ + ... + + @property + def account(self) -> str: + """The local account (read-only).""" + ... + + def add_transaction( + self, + account: str, + amount: float, + purpose: str | tuple[str, str], + eref: str | None = None, + ext_purpose: str | None = None, + due_date: int | str | None = None, + ) -> "SEPATransaction": + """ + Adds a transaction to the SEPADirectDebit document. If scl_check is set to True, it is verified that the transaction can be routed to the target bank. + + Parameters: + * account – The remote debtor account with a valid mandate. + * amount – The transaction amount as floating point number or an instance of Amount. In the latter case the currency must match the currency of the document. + * purpose – The transaction purpose text. If the value matches a valid ISO creditor reference number (starting with “RF…”), it is added as a structured reference. For other structured references a tuple can be passed in the form of (REFERENCE_NUMBER, PURPOSE_TEXT). + * eref – The end-to-end reference (optional). + * ext_purpose – The SEPA external purpose code (optional). This code is forwarded to the remote bank and the account holder. See module attribute EXTERNAL_PURPOSE_CODES for possible values. + * due_date – The due date. If it is an integer or None, the next possible date is calculated starting from today plus the given number of days (considering holidays, the lead time and the given cut-off time). If it is a date object or an ISO8601 formatted string, this date is used without further validation. + + Returns: A SEPATransaction instance. + """ + ... + + @property + def batch(self) -> bool: + """Flag if batch mode is enabled (read-only).""" + ... + + @property + def cat_purpose(self) -> str | None: + """The category purpose (read-only).""" + ... + + @property + def currency(self) -> str: + """The ISO-4217 currency code (read-only).""" + ... + + @property + def cutoff(self) -> int: + """The cut-off time of the local bank (read-only).""" + ... + + @property + def message_id(self) -> str: + """The message id of this document (read-only).""" + ... + + def new_batch(self, kref: str | None = None): + """ + After calling this method additional transactions are added to a new batch (PmtInf block). This could be useful if you want to divide transactions into different batches with unique KREF ids. + + Parameters: + * kref – It is possible to set a custom KREF (PmtInfId) for the new batch (new in v7.2). Be aware that KREF ids should be unique over time and that all transactions must be grouped by particular SEPA specifications (date, sequence type, etc.) into separate batches. This is done automatically if you do not pass a custom KREF. + """ + ... + + def render(self) -> str: + """Renders the SEPADirectDebit document and returns it as XML.""" + ... + + @property + def scheme(self) -> str: + """The document scheme version (read-only).""" + ... + + @property + def scl_check(self) -> bool: + """ + Flag whether remote accounts should be verified against the SEPA Clearing Directory or not. The initial value is set to True if the kontocheck library is available and the local account is originated in Germany, otherwise it is set to False. + """ + ... + + def send(self, ebics_client: "EbicsClient", use_ful: bool | None = None) -> str: + """ + Sends the SEPA document using the passed EBICS instance. + + Parameters: + * ebics_client – The fintech.ebics.EbicsClient instance. + * use_ful – Flag, whether to use the order type fintech.ebics.EbicsClient.FUL() for uploading the document or one of the suitable order types fintech.ebics.EbicsClient.CCT(), fintech.ebics.EbicsClient.CDD() or fintech.ebics.EbicsClient.CDB(). If not specified, use_ful is set to False if the local account is originated in Germany, otherwise it is set to True. With EBICS v3.0 the document is always uploaded via fintech.ebics.EbicsClient.BTU(). + + Returns: The EBICS order id. + """ + ... + + @property + def type(self) -> str: + """The direct debit type (read-only).""" + ... + + +class EbicsKeyRing: + """EBICS key ring representation. + + An EbicsKeyRing instance can hold sets of private user keys and/or public bank keys. + Private user keys are always stored AES encrypted by the specified passphrase (derivated by PBKDF2). + For each key file on disk or same key dictionary, a singleton instance is created. + """ + + def __init__( + self, + keys: str | dict, + passphrase: str | None = None, + sig_passphrase: str | None = None, + ): + """Initializes the EBICS key ring instance. + + Parameters: + * keys - The path to a key file or a dictionary of keys. If keys is a path and the key file does not exist, it will be created as soon as keys are added. If keys is a dictionary, all changes are applied to this dictionary and the caller is responsible to store the modifications. Key files from previous PyEBICS versions are automatically converted to a new format. + * passphrase - The passphrase by which all private keys are encrypted/decrypted. + * sig_passphrase - A different passphrase for the signature key (optional). Useful if you want to store the passphrase to automate downloads while preventing uploads without user interaction. (New since v7.3) + """ + ... + + def change_passphrase( + self, passphrase: str | None = None, sig_passphrase: str | None = None + ) -> None: + """Changes the passphrase by which all private keys are encrypted. + + If a passphrase is omitted, it is left unchanged. The key ring is automatically updated and saved. + + Parameters: + * passphrase - The new passphrase. + * sig_passphrase - The new signature passphrase. (New since v7.3) + """ + ... + + @property + def keyfile(self) -> str: + """The path to the key file (read-only).""" + ... + + @property + def pbkdf_iterations(self) -> int: + """The number of iterations to derivate the passphrase by the PBKDF2 algorithm. + + Initially it is set to a number that requires an approximate run time of 50 ms to perform the derivation function. + """ + ... + + def save(self, path: str | None = None) -> None: + """Saves all keys to the file specified by path. + + Usually, it is not necessary to call this method, since most modifications are stored automatically. + + Parameters: + * path - The path of the key file. If path is not specified, the path of the current key file is used. + """ + ... + + def set_pbkdf_iterations( + self, iterations: int = 10000, duration: float | None = None + ) -> int: + """Sets the number of iterations which is used to derivate the passphrase by the PBKDF2 algorithm. + + The optimal number depends on the performance of the underlying system and the use case. + + Parameters: + * iterations - The minimum number of iterations to set. + * duration - The target run time in seconds to perform the derivation function. A higher value results in a higher number of iterations. + + Returns: The specified or calculated number of iterations, whatever is higher. + """ + ... + + +class EbicsBank: + """EBICS bank representation.""" + + def __init__(self, keyring: "EbicsKeyRing", hostid: str, url: str): + """Initializes the EBICS bank instance. + + Parameters: + * keyring - An EbicsKeyRing instance. + * hostid - The HostID of the bank. + * url - The URL of the EBICS server. + """ + ... + + def activate_keys(self, fail_silently: bool = False) -> None: + """Activates the bank keys downloaded via EbicsClient.HPB(). + + Parameters: + * fail_silently - Flag whether to throw a RuntimeError if there exists no key to activate. + """ + ... + + def export_keys(self) -> dict[str, str]: + """Exports the bank keys in PEM format. + + Returns: A dictionary with pairs of key version and PEM encoded public key. + """ + ... + + def get_protocol_versions(self) -> dict[str, str]: + """Returns a dictionary of supported EBICS protocol versions. Same as calling EbicsClient.HEV().""" + ... + + @property + def hostid(self) -> str: + """The HostID of the bank (read-only).""" + ... + + @property + def keyring(self) -> "EbicsKeyRing": + """The EbicsKeyRing instance (read-only).""" + ... + + @property + def url(self) -> str: + """The URL of the EBICS server (read-only).""" + ... + + +class EbicsUser: + """EBICS user representation.""" + + def __init__( + self, + keyring: EbicsKeyRing, + partnerid: str, + userid: str, + systemid: str | None = None, + transport_only: bool = False, + ): + """Initializes the EBICS user instance. + + Parameters: + * keyring - An EbicsKeyRing instance. + * partnerid - The assigned PartnerID (Kunden-ID). + * userid - The assigned UserID (Teilnehmer-ID). + * systemid - The assigned SystemID (usually unused). + * transport_only - Flag if the user has permission T (EBICS T). New since v7.4. + """ + ... + + def create_certificates( + self, validity_period: int = 5, **x509_dn: dict[str, str] + ) -> list[str]: + """Generates self-signed certificates for all keys that still lack a certificate and adds them to the key ring. + May only be used for EBICS accounts whose key management is based on certificates (eg. French banks). + + Parameters: + * validity_period - The validity period in years. + * x509_dn - Keyword arguments representing Distinguished Names for creating self-signed certificates. Possible arguments include: + - commonName [CN] + - organizationName [O] + - organizationalUnitName [OU] + - countryName [C] + - stateOrProvinceName [ST] + - localityName [L] + - emailAddress + + Returns: A list of key versions for which a new certificate was created (new since v6.4). + """ + ... + + def create_ini_letter( + self, bankname: str, path: str | None = None, lang: str | None = None + ) -> bytes: + """Creates the INI-letter as PDF document. + + Parameters: + * bankname - The name of the bank printed on the INI-letter as the recipient. New in v7.5.1: If bankname matches a BIC and the kontockeck package is installed, + the SCL directory is queried for the bank name. + * path - The destination path of the created PDF file. If not specified, the PDF will not be saved. + * lang - ISO 639-1 language code of the INI-letter to create. Defaults to the system locale language. (New in v7.5.1: If bankname matches a BIC, + it first tries to get the language from the country code of the BIC). + + Returns: The PDF data as byte string if path is None. + """ + ... + + def create_keys(self, keyversion: str = "A006", bitlength: int = 2048) -> list[str]: + """Generates all missing keys required for a new EBICS user. The key ring is automatically updated and saved. + + Parameters: + * keyversion - The key version of the electronic signature. Supported versions are A005 (based on RSASSA-PKCS1-v1_5) and A006 (based on RSASSA-PSS). + * bitlength - The bit length of the generated keys, must be between 2048 and 4096 (default is 2048). + + Returns: A list of created key versions (new since v6.4). + """ + ... + + def export_certificates(self) -> dict[str, list[str]]: + """Exports the user certificates in PEM format. + + Returns: A dictionary with pairs of key version and a list of PEM-encoded certificates (the certificate chain). + """ + ... + + def export_keys(self, passphrase: str, pkcs: int = 8) -> dict[str, str]: + """Exports the user keys in encrypted PEM format. + + Parameters: + * passphrase - The passphrase by which all keys are encrypted. The encryption algorithm depends on the used cryptography library. + * pkcs - The PKCS version. An integer of either 1 or 8. + + Returns: A dictionary with pairs of key version and PEM-encoded private key. + """ + ... + + def import_certificates(self, **certs: dict[str, bytes | list[bytes]]) -> None: + """Imports certificates from a set of keyword arguments. It verifies that the certificates match the existing keys. + If a signature key is missing, the public key is added from the certificate. The key ring is automatically updated and saved. + May only be used for EBICS accounts whose key management is based on certificates (eg. French banks). + + Parameters: + * certs - Keyword arguments represent the different certificates to import. The keyword name represents the key version assigned to it. + The value can be a byte string of the certificate or a list of byte strings (certificate chain), either in DER or PEM format. + Supported keywords are: A006, A005, X002, E002. + """ + ... + + def import_keys(self, passphrase: str | None = None, **keys: dict[str, bytes]) -> None: + """Imports private user keys from a set of keyword arguments. The key ring is automatically updated and saved. + + Parameters: + * passphrase - The passphrase if the keys are encrypted. Only DES or 3TDES encrypted keys are supported. + * keys - Keyword arguments representing the private keys to import. The keyword name represents the key version, and the value is the byte string + of the corresponding key, either in DER or PEM format (PKCS#1 or PKCS#8). Supported keys include: + - A006: The signature key (RSASSA-PSS) + - A005: The signature key (RSASSA-PKCS1-v1_5) + - X002: The authentication key + - E002: The encryption key + """ + ... + + @property + def keyring(self) -> "EbicsKeyRing": + """The EbicsKeyRing instance (read-only).""" + ... + + @property + def manual_approval(self) -> bool: + """If uploaded orders are approved manually via accompanying document, this property must be set to True. Deprecated, use class parameter transport_only instead.""" + ... + + @property + def partnerid(self) -> str: + """The PartnerID of the EBICS account (read-only).""" + ... + + @property + def systemid(self) -> str: + """The SystemID of the EBICS account (read-only).""" + ... + + @property + def transport_only(self) -> bool: + """Flag if the user has permission T (read-only). New since v7.4.""" + ... + + @property + def userid(self) -> str: + """The UserID of the EBICS account (read-only).""" + ... + + +class BusinessTransactionFormat: + """ + Business Transaction Format class + + Required for EBICS protocol version 3.0 (H005). + + With EBICS v3.0 you have to declare the file types you want to transfer. Please ask your bank what formats they provide. Instances of this class are used with EbicsClient.BTU(), EbicsClient.BTD() and all methods regarding the distributed signature. + + Examples: + + ```python + # SEPA Credit Transfer + CCT = BusinessTransactionFormat( + service='SCT', + msg_name='pain.001', + ) + + # SEPA Direct Debit (Core) + CDD = BusinessTransactionFormat( + service='SDD', + msg_name='pain.008', + option='COR', + ) + + # SEPA Direct Debit (B2B) + CDB = BusinessTransactionFormat( + ervice='SDD', + msg_name='pain.008', + option='B2B', + ) + + # End of Period Statement (camt.053) + C53 = BusinessTransactionFormat( + service='EOP', + msg_name='camt.053', + scope='DE', + container='ZIP', + ) + ``` + """ + + def __init__( + self, + service: str, + msg_name: str, + scope: str, + option: str, + container: str, + version: str, + variant: str, + format: str, + ): + """Initializes the BTF instance. + + Parameters: + * service – The service code name consisting of 3 alphanumeric characters [A-Z0-9] (eg. SCT, SDD, STM, EOP) + * msg_name – The message name consisting of up to 10 alphanumeric characters [a-z0-9.] (eg. pain.001, pain.008, camt.053, mt940) + * scope – Scope of service. Either an ISO-3166 ALPHA 2 country code or an issuer code of 3 alphanumeric characters [A-Z0-9]. + * option – The service option code consisting of 3-10 alphanumeric characters [A-Z0-9] (eg. COR, B2B) + * container – Type of container consisting of 3 characters [A-Z] (eg. XML, ZIP) + * version – Message version consisting of 2 numeric characters [0-9] (eg. 03) + * variant – Message variant consisting of 3 numeric characters [0-9] (eg. 001) + * format – Message format consisting of 1-4 alphanumeric characters [A-Z0-9] (eg. XML, JSON, PDF) + """ + ... + + +class EbicsClient: + """Main EBICS client class.""" + + def __init__( + self, + bank: EbicsBank, + user: EbicsUser | list[EbicsUser], + version: str = "H004", + ): + """ + Initializes the EBICS client instance. + + Parameters: + * bank - An instance of EbicsBank. + * user – An instance of EbicsUser. If you pass a list of users, a signature for each user is added to an upload request (new since v7.2). In this case the first user is the initiating one. + * version – The EBICS protocol version (H003, H004 or H005). It is strongly recommended to use at least version H004 (2.5). When using version H003 (2.4) the client is responsible to generate the required order ids, which must be implemented by your application. + """ + ... + + def BTD( + self, + btf: BusinessTransactionFormat, + start: str | date | None = None, + end: str | date | None = None, + **params: Any, + ) -> bytes: + """ + Downloads data with EBICS protocol version 3.0 (H005). + + Parameters: + * btf - Instance of BusinessTransactionFormat. + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * params - Additional keyword arguments, collected in params, are added as custom order parameters to the request. + + Returns: + The requested file data. + """ + ... + + def BTU(self, btf: BusinessTransactionFormat, data: bytes, **params: Any) -> str: + """ + Uploads data with EBICS protocol version 3.0 (H005). + + Parameters: + * btf - Instance of BusinessTransactionFormat. + * data - The data to upload. + * params - Additional keyword arguments, collected in params, are added as custom order parameters to the request. Some banks in France require to upload a file in test mode the first time: TEST='TRUE' + + Returns: + The order id (OrderID). + """ + ... + + def C52( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict[str, Any] | bytes: + """ + Downloads Bank to Customer Account Reports (camt.52). + + Parameters: + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * parsed - Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: + A dictionary of either raw XML documents or structures of dictionaries. + """ + ... + + def C53( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict[str, Any] | bytes: + """ + Downloads Bank to Customer Statements (camt.53). + + Parameters: + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * parsed - Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: + A dictionary of either raw XML documents or structures of dictionaries. + """ + ... + + def C54( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict[str, Any] | bytes: + """ + Downloads Bank to Customer Debit Credit Notifications (camt.54). + + Parameters: + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * parsed - Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: + A dictionary of either raw XML documents or structures of dictionaries. + """ + ... + + def CCT(self, document: str | SEPACreditTransfer) -> str: + """ + Uploads a SEPA Credit Transfer document. + + Parameters: + * document - The SEPA document to be uploaded either as a raw XML string or a fintech.sepa.SEPACreditTransfer object. + + Returns: + The id of the uploaded order (OrderID). + """ + ... + + def CCU(self, document: str | SEPACreditTransfer) -> str: + """ + Uploads a SEPA Credit Transfer document (Urgent Payments). New in v7.0.0. + + Parameters: + * document - The SEPA document to be uploaded either as a raw XML string or a fintech.sepa.SEPACreditTransfer object. + + Returns: + The id of the uploaded order (OrderID). + """ + ... + + def CDB(self, document: str | "SEPADirectDebit") -> str: + """ + Uploads a SEPA Direct Debit document of type B2B. + + Parameters: + * document - The SEPA document to be uploaded either as a raw XML string or a fintech.sepa.SEPADirectDebit object. + + Returns: + The id of the uploaded order (OrderID). + """ + ... + + def CDD(self, document: str | "SEPADirectDebit") -> str: + """ + Uploads a SEPA Direct Debit document of type CORE. + + Parameters: + * document - The SEPA document to be uploaded either as a raw XML string or a fintech.sepa.SEPADirectDebit object. + + Returns: + The id of the uploaded order (OrderID). + """ + ... + + def CDZ( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict[str, Any] | bytes: + """ + Downloads Payment Status Report for Direct Debits. + + Parameters: + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * parsed - Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: + A dictionary of either raw XML documents or structures of dictionaries. + """ + ... + + def CIP(self, document: str | SEPACreditTransfer) -> str: + """Uploads a SEPA Credit Transfer document (Instant Payments). + + Parameters: + * document - The SEPA document to be uploaded, either as a raw XML string or a fintech.sepa.SEPACreditTransfer object. + + Returns: + The ID of the uploaded order (OrderID). + """ + ... + + def CIZ( + self, start: str | date = None, end: str | date = None, parsed: bool = False + ) -> dict: + """Downloads Payment Status Report for Credit Transfers (Instant Payments). + + Parameters: + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * parsed - Flag to determine whether the received XML documents should be parsed and returned as structures of dictionaries. + + Returns: + A dictionary of either raw XML documents or structures of dictionaries. + """ + ... + + def CRZ( + self, start: str | date = None, end: str | date = None, parsed: bool = False + ) -> dict: + """Downloads Payment Status Report for Credit Transfers. + + Parameters: + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * parsed - Flag to determine whether the received XML documents should be parsed and returned as structures of dictionaries. + + Returns: + A dictionary of either raw XML documents or structures of dictionaries. + """ + ... + + def FDL( + self, + filetype: str, + start: str | date = None, + end: str | date = None, + country: str = None, + **params, + ) -> bytes: + """Downloads a file in arbitrary format. Not usable with EBICS 3.0 (H005). + + Parameters: + * filetype - The requested file type. + * start - The start date of requested transactions. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested transactions. Can be a date object or an ISO8601 formatted string. + * country - The country code (ISO-3166 ALPHA 2) if the specified file type is country-specific. + * params - Additional keyword arguments to be added as custom order parameters to the request. + + Returns: + The requested file data. + """ + ... + + def FUL(self, filetype: str, data: bytes, country: str = None, **params) -> str: + """Uploads a file in arbitrary format. Not usable with EBICS 3.0 (H005). + + Parameters: + * filetype - The file type to upload. + * data - The file data to upload. + * country - The country code (ISO-3166 ALPHA 2) if the specified file type is country-specific. + * params - Additional keyword arguments to be added as custom order parameters to the request. Some banks in France require a test mode upload: TEST='TRUE'. + + Returns: + The order ID (OrderID). + """ + ... + + def H3K(self) -> str: + """Sends the public key of the electronic signature, the public authentication key, and the encryption key based on certificates. At least the signature key certificate must be signed by a certification authority (CA) or the bank itself. + + Returns: + The assigned order ID. + """ + ... + + def HAA(self, parsed: bool = False) -> dict: + """Downloads the available order types. + + Parameters: + * parsed - Flag to determine whether the received XML document should be parsed and returned as a structure of dictionaries. + + Returns: + Either the raw XML document or a structure of dictionaries. + """ + ... + + def HAC( + self, start: str | date = None, end: str | date = None, parsed: bool = False + ) -> dict: + """Downloads the customer usage report in XML format. + + Parameters: + * start - The start date of requested processes. Can be a date object or an ISO8601 formatted string. + * end - The end date of requested processes. Can be a date object or an ISO8601 formatted string. + * parsed - Flag to determine whether the received XML document should be parsed and returned as a structure of dictionaries. + + Returns: + Either the raw XML document or a structure of dictionaries. + """ + ... + + def HCA(self, bitlength: int = 2048) -> str: + """Creates a new authentication and encryption key, transfers them to the bank, and updates the user key ring. + + Parameters: + * bitlength - The bit length of the generated keys. The value must be between 1536 and 4096 (default is 2048). + + Returns: + The assigned order ID. + """ + ... + + def HCS(self, bitlength: int = 2048, keyversion: str | None = None) -> str: + """Creates a new signature, authentication, and encryption key, transfers them to the bank and updates the user key ring. + + It acts like a combination of EbicsClient.PUB() and EbicsClient.HCA(). + + Parameters: + * bitlength - The bit length of the generated keys. The value must be between 1536 and 4096 (default is 2048). + * keyversion - The key version of the electronic signature. Supported versions are A005 (based on RSASSA-PKCS1-v1_5) and A006 (based on RSASSA-PSS). If not specified, the version of the current signature key is used. + + Returns: The assigned order id. + """ + ... + + def HEV(self) -> dict: + """Returns a dictionary of supported protocol versions.""" + ... + + def HIA(self) -> str: + """Sends the public authentication (X002) and encryption (E002) keys. + + Returns: The assigned order id. + """ + ... + + def HKD(self, parsed: bool = False) -> str | dict: + """Downloads the customer properties and settings. + + Parameters: + * parsed - Flag whether the received XML document should be parsed and returned as a structure of dictionaries or not. + + Returns: Either the raw XML document or a structure of dictionaries. + """ + ... + + def HPB(self) -> str: + """Receives the public authentication (X002) and encryption (E002) keys from the bank. + + The keys are added to the key file and must be activated by calling the method EbicsBank.activate_keys(). + + Returns: The string representation of the keys. + """ + ... + + def HPD(self, parsed: bool = False) -> str | dict: + """Downloads the available bank parameters. + + Parameters: + * parsed - Flag whether the received XML document should be parsed and returned as a structure of dictionaries or not. + + Returns: Either the raw XML document or a structure of dictionaries. + """ + ... + + def HTD(self, parsed: bool = False) -> str | dict: + """Downloads the user properties and settings. + + Parameters: + * parsed - Flag whether the received XML document should be parsed and returned as a structure of dictionaries or not. + + Returns: Either the raw XML document or a structure of dictionaries. + """ + ... + + def HVD( + self, + orderid: str, + ordertype: str | None = None, + partnerid: str | None = None, + parsed: bool = False, + ) -> str | dict: + """Downloads the signature status of a pending order. + + This method is part of the distributed signature. + + Parameters: + * orderid - The id of the order in question. + * ordertype - With EBICS protocol version H005, a BusinessTransactionFormat instance of the order. Otherwise, the type of the order in question. If not specified, the related BTF/order type is detected by calling the method EbicsClient.HVU(). + * partnerid - The partner id of the corresponding order. Defaults to the partner id of the current user. + * parsed - Flag whether the received XML document should be parsed and returned as a structure of dictionaries or not. + + Returns: Either the raw XML document or a structure of dictionaries. + """ + ... + + def HVE( + self, + orderid: str, + ordertype: str | None = None, + hash: str | None = None, + partnerid: str | None = None, + ) -> None: + """Signs a pending order. + + This method is part of the distributed signature. + + Parameters: + * orderid - The id of the order in question. + * ordertype - With EBICS protocol version H005, a BusinessTransactionFormat instance of the order. Otherwise, the type of the order in question. If not specified, the related BTF/order type is detected by calling the method EbicsClient.HVZ(). + * hash - The base64 encoded hash of the order to be signed. If not specified, the corresponding hash is detected by calling the method EbicsClient.HVZ(). + * partnerid - The partner id of the corresponding order. Defaults to the partner id of the current user. + """ + ... + + def HVS( + self, + orderid: str, + ordertype: str | None = None, + hash: str | None = None, + partnerid: str | None = None, + ) -> None: + """Cancels a pending order. + + This method is part of the distributed signature. + + Parameters: + * orderid - The id of the order in question. + * ordertype - With EBICS protocol version H005, a BusinessTransactionFormat instance of the order. Otherwise, the type of the order in question. If not specified, the related BTF/order type is detected by calling the method EbicsClient.HVZ(). + * hash - The base64 encoded hash of the order to be canceled. If not specified, the corresponding hash is detected by calling the method EbicsClient.HVZ(). + * partnerid - The partner id of the corresponding order. Defaults to the partner id of the current user. + """ + ... + + def HVT( + self, + orderid: str, + ordertype: str | BusinessTransactionFormat | None = None, + source: bool = False, + limit: int = 100, + offset: int = 0, + partnerid: str | None = None, + parsed: bool = False, + ) -> str | dict: + """Downloads the transaction details of a pending order as part of the distributed signature process. + + Parameters: + * orderid – The id of the order in question. + * ordertype – With EBICS protocol version H005, a BusinessTransactionFormat instance of the order; otherwise, the order type. If not specified, the related order type is detected by calling EbicsClient.HVU(). + * source – Boolean flag whether to return the original document of the order or just a summary of the corresponding transactions. + * limit – The number of transactions returned. Applicable only if source evaluates to False. + * offset – The offset of the first transaction to return. Applicable only if source evaluates to False. + * partnerid – The partner id of the order, defaulting to the partner id of the current user. + * parsed – Flag whether to parse the received XML document and return it as a dictionary structure or not. + + Returns: Either the raw XML document or a structure of dictionaries.""" + ... + + def HVU( + self, + filter: list[str | BusinessTransactionFormat] | None = None, + parsed: bool = False, + ) -> str | dict: + """Downloads pending orders waiting to be signed, as part of the distributed signature process. + + Parameters: + * filter – With EBICS protocol version H005, an optional list of BusinessTransactionFormat instances to filter the result; otherwise, a list of order types for filtering. + * parsed – Flag whether the received XML document should be parsed and returned as a structure of dictionaries or not. + + Returns: Either the raw XML document or a structure of dictionaries.""" + ... + + def HVZ( + self, + filter: list[str | BusinessTransactionFormat] | None = None, + parsed: bool = False, + ) -> str | dict: + """Downloads pending orders waiting to be signed. Combines the functionality of HVU() and HVD(). + + Parameters: + * filter – With EBICS protocol version H005, an optional list of BusinessTransactionFormat instances to filter the result; otherwise, a list of order types for filtering. + * parsed – Flag whether the received XML document should be parsed and returned as a structure of dictionaries or not. + + Returns: Either the raw XML document or a structure of dictionaries.""" + ... + + def INI(self) -> str: + """Sends the public key of the electronic signature. + + Returns: The assigned order id.""" + ... + + def PTK(self, start: str | date | None = None, end: str | date | None = None) -> str: + """Downloads the customer usage report in text format. + + Parameters: + * start – The start date of the requested processes (date object or ISO8601 formatted string). + * end – The end date of the requested processes (date object or ISO8601 formatted string). + + Returns: The customer usage report.""" + ... + + def PUB(self, bitlength: int = 2048, keyversion: str | None = None) -> str: + """Creates a new electronic signature key, transfers it to the bank, and updates the user key ring. + + Parameters: + * bitlength – The bit length of the generated key (between 1536 and 4096, default 2048). + * keyversion – The key version of the electronic signature. Supported versions: A005 (RSASSA-PKCS1-v1_5) and A006 (RSASSA-PSS). If not specified, the current signature key version is used. + + Returns: The assigned order id.""" + ... + + def SPR(self) -> None: + """Locks the EBICS access of the current user.""" + ... + + def STA( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> str | dict: + """Downloads the bank account statement in SWIFT format (MT940). + + Parameters: + * start – The start date of the requested transactions (date object or ISO8601 formatted string). + * end – The end date of the requested transactions (date object or ISO8601 formatted string). + * parsed – Flag whether to parse the MT940 message and return it as a dictionary or not. + + Returns: Either the raw data of the MT940 SWIFT message or the parsed message as a dictionary.""" + ... + + def VMK( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> str | dict: + """Downloads the interim transaction report in SWIFT format (MT942). + + Parameters: + * start – The start date of the requested transactions (date object or ISO8601 formatted string). + * end – The end date of the requested transactions (date object or ISO8601 formatted string). + * parsed – Flag whether to parse the MT942 message and return it as a dictionary or not. + + Returns: Either the raw data of the MT942 SWIFT message or the parsed message as a dictionary.""" + ... + + def XE2(self, document: str | SEPACreditTransfer) -> str: + """Uploads a SEPA Credit Transfer document (Switzerland). + + Parameters: + * document – The SEPA document to upload, either as a raw XML string or a SEPACreditTransfer object. + + Returns: The id of the uploaded order (OrderID).""" + ... + + def Z01( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict: + """Downloads Payment Status Report (Switzerland, mixed). + + Parameters: + * start – The start date of requested transactions (date object or ISO8601 formatted string). + * end – The end date of requested transactions (date object or ISO8601 formatted string). + * parsed – Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: A dictionary of either raw XML documents or structures of dictionaries.""" + ... + + def Z53( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict: + """Downloads Bank to Customer Statements (Switzerland, camt.53). + + Parameters: + * start – The start date of requested transactions (date object or ISO8601 formatted string). + * end – The end date of requested transactions (date object or ISO8601 formatted string). + * parsed – Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: A dictionary of either raw XML documents or structures of dictionaries.""" + ... + + def Z54( + self, + start: str | date | None = None, + end: str | date | None = None, + parsed: bool = False, + ) -> dict: + """Downloads Bank Batch Statements ESR (Switzerland, C53F). + + Parameters: + * start – The start date of requested transactions (date object or ISO8601 formatted string). + * end – The end date of requested transactions (date object or ISO8601 formatted string). + * parsed – Flag whether the received XML documents should be parsed and returned as structures of dictionaries or not. + + Returns: A dictionary of either raw XML documents or structures of dictionaries.""" + ... + + @property + def bank(self) -> "EbicsBank": + """Returns the EBICS bank (read-only).""" + ... + + @property + def check_ssl_certificates(self) -> bool: + """Flag whether remote SSL certificates should be checked for validity (default: True).""" + ... + + def confirm_download(self, trans_id: str | None = None, success: bool = True) -> None: + """Confirms the receipt of previously executed downloads. + + Parameters: + * trans_id – The transaction id of the download (see last_trans_id). If not specified, all previously unconfirmed downloads are confirmed. + * success – Informs the EBICS server whether the downloaded data was successfully processed or not.""" + ... + + def download( + self, + order_type: str, + start: str | date | None = None, + end: str | date | None = None, + params: list | dict | None = None, + ): + """Performs an arbitrary EBICS download request. + + Parameters: + * order_type - The id of the intended order type. + * start - The start date of requested documents. Can be a date object or an ISO8601 formatted string. Not allowed with all order types. + * end - The end date of requested documents. Can be a date object or an ISO8601 formatted string. Not allowed with all order types. + * params - A list or dictionary of parameters that are added to the EBICS request. Cannot be combined with a date range specified by start and end. + + Returns: The downloaded data. The returned transaction id is stored in the attribute last_trans_id. + """ + ... + + @property + def last_trans_id(self) -> str: + """This attribute stores the transaction id of the last download process (read-only).""" + ... + + def listen(self, filter: list[str] | None = None) -> None: + """Connects to the EBICS websocket server and listens for new incoming messages. This is a blocking service. + + Parameters: + * filter - An optional list of order types or BTF message names (BusinessTransactionFormat.msg_name) that will be processed.Other data types are skipped. + """ + ... + + @property + def suppress_no_data_error(self) -> bool: + """Flag whether to suppress exceptions if no download data is available or not. + + The default value is False. If set to True, download methods return None in the case that no download data is available. + """ + ... + + @suppress_no_data_error.setter + def suppress_no_data_error(self, value: bool) -> None: + """Sets the flag to suppress exceptions if no download data is available. + + Parameters: + * value - Boolean value to set suppress_no_data_error flag. + """ + ... + + @property + def timeout(self) -> int: + """The timeout in seconds for EBICS connections (default: 30).""" + ... + + @timeout.setter + def timeout(self, value: int) -> None: + """Sets the timeout in seconds for EBICS connections. + + Parameters: + * value - The timeout value in seconds. + """ + ... + + def upload( + self, + order_type: str, + data: str, + params: list | dict | None = None, + prehashed: bool = False, + ) -> str: + """Performs an arbitrary EBICS upload request. + + Parameters: + * order_type - The id of the intended order type. + * data - The data to be uploaded. + * params - A list or dictionary of parameters that are added to the EBICS request. + * prehashed - Flag whether the data contains a prehashed value or not. + + Returns: The id of the uploaded order, if applicable. + """ + ... + + @property + def user(self) -> EbicsUser | list[EbicsUser]: + """The EBICS user (read-only).""" + ... + + @property + def version(self) -> str: + """The EBICS protocol version (read-only).""" + ... + + @property + def websocket(self): + """The websocket instance if running (read-only).""" + ... + + +class CAMTDocument: + """The CAMTDocument class is used to parse CAMT52, CAMT53 or CAMT54 documents. + An instance can be treated as an iterable over its transactions, each represented as an instance of type SEPATransaction. + + Note: If orders were submitted in batch mode, there are three methods to resolve the underlying transactions. + Either (A) directly within the CAMT52/CAMT53 document, (B) within a separate CAMT54 document or + (C) by a reference to the originally transferred PAIN message. The applied method depends on the bank (method B is most commonly used). + """ + + def __init__(self, xml: str, camt54: str | list[str] = None): + """Initializes the CAMTDocument instance. + + Parameters: + * xml - The XML string of a CAMT document to be parsed (either CAMT52, CAMT53 or CAMT54). + * camt54 - In case xml is a CAMT52 or CAMT53 document, an additional CAMT54 document or a sequence of such documents can be passed, which are automatically merged with the corresponding batch transactions. + """ + ... + + def __iter__(self) -> Iterator[SEPATransaction]: + """Returns an iterator over the transactions.""" + ... + + @property + def balance_close(self) -> "Amount": + """The closing balance of type Amount (read-only).""" + ... + + @property + def balance_open(self) -> "Amount": + """The opening balance of type Amount (read-only).""" + ... + + @property + def bic(self) -> str: + """The local BIC (read-only).""" + ... + + @property + def created(self) -> str: + """The date of creation (read-only).""" + ... + + @property + def currency(self) -> str: + """The currency of the account (read-only).""" + ... + + @property + def date_from(self) -> str: + """The start date (read-only).""" + ... + + @property + def date_to(self) -> str: + """The end date (read-only).""" + ... + + @property + def iban(self) -> str: + """The local IBAN (read-only).""" + ... + + @property + def info(self) -> str: + """Some info text about the document (read-only).""" + ... + + @property + def message_id(self) -> str: + """The message id (read-only).""" + ... + + @property + def name(self) -> str: + """The name of the account holder (read-only).""" + ... + + @property + def reference_id(self) -> str: + """A unique reference number (read-only).""" + ... + + @property + def sequence_id(self) -> str: + """The statement sequence number (read-only).""" + ... + + @property + def type(self) -> str: + """The CAMT type, e.g. camt.053.001.02 (read-only).""" + ... diff --git a/banking/ebics/utils.py b/banking/ebics/utils.py new file mode 100644 index 00000000..f0830d95 --- /dev/null +++ b/banking/ebics/utils.py @@ -0,0 +1,136 @@ +import contextlib +from typing import TYPE_CHECKING + +import frappe +from frappe import _ + +from banking.ebics.manager import EBICSManager + +if TYPE_CHECKING: + from datetime import date + from .types import SEPATransaction + from banking.ebics.doctype.ebics_user.ebics_user import EBICSUser + + +def get_ebics_manager( + ebics_user: "EBICSUser", + passphrase: str | None = None, + sig_passphrase: str | None = None, +) -> "EBICSManager": + """Get an EBICSManager instance for the given EBICS User. + + :param ebics_user: The EBICS User record. + :param passphrase: The secret passphrase for uploads to the bank. + """ + banking_settings = frappe.get_single("Banking Settings") + + manager = EBICSManager( + license_name=banking_settings.fintech_licensee_name, + license_key=banking_settings.get_password("fintech_license_key"), + ) + + manager.set_keyring( + keys=ebics_user.get_keyring(), + save_to_db=ebics_user.store_keyring, + sig_passphrase=sig_passphrase, + passphrase=passphrase or ebics_user.get_password("passphrase"), + ) + + manager.set_user(ebics_user.partner_id, ebics_user.user_id) + + host_id, url = frappe.db.get_value( + "Bank", ebics_user.bank, ["ebics_host_id", "ebics_url"] + ) + manager.set_bank(host_id, url) + + return manager + + +def sync_ebics_transactions( + ebics_user: str, + start_date: str | None = None, + end_date: str | None = None, + passphrase: str | None = None, +): + user = frappe.get_doc("EBICS User", ebics_user) + manager = get_ebics_manager(ebics_user=user, passphrase=passphrase) + for camt_document in manager.download_bank_statements(start_date, end_date): + bank_account = frappe.db.get_value( + "Bank Account", + { + "iban": camt_document.iban, + "disabled": 0, + "bank": user.bank, + "is_company_account": 1, + "company": user.company, + }, + ) + if not bank_account: + frappe.log_error( + title=_("Banking Error"), + message=_("Bank Account not found for IBAN {0}").format(camt_document.iban), + ) + continue + + for transaction in camt_document: + if transaction.status != "BOOK": + # Skip PDNG and INFO transactions + continue + + if transaction.batch and len(transaction): + # Split batch transactions into sub-transactions, based on info + # from camt.054 that is sometimes available. + # If that's not possible, create a single transaction + for sub_transaction in transaction: + _create_bank_transaction( + bank_account, user.company, sub_transaction, user.start_date + ) + else: + _create_bank_transaction(bank_account, user.company, transaction, user.start_date) + + +def _create_bank_transaction( + bank_account: str, + company: str, + sepa_transaction: "SEPATransaction", + start_date: "date" = None, +): + """Create an ERPNext Bank Transaction from a given fintech.sepa.SEPATransaction. + + https://www.joonis.de/en/fintech/doc/sepa/#fintech.sepa.SEPATransaction + """ + # sepa_transaction.bank_reference can be None, but we can still find an ID in the XML + # For our test bank, the latter is a timestamp with nanosecond accuracy. + transaction_id = ( + sepa_transaction.bank_reference or sepa_transaction._xmlobj.Refs.TxId.text + ) + + # NOTE: This does not work for old data, this ID is different from Kosma's + if sepa_transaction.bank_reference and frappe.db.exists( + "Bank Transaction", + {"transaction_id": transaction_id, "bank_account": bank_account}, + ): + return + + if start_date and sepa_transaction.date < start_date: + return + + bt = frappe.new_doc("Bank Transaction") + bt.date = sepa_transaction.date + bt.bank_account = bank_account + bt.company = company + + amount = float(sepa_transaction.amount.value) + bt.deposit = max(amount, 0) + bt.withdrawal = abs(min(amount, 0)) + bt.currency = sepa_transaction.amount.currency + + bt.description = "\n".join(sepa_transaction.purpose) + bt.reference_number = sepa_transaction.eref + bt.transaction_id = transaction_id + bt.bank_party_iban = sepa_transaction.iban + bt.bank_party_name = sepa_transaction.name + + with contextlib.suppress(frappe.exceptions.UniqueValidationError): + bt.insert() + bt.submit() diff --git a/banking/hooks.py b/banking/hooks.py index a765e772..5780be95 100644 --- a/banking/hooks.py +++ b/banking/hooks.py @@ -185,6 +185,29 @@ translatable=0, ) ], + "Bank": [ + dict( + fieldname="ebics_section", + label="EBICS", + fieldtype="Section Break", + insert_after="plaid_access_token", + ), + dict( + fieldname="ebics_host_id", + label="EBICS Host ID", + fieldtype="Data", + insert_after="ebics_section", + translatable=0, + ), + dict( + fieldname="ebics_url", + label="EBICS URL", + fieldtype="Data", + options="URL", + insert_after="ebics_host_id", + translatable=0, + ), + ], } kosma_property_setters = { diff --git a/banking/klarna_kosma_integration/admin.py b/banking/klarna_kosma_integration/admin.py index f65f96df..9d3b2d41 100644 --- a/banking/klarna_kosma_integration/admin.py +++ b/banking/klarna_kosma_integration/admin.py @@ -28,11 +28,15 @@ class Admin: """A class that directly communicates with the Banking Admin App.""" - def __init__(self) -> None: + def __init__(self, settings=None) -> None: + """Initialize the Admin class with the necessary settings. + + :param settings: Banking Settings document. Enables you to pass the most recent settings that may not be in the database yet. + """ self.ip_address = get_current_ip() self.user_agent = frappe.get_request_header("User-Agent") if frappe.request else None - settings = frappe.get_single("Banking Settings") + settings = settings or frappe.get_single("Banking Settings") self.use_test_environment = settings.use_test_environment self.api_token = settings.get_password("api_token") self.customer_id = settings.customer_id diff --git a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.js b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.js index 1a5da1ff..80bad68b 100644 --- a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.js +++ b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.js @@ -6,17 +6,25 @@ frappe.ui.form.on('Banking Settings', { if (frm.doc.enabled) { frm.trigger("get_app_health"); - frm.add_custom_button(__('Link Bank and Accounts'), () => { - frm.events.refresh_banks(frm); - }); + if (frm.doc.enable_klarna_kosma) { + frm.add_custom_button(__('Link Bank and Accounts'), () => { + frm.events.refresh_banks(frm); + }); + + frm.add_custom_button(__("Transactions"), () => { + frm.events.sync_transactions(frm); + }, __("Sync")); - frm.add_custom_button(__("Transactions"), () => { - frm.events.sync_transactions(frm); - }, __("Sync")); + frm.add_custom_button(__("Older Transactions"), () => { + frm.events.sync_transactions(frm, true); + }, __("Sync")); + } - frm.add_custom_button(__("Older Transactions"), () => { - frm.events.sync_transactions(frm, true); - }, __("Sync")); + if (frm.doc.enable_ebics) { + frm.add_custom_button(__("View EBICS Users"), () => { + frappe.set_route("List", "EBICS User"); + }); + } if (frm.doc.customer_id && frm.doc.admin_endpoint && frm.doc.api_token) { frm.trigger("get_subscription"); @@ -176,7 +184,6 @@ frappe.ui.form.on('Banking Settings', { style="border: 1px solid var(--gray-300); border-radius: 4px; padding: 1rem; - width: calc(50% - 15px); margin-bottom: 0.5rem; ">

@@ -184,13 +191,20 @@ frappe.ui.form.on('Banking Settings', {

${ __("Subscriber") }: - ${subscription.full_name}

+ ${subscription.full_name} +

${ __("Status") }: - ${subscription.subscription_status}

+ ${subscription.subscription_status} +

${ __("Transaction Limit") }: - ${subscription.usage} (${__("Usage")}) / ${subscription.transaction_limit} (${__("Limit")})

+ ${subscription.usage} (${__("Usage")}) / ${subscription.transaction_limit} (${__("Limit")}) +

+

+ ${ __("Ebics Users") }: + ${subscription.ebics_usage.used} (${__("Usage")}) / ${subscription.ebics_usage.allowed} (${__("Limit")}) +

${ __("Valid Till") }: ${frappe.format(subscription.plan_end_date, {"fieldtype": "Date"})} diff --git a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json index cd16dedd..79648416 100644 --- a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json +++ b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.json @@ -7,15 +7,20 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "admin_endpoint", "enabled", - "use_test_environment", "section_break_2", + "admin_endpoint", "customer_id", - "column_break_4", "api_token", - "section_break_aiyw3", - "subscription" + "column_break_4", + "subscription", + "klarna_kosma_section", + "enable_klarna_kosma", + "use_test_environment", + "ebics_section", + "enable_ebics", + "fintech_licensee_name", + "fintech_license_key" ], "fields": [ { @@ -25,9 +30,9 @@ "label": "Enabled" }, { - "depends_on": "enabled", "fieldname": "section_break_2", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Subscription" }, { "fieldname": "column_break_4", @@ -55,10 +60,6 @@ "label": "Admin URL", "mandatory_depends_on": "enabled" }, - { - "fieldname": "section_break_aiyw3", - "fieldtype": "Section Break" - }, { "depends_on": "subscription", "fieldname": "subscription", @@ -67,15 +68,52 @@ }, { "default": "0", + "depends_on": "enable_klarna_kosma", "fieldname": "use_test_environment", "fieldtype": "Check", "label": "Use Test Environment" + }, + { + "fieldname": "ebics_section", + "fieldtype": "Section Break", + "label": "EBICS" + }, + { + "depends_on": "enable_ebics", + "fieldname": "fintech_license_key", + "fieldtype": "Password", + "label": "Fintech License Key", + "read_only": 1 + }, + { + "depends_on": "enable_ebics", + "fieldname": "fintech_licensee_name", + "fieldtype": "Data", + "label": "Fintech Licensee Name", + "read_only": 1 + }, + { + "fieldname": "klarna_kosma_section", + "fieldtype": "Section Break", + "label": "Klarna Kosma" + }, + { + "default": "1", + "fieldname": "enable_klarna_kosma", + "fieldtype": "Check", + "label": "Enable Klarna Kosma (Legacy)" + }, + { + "default": "0", + "fieldname": "enable_ebics", + "fieldtype": "Check", + "label": "Enable EBICS (New)" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-03-12 21:09:51.503474", + "modified": "2024-11-12 13:20:54.035378", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "Banking Settings", diff --git a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py index e78ed408..9050d4c2 100644 --- a/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py +++ b/banking/klarna_kosma_integration/doctype/banking_settings/banking_settings.py @@ -1,6 +1,7 @@ # Copyright (c) 2022, ALYF GmbH and contributors # For license information, please see license.txt import json +from requests import HTTPError from semantic_version import Version from typing import Dict, Optional, Union @@ -19,7 +20,28 @@ class BankingSettings(Document): - pass + def before_validate(self): + self.update_fintech_license() + + def update_fintech_license(self): + if not self.enabled: + return self.reset_fintech_license() + + try: + response = Admin(self).request.get_fintech_license() + response.raise_for_status() + except HTTPError: + return self.reset_fintech_license() + except Exception: + return + + data = response.json().get("message", {}) + self.fintech_licensee_name = data.get("licensee_name") + self.fintech_license_key = data.get("license_key") + + def reset_fintech_license(self): + self.fintech_licensee_name = None + self.fintech_license_key = None @frappe.whitelist() @@ -97,9 +119,18 @@ def sync_all_accounts_and_transactions(): Refresh all Bank accounts and enqueue their transactions sync, via the Consent API. Called via hooks. """ - if not frappe.db.get_single_value("Banking Settings", "enabled"): + banking_settings = frappe.get_single("Banking Settings") + if not banking_settings.enabled: return + if banking_settings.enable_klarna_kosma: + daily_sync_kosma() + + if banking_settings.enable_ebics: + daily_sync_ebics() + + +def daily_sync_kosma(): accounts_list = [] for bank, company in frappe.get_all( @@ -131,6 +162,25 @@ def sync_all_accounts_and_transactions(): sync_transactions(account=bank_account) +def daily_sync_ebics(): + from banking.ebics.utils import sync_ebics_transactions + + for ebics_user in frappe.get_all( + "EBICS User", + filters={ + "initialized": 1, + "bank_keys_activated": 1, + "passphrase": ("is", "set"), + "keyring": ("is", "set"), + }, + pluck="name", + ): + frappe.enqueue( + sync_ebics_transactions, + ebics_user=ebics_user, + ) + + def get_bank_accounts_to_sync(bank: str, company: str) -> list: """ Get bank accounts from Kosma via the consent API. diff --git a/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json b/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json index f241df50..a66b250f 100644 --- a/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json +++ b/banking/klarna_kosma_integration/workspace/alyf_banking/alyf_banking.json @@ -16,7 +16,8 @@ "hidden": 0, "is_query_report": 0, "label": "Settings", - "link_count": 1, + "link_count": 2, + "link_type": "DocType", "onboard": 0, "type": "Card Break" }, @@ -29,9 +30,19 @@ "link_type": "DocType", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "EBICS User", + "link_count": 0, + "link_to": "EBICS User", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2023-09-18 19:49:39.472163", + "modified": "2024-10-16 19:52:52.240230", "modified_by": "Administrator", "module": "Klarna Kosma Integration", "name": "ALYF Banking", diff --git a/banking/modules.txt b/banking/modules.txt index 2fc6ef32..c0ed936c 100644 --- a/banking/modules.txt +++ b/banking/modules.txt @@ -1 +1,2 @@ -Klarna Kosma Integration \ No newline at end of file +Klarna Kosma Integration +EBICS \ No newline at end of file diff --git a/banking/patches.txt b/banking/patches.txt index e69de29b..dc511117 100644 --- a/banking/patches.txt +++ b/banking/patches.txt @@ -0,0 +1,5 @@ +[pre_model_sync] +banking.patches.recreate_custom_fields + +[post_model_sync] +execute:frappe.db.set_single_value("Banking Settings", "enable_klarna_kosma", 1) diff --git a/banking/patches/recreate_custom_fields.py b/banking/patches/recreate_custom_fields.py new file mode 100644 index 00000000..1fe4f96b --- /dev/null +++ b/banking/patches/recreate_custom_fields.py @@ -0,0 +1,6 @@ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe import get_hooks + + +def execute(): + create_custom_fields(get_hooks("kosma_custom_fields")) diff --git a/banking/translations/de.csv b/banking/translations/de.csv index ce050086..be6a3b53 100644 --- a/banking/translations/de.csv +++ b/banking/translations/de.csv @@ -46,4 +46,42 @@ Fetching older transactions will count against your limit in the current billing Select IBAN and corresponding ERPNext Account,IBAN und zugehöriges ERPNext-Konto auswählen, Bank Reconciliation Tool Beta,Bankabgleich Beta, Are you trying to reconcile vouchers of different parties? This action will reconcile vouchers using a Journal Entry.,"Versuchen Sie, Belege verschiedener Parteien abzugleichen? Diese Aktion gleicht Belege mit einem Buchungssatz ab.", -Multiple Party Reconciliation, Abgleich mehrerer Parteien, \ No newline at end of file +Multiple Party Reconciliation, Abgleich mehrerer Parteien, +Bank Keys Activated,Bankenschlüssel aktiviert, +Needs Certificate,Benötigt Zertifikat, +Enable this for EBICS accounts whose key management is based on certificates (eg. French banks).,"Aktivieren Sie dies für EBICS-Konten, deren Schlüsselverwaltung auf Zertifikaten basiert (z. B. französische Banken).", +Please enter the values provided by your bank.,"Bitte geben Sie die Werte ein, die von Ihrer Bank bereitgestellt wurden.", +Download Bank Statements,Kontoauszüge herunterladen, +Initialize,Initialisieren, +Initialized,Initialisiert, +Verify Bank Keys,Bankenschlüssel überprüfen, +Passphrase,Passwort, +"Enter your password to enable automated, regular syncing. Leave blank if you prefer to sync manually.","Geben Sie Ihr Passwort ein, um die automatisierte, regelmäßige Synchronisierung zu aktivieren. Lassen Sie das Feld leer, wenn Sie die Synchronisierung manuell durchführen möchten.", +Keyring,Schlüsselbund, +Enable Klarna Kosma (Legacy),Klarna Kosma aktivieren (Veraltet), +Enable EBICS (New),EBICS aktivieren (Neu), +Fintech Licensee Name,Fintech-Lizenznehmername, +Fintech License Key,Fintech-Lizenzschlüssel, +Ebics Users,EBICS-Benutzer, +View EBICS Users,EBICS-Benutzer anzeigen, +EBICS User,EBICS-Benutzer, +"Please print the attached INI letter, send it to your bank and wait for confirmation. Then verify the bank keys.","Bitte drucken Sie den angehängten INI-Brief aus, senden Sie ihn an Ihre Bank und warten Sie auf die Bestätigung. Überprüfen Sie dann die Bankenschlüssel.", +Set a new password for downloading bank statements from your bank.,"Legen Sie ein neues Passwort fest, um Kontoauszüge von Ihrer Bank herunterzuladen.", +Store Passphrase,Passwort speichern, +"Store the passphrase in the ERPNext database to enable automated, regular download of bank statements.","Passwort in der ERPNext-Datenbank speichern, um den automatisierten, regelmäßigen Download von Kontoauszügen zu ermöglichen.", +Signature Passphrase,Signatur-Passwort, +Set a new password for uploading transactions to your bank.,"Legen Sie ein neues Passwort fest, um Transaktionen an Ihre Bank hochzuladen.", +"Note: When you lose these passwords, you will have to go through the initialization process with your bank again.","Hinweis: Wenn Sie diese Passwörter verlieren, müssen Sie den Initialisierungsprozess mit Ihrer Bank erneut durchlaufen.", +Initialize EBICS User,EBICS-Benutzer initialisieren, +Please confirm that the following keys are identical to the ones mentioned on your bank's letter:,"Bitte bestätigen Sie, dass die folgenden Schlüssel mit denen auf dem Schreiben Ihrer Bank übereinstimmen:", +Bank keys confirmed,Bankenschlüssel bestätigt, +Bank statements are being downloaded in the background.,Kontoauszüge werden im Hintergrund heruntergeladen., +Bank transactions that happened before this date must not be imported.,"Banktransaktionen, die vor diesem Datum stattgefunden haben, dürfen nicht importiert werden.", +EBICS User limit exceeded.,EBICS-Benutzerlimit überschritten., +User ID not available.,Benutzer-ID nicht verfügbar., +Failed to remove EBICS user registration.,EBICS-Benutzerregistrierung konnte nicht entfernt werden., +Please add a two-letter country code to country {0},Bitte fügen Sie dem Land {0} einen zweistelligen Ländercode hinzu, +Please add EBICS Host ID and URL to bank {0},Bitte tragen Sie die EBICS-Host-ID und die URL in der Bank {0} ein, +Bank Account not found for IBAN {0},Bankkonto nicht gefunden für IBAN {0}, +EBICS Host ID,EBICS-Host-ID, +EBICS URL,EBICS-URL, diff --git a/banking/translations/fr.csv b/banking/translations/fr.csv index cb7ef5ae..a6e76d18 100644 --- a/banking/translations/fr.csv +++ b/banking/translations/fr.csv @@ -45,3 +45,41 @@ Please select a Bank Account to start reconciling.,Veuillez sélectionner un com Fetching older transactions will count against your limit in the current billing period.,Récupérer les transactions plus anciennes comptera contre votre limite dans la période de facturation en cours., Are you trying to reconcile vouchers of different parties? This action will reconcile vouchers using a Journal Entry.,Essayez-vous de rapprocher des justificatifs de différentes parties? Cette action rapprochera les justificatifs en utilisant un écriture comptable., Multiple Party Reconciliation, Rapprochement de plusieurs parties, +Bank Keys Activated,Clés de banque activées, +Needs Certificate,Nécessite un certificat, +Enable this for EBICS accounts whose key management is based on certificates (eg. French banks).,"Activez ceci pour les comptes EBICS dont la gestion des clés est basée sur des certificats (par exemple, les banques françaises).", +Please enter the values provided by your bank.,"Veuillez entrer les valeurs fournies par votre banque.", +Download Bank Statements,Télécharger les relevés bancaires, +Initialize,Initialiser, +Initialized,Initialisé, +Verify Bank Keys,Vérifier les clés de banque, +Passphrase,Phrase secrète, +"Enter your password to enable automated, regular syncing. Leave blank if you prefer to sync manually.","Entrez votre mot de passe pour activer la synchronisation automatique et régulière. Laissez vide si vous préférez une synchronisation manuelle.", +Keyring,Porte-clés, +Enable Klarna Kosma (Legacy),Activer Klarna Kosma (ancien), +Enable EBICS (New),Activer EBICS (nouveau), +Fintech Licensee Name,Nom du titulaire de la licence Fintech, +Fintech License Key,Clé de licence Fintech, +Ebics Users,Utilisateurs EBICS, +View EBICS Users,Afficher les utilisateurs EBICS, +EBICS User,Utilisateur EBICS, +"Please print the attached INI letter, send it to your bank and wait for confirmation. Then verify the bank keys.","Veuillez imprimer la lettre INI jointe, l'envoyer à votre banque et attendre la confirmation. Ensuite, vérifiez les clés de la banque.", +Set a new password for downloading bank statements from your bank.,"Définissez un nouveau mot de passe pour télécharger les relevés bancaires de votre banque.", +Store Passphrase,Enregistrer la phrase secrète, +"Store the passphrase in the ERPNext database to enable automated, regular download of bank statements.","Enregistrez la phrase secrète dans la base de données ERPNext pour activer le téléchargement automatique et régulier des relevés bancaires.", +Signature Passphrase,Phrase secrète de signature, +Set a new password for uploading transactions to your bank.,"Définissez un nouveau mot de passe pour télécharger les transactions vers votre banque.", +"Note: When you lose these passwords, you will have to go through the initialization process with your bank again.","Remarque : si vous perdez ces mots de passe, vous devrez recommencer le processus d'initialisation avec votre banque.", +Initialize EBICS User,Initialiser l'utilisateur EBICS, +Please confirm that the following keys are identical to the ones mentioned on your bank's letter:,"Veuillez confirmer que les clés suivantes sont identiques à celles mentionnées dans la lettre de votre banque :", +Bank keys confirmed,Clés de banque confirmées, +Bank statements are being downloaded in the background.,Les relevés bancaires sont en cours de téléchargement en arrière-plan., +Bank transactions that happened before this date must not be imported.,"Les transactions bancaires antérieures à cette date ne doivent pas être importées.", +EBICS User limit exceeded.,Limite d'utilisateurs EBICS dépassée., +User ID not available.,Identifiant utilisateur non disponible., +Failed to remove EBICS user registration.,Échec de la suppression de l'enregistrement de l'utilisateur EBICS., +Please add a two-letter country code to country {0},Veuillez ajouter un code pays à deux lettres pour le pays {0}, +Please add EBICS Host ID and URL to bank {0},Veuillez ajouter l'ID d'hôte EBICS et l'URL à la banque {0}, +Bank Account not found for IBAN {0},Compte bancaire introuvable pour IBAN {0}, +EBICS Host ID,ID d'hôte EBICS, +EBICS URL,URL EBICS, diff --git a/ready_for_ebics.jpg b/ready_for_ebics.jpg new file mode 100644 index 00000000..a7f68e4b Binary files /dev/null and b/ready_for_ebics.jpg differ diff --git a/requirements.txt b/requirements.txt index 7668191f..54f1f70f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -# frappe -- https://github.com/frappe/frappe is installed via 'bench init' \ No newline at end of file +# frappe -- https://github.com/frappe/frappe is installed via 'bench init' +fintech~=7.5.3