Merge pull request #24776 from ankush/gst_invoice_validation

fix: Add warning for invalid GST invoice numbers
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 59639ff..f87769c 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -278,6 +278,9 @@
 	('Sales Invoice', 'Sales Order', 'Delivery Note', 'Purchase Invoice', 'Purchase Order', 'Purchase Receipt'): {
 		'validate': ['erpnext.regional.india.utils.set_place_of_supply']
 	},
+	('Sales Invoice', 'Purchase Invoice'): {
+		'validate': ['erpnext.regional.india.utils.validate_document_name']
+	},
 	"Contact": {
 		"on_trash": "erpnext.support.doctype.issue.issue.update_issue",
 		"after_insert": "erpnext.telephony.doctype.call_log.call_log.link_existing_conversations",
diff --git a/erpnext/regional/india/test_utils.py b/erpnext/regional/india/test_utils.py
new file mode 100644
index 0000000..7ce27f6
--- /dev/null
+++ b/erpnext/regional/india/test_utils.py
@@ -0,0 +1,38 @@
+from __future__ import unicode_literals
+
+import unittest
+import frappe
+from unittest.mock import patch
+from erpnext.regional.india.utils import validate_document_name
+
+
+class TestIndiaUtils(unittest.TestCase):
+	@patch("frappe.get_cached_value")
+	def test_validate_document_name(self, mock_get_cached):
+		mock_get_cached.return_value = "India"  # mock country
+		posting_date = "2021-05-01"
+
+		invalid_names = [ "SI$1231", "012345678901234567", "SI 2020 05", 
+				"SI.2020.0001", "PI2021 - 001" ]
+		for name in invalid_names:
+			doc = frappe._dict(name=name, posting_date=posting_date)
+			self.assertRaises(frappe.ValidationError, validate_document_name, doc)
+
+		valid_names = [ "012345678901236", "SI/2020/0001", "SI/2020-0001",
+			"2020-PI-0001", "PI2020-0001" ]
+		for name in valid_names:
+			doc = frappe._dict(name=name, posting_date=posting_date)
+			try:
+				validate_document_name(doc)
+			except frappe.ValidationError:
+				self.fail("Valid name {} throwing error".format(name))
+
+	@patch("frappe.get_cached_value")
+	def test_validate_document_name_not_india(self, mock_get_cached):
+		mock_get_cached.return_value = "Not India"
+		doc = frappe._dict(name="SI$123", posting_date="2021-05-01")
+
+		try:
+			validate_document_name(doc)
+		except frappe.ValidationError:
+			self.fail("Regional validation related to India are being applied to other countries")
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index cb30605..1a618d6 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -2,7 +2,7 @@
 import frappe, re, json
 from frappe import _
 import erpnext
-from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words
+from frappe.utils import cstr, flt, date_diff, nowdate, round_based_on_smallest_currency_fraction, money_in_words, getdate
 from erpnext.regional.india import states, state_numbers
 from erpnext.controllers.taxes_and_totals import get_itemised_tax, get_itemised_taxable_amount
 from erpnext.controllers.accounts_controller import get_taxes_and_charges
@@ -14,6 +14,13 @@
 from erpnext.accounts.utils import get_account_currency
 from frappe.model.utils import get_fetch_values
 
+
+GST_INVOICE_NUMBER_FORMAT = re.compile(r"^[a-zA-Z0-9\-/]+$")   #alphanumeric and - /
+GSTIN_FORMAT = re.compile("^[0-9]{2}[A-Z]{4}[0-9A-Z]{1}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}[1-9A-Z]{1}[0-9A-Z]{1}$")
+GSTIN_UIN_FORMAT = re.compile("^[0-9]{4}[A-Z]{3}[0-9]{5}[0-9A-Z]{3}")
+PAN_NUMBER_FORMAT = re.compile("[A-Z]{5}[0-9]{4}[A-Z]{1}")
+
+
 def validate_gstin_for_india(doc, method):
 	if hasattr(doc, 'gst_state') and doc.gst_state:
 		doc.gst_state_number = state_numbers[doc.gst_state]
@@ -37,12 +44,10 @@
 		frappe.throw(_("Invalid GSTIN! A GSTIN must have 15 characters."))
 
 	if gst_category and gst_category == 'UIN Holders':
-		p = re.compile("^[0-9]{4}[A-Z]{3}[0-9]{5}[0-9A-Z]{3}")
-		if not p.match(doc.gstin):
+		if not GSTIN_UIN_FORMAT.match(doc.gstin):
 			frappe.throw(_("Invalid GSTIN! The input you've entered doesn't match the GSTIN format for UIN Holders or Non-Resident OIDAR Service Providers"))
 	else:
-		p = re.compile("^[0-9]{2}[A-Z]{4}[0-9A-Z]{1}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}[1-9A-Z]{1}[0-9A-Z]{1}$")
-		if not p.match(doc.gstin):
+		if not GSTIN_FORMAT.match(doc.gstin):
 			frappe.throw(_("Invalid GSTIN! The input you've entered doesn't match the format of GSTIN."))
 
 		validate_gstin_check_digit(doc.gstin)
@@ -59,8 +64,7 @@
 	if doc.get('country') != 'India' or not doc.pan:
 		return
 
-	p = re.compile("[A-Z]{5}[0-9]{4}[A-Z]{1}")
-	if not p.match(doc.pan):
+	if not PAN_NUMBER_FORMAT.match(doc.pan):
 		frappe.throw(_("Invalid PAN No. The input you've entered doesn't match the format of PAN."))
 
 def validate_tax_category(doc, method):
@@ -148,6 +152,20 @@
 def set_place_of_supply(doc, method=None):
 	doc.place_of_supply = get_place_of_supply(doc, doc.doctype)
 
+def validate_document_name(doc, method=None):
+	"""Validate GST invoice number requirements."""
+	country = frappe.get_cached_value("Company", doc.company, "country")
+
+	# Date was chosen as start of next FY to avoid irritating current users.
+	if country != "India" or getdate(doc.posting_date) < getdate("2021-04-01"):
+		return
+
+	if len(doc.name) > 16:
+		frappe.throw(_("Maximum length of document number should be 16 characters as per GST rules. Please change the naming series."))
+
+	if not GST_INVOICE_NUMBER_FORMAT.match(doc.name):
+		frappe.throw(_("Document name should only contain alphanumeric values, dash(-) and slash(/) characters as per GST rules. Please change the naming series."))
+
 # don't remove this function it is used in tests
 def test_method():
 	'''test function'''
@@ -800,4 +818,4 @@
 
 	account_list.extend(gst_account_list)
 
-	return account_list
\ No newline at end of file
+	return account_list