From a6faab8ee9d101cf2fb20ee2919d3955f9ae0b87 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:27:12 +0200 Subject: [PATCH 1/4] refactor: move IBAN formatting into utils --- frappe/public/js/frappe/form/controls/data.js | 2 +- frappe/public/js/frappe/utils/utils.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 1a95d2c211..8b5717bd7e 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) { + 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 = { From 3e38c0bc4e17122fb7c7ef6af1aa978af73b1659 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:27:44 +0200 Subject: [PATCH 2/4] fix: don't format IBAN for countries with special rules --- frappe/public/js/frappe/utils/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 8b5717bd7e..24956b54d8 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1135,7 +1135,7 @@ Object.assign(frappe.utils, { }, get_formatted_iban(value) { - if (!value) { + if (!value || ["BI", "SV", "EG", "LY"].some((country) => value.startsWith(country))) { return value; } From 7b0067d0adb35e15c770038d2e63355e5d32ad3b Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:28:11 +0200 Subject: [PATCH 3/4] feat: format IBAN in read-only fields --- frappe/public/js/frappe/form/formatters.js | 4 ++++ 1 file changed, 4 insertions(+) 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); From b1c7821911d438490d906e1c25c1bff52712f436 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:21:56 +0200 Subject: [PATCH 4/4] feat: validate IBAN in backend --- frappe/model/base_document.py | 4 ++++ frappe/tests/test_utils.py | 21 +++++++++++++++++++ frappe/utils/__init__.py | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) 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/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