diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index aa22772b9b..427ff2a334 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -993,6 +993,7 @@ class BaseDocument: from frappe.utils import ( split_emails, validate_email_address, + validate_iban, validate_name, validate_phone_number, validate_phone_number_with_country_code, @@ -1031,6 +1032,9 @@ class BaseDocument: if data_field_options == "URL": validate_url(data, throw=True) + if data_field_options == "IBAN": + validate_iban(data, throw=True) + def _validate_constants(self): if frappe.flags.in_import or self.is_new() or self.flags.ignore_validate_constants: return diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index d80c7467cf..6b3a501eb8 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -267,7 +267,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp } format_for_input(val) { if (this.df.options == "IBAN" && val) { - return val.replaceAll(" ", "").replace(/(.{4})(?=.)/g, "$1 "); + return frappe.utils.get_formatted_iban(val); } return val == null ? "" : val; } diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index c068502fc7..1b91be7abc 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -38,6 +38,10 @@ frappe.form.formatters = { if (!value) return; return `${value}`; } + if (df && df.options == "IBAN") { + if (!value) return; + return frappe.utils.get_formatted_iban(value); + } value = value == null ? "" : value; return frappe.form.formatters._apply_custom_formatter(value, df); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 1a95d2c211..24956b54d8 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1134,6 +1134,14 @@ Object.assign(frappe.utils, { return duration; }, + get_formatted_iban(value) { + if (!value || ["BI", "SV", "EG", "LY"].some((country) => value.startsWith(country))) { + return value; + } + + return value.replaceAll(" ", "").replace(/(.{4})(?=.)/g, "$1 "); + }, + seconds_to_duration(seconds, duration_options) { const round = seconds > 0 ? Math.floor : Math.ceil; const total_duration = { diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 5f57550e64..78acfaf17a 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -34,6 +34,7 @@ from frappe.utils import ( get_site_info, get_sites, get_url, + is_valid_iban, money_in_words, parse_and_map_trackers_from_url, parse_timedelta, @@ -479,6 +480,26 @@ class TestValidationUtils(IntegrationTestCase): for name in invalid_names: self.assertRaises(frappe.InvalidNameError, validate_name, name, True) + def test_validate_iban(self): + valid_ibans = [ + "GB82 WEST 1234 5698 7654 32", + "DE91 1000 0000 0123 4567 89", + "FR76 3000 6000 0112 3456 7890 189", + ] + + invalid_ibans = [ + # wrong checksum (3rd place) + "GB72 WEST 1234 5698 7654 32", + "DE81 1000 0000 0123 4567 89", + "FR66 3000 6000 0112 3456 7890 189", + ] + + for iban in valid_ibans: + self.assertTrue(is_valid_iban(iban)) + + for not_iban in invalid_ibans: + self.assertFalse(is_valid_iban(not_iban)) + class TestImage(IntegrationTestCase): def test_strip_exif_data(self): diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index c1362bb33b..0279fb9c85 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -261,6 +261,44 @@ def validate_url( return is_valid +def validate_iban(iban: str, throw: bool = False) -> bool: + from frappe import _ + + valid = is_valid_iban(iban) + if not valid and throw: + frappe.throw(frappe._("'{0}' is not a valid IBAN").format(frappe.bold(iban))) + + return valid + + +def is_valid_iban(iban: str) -> bool: + """ + Algorithm: https://en.wikipedia.org/wiki/International_Bank_Account_Number#Validating_the_IBAN + """ + if not iban: + return False + + def encode_char(c): + # Position in the alphabet (A=1, B=2, ...) plus nine + return str(9 + ord(c) - 64) + + # remove whitespaces, upper case to get the right number from ord() + iban = iban.replace(" ", "").upper() + + # Move country code and checksum from the start to the end + flipped = iban[4:] + iban[:4] + + # Encode characters as numbers + encoded = [encode_char(c) if ord(c) >= 65 and ord(c) <= 90 else c for c in flipped] + + try: + to_check = int("".join(encoded)) + except ValueError: + return False + + return to_check % 97 == 1 + + def random_string(length: int) -> str: """generate a random string""" import string