diff --git a/README.md b/README.md index 4281e7b4..6ce3bbde 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ App to hold regional code for Germany, built on top of ERPNext. ![Section with Register Information](docs/register_information.png) +- Validation of EU VAT IDs + + + ## Installation ### On Frappe Cloud diff --git a/docs/validate_vat_id.webm b/docs/validate_vat_id.webm new file mode 100644 index 00000000..123682fc Binary files /dev/null and b/docs/validate_vat_id.webm differ diff --git a/erpnext_germany/api.py b/erpnext_germany/api.py new file mode 100644 index 00000000..eb07091f --- /dev/null +++ b/erpnext_germany/api.py @@ -0,0 +1,20 @@ +import frappe + + +@frappe.whitelist() +def validate_vat_id(vat_id: str) -> bool: + """Use the EU VAT checker to validate a VAT ID.""" + from .utils.eu_vat import is_valid_eu_vat_id + + result = frappe.cache().hget("eu_vat_validation", vat_id, shared=True) + if result is not None: + return result + + try: + result = is_valid_eu_vat_id(vat_id) + frappe.cache().hset("eu_vat_validation", vat_id, result, shared=True) + except Exception: + frappe.response["status_code"] = 501 + result = None + + return result diff --git a/erpnext_germany/hooks.py b/erpnext_germany/hooks.py index 0a967351..26e29676 100644 --- a/erpnext_germany/hooks.py +++ b/erpnext_germany/hooks.py @@ -1,7 +1,6 @@ from . import __version__ as app_version from .constants import REGISTER_COURTS - app_name = "erpnext_germany" app_title = "ERPNext Germany" app_publisher = "ALYF GmbH" @@ -17,7 +16,7 @@ # include js, css files in header of desk.html # app_include_css = "/assets/erpnext_germany/css/erpnext_germany.css" -# app_include_js = "/assets/erpnext_germany/js/erpnext_germany.js" +app_include_js = "/assets/erpnext_germany/js/validate_vat_id.js" # include js, css files in header of web template # web_include_css = "/assets/erpnext_germany/css/erpnext_germany.css" @@ -34,7 +33,10 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = { + "Customer": "public/js/customer.js", + "Supplier": "public/js/supplier.js", +} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} diff --git a/erpnext_germany/public/js/customer.js b/erpnext_germany/public/js/customer.js new file mode 100644 index 00000000..5a903a9b --- /dev/null +++ b/erpnext_germany/public/js/customer.js @@ -0,0 +1,5 @@ +frappe.ui.form.on("Customer", { + setup: function (frm) { + erpnext_germany.utils.setup_vat_id_validation_button(frm); + } +}); \ No newline at end of file diff --git a/erpnext_germany/public/js/supplier.js b/erpnext_germany/public/js/supplier.js new file mode 100644 index 00000000..9f77d6d5 --- /dev/null +++ b/erpnext_germany/public/js/supplier.js @@ -0,0 +1,5 @@ +frappe.ui.form.on("Supplier", { + setup: function (frm) { + erpnext_germany.utils.setup_vat_id_validation_button(frm); + } +}); \ No newline at end of file diff --git a/erpnext_germany/public/js/validate_vat_id.js b/erpnext_germany/public/js/validate_vat_id.js new file mode 100644 index 00000000..6930dde9 --- /dev/null +++ b/erpnext_germany/public/js/validate_vat_id.js @@ -0,0 +1,51 @@ + +frappe.provide("erpnext_germany.utils"); + +erpnext_germany.utils.setup_vat_id_validation_button = function (frm) { + const $wrapper = $(frm.fields_dict.tax_id.input_area); + const $link_button = $( + ` + + ${frappe.utils.icon("search", "sm")} + + ` + ); + + $($wrapper).append($link_button); + $link_button.toggle(true); + $link_button.on("click", "a", () => { + const vat_id = frm.doc.tax_id; + erpnext_germany.utils.check_vat_id(vat_id) + .then((is_valid) => { + if (is_valid === true) { + frappe.show_alert({ + message: __("Tax ID is a valid EU VAT ID"), + indicator: "green", + }); + } else if (is_valid === false) { + frappe.show_alert({ + message: __("Tax ID is not a valid EU VAT ID"), + indicator: "red", + }); + } else { + frappe.show_alert({ + message: __("Tax ID could not be validated"), + indicator: "grey", + }); + } + }); + }); +}; + +erpnext_germany.utils.check_vat_id = function (vat_id) { + return frappe.xcall( + "erpnext_germany.api.validate_vat_id", + { vat_id: vat_id }, + ); +}; diff --git a/erpnext_germany/translations/de.csv b/erpnext_germany/translations/de.csv index b0aae0ee..d9838eda 100644 --- a/erpnext_germany/translations/de.csv +++ b/erpnext_germany/translations/de.csv @@ -1,3 +1,7 @@ Register Type,Registerart Register Number,Registernummer Register Court,Registergericht +Validate EU VAT ID,Ust-IdNr. validieren +Tax ID is a valid EU VAT ID,Steuernummer ist gültige Ust-IdNr. +Tax ID is not a valid EU VAT ID,Steuernummer ist keine gültige Ust-IdNr. +Tax ID could not be validated,Steuernummer konnte nicht validiert werden. diff --git a/erpnext_germany/utils/eu_vat.py b/erpnext_germany/utils/eu_vat.py new file mode 100644 index 00000000..097f793a --- /dev/null +++ b/erpnext_germany/utils/eu_vat.py @@ -0,0 +1,12 @@ +import zeep + + +def is_valid_eu_vat_id(vat_id) -> bool: + """Use the EU VAT checker to validate a VAT ID.""" + country_code = vat_id[:2].upper() + vat_number = vat_id[2:].replace(" ", "") + + client = zeep.Client("https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl") + result = client.service.checkVat(vatNumber=vat_number, countryCode=country_code) + + return result.valid diff --git a/erpnext_germany/utils/test_utils.py b/erpnext_germany/utils/test_utils.py new file mode 100644 index 00000000..019c8994 --- /dev/null +++ b/erpnext_germany/utils/test_utils.py @@ -0,0 +1,21 @@ +from unittest import TestCase +from .eu_vat import is_valid_vat_id + + +class TestUtils(TestCase): + def test_validate_vat_id(self): + valid_ids = [ + "DE329035522", # ALYF + "DE210157578", # SAP + ] + invalid_ids = [ + "ABC123", + "Test Test", + "DE1234567890", + ] + + for vat_id in valid_ids: + self.assertTrue(is_valid_vat_id(vat_id)) + + for vat_id in invalid_ids: + self.assertFalse(is_valid_vat_id(vat_id))