diff --git a/india_compliance/gst_india/api_classes/base.py b/india_compliance/gst_india/api_classes/base.py index 9e2b00301a..83511299d7 100644 --- a/india_compliance/gst_india/api_classes/base.py +++ b/india_compliance/gst_india/api_classes/base.py @@ -85,6 +85,9 @@ def get(self, *args, **kwargs): def post(self, *args, **kwargs): return self._make_request("POST", *args, **kwargs) + def put(self, *args, **kwargs): + return self._make_request("PUT", *args, **kwargs) + def _make_request( self, method, @@ -94,7 +97,7 @@ def _make_request( json=None, ): method = method.upper() - if method not in ("GET", "POST"): + if method not in ("GET", "POST", "PUT"): frappe.throw(_("Invalid method {0}").format(method)) request_args = frappe._dict( @@ -108,7 +111,6 @@ def _make_request( ) log_headers = request_args.headers.copy() - log = frappe._dict( **self.default_log_values, url=request_args.url, @@ -116,7 +118,7 @@ def _make_request( request_headers=log_headers, ) - if method == "POST" and json: + if method in ["POST", "PUT"] and json: request_args.json = json json_data = json.copy() @@ -135,6 +137,7 @@ def _make_request( response = requests.request(method, **request_args) if api_request_id := response.headers.get("x-amzn-RequestId"): + self.request_id = api_request_id log.request_id = api_request_id try: @@ -258,7 +261,7 @@ def handle_http_code(self, status_code, response_json): raise GatewayTimeoutError def generate_request_id(self, length=12): - return frappe.generate_hash(length=length) + return f"IC{frappe.generate_hash(length=length - 2)}".upper() def mask_sensitive_info(self, log): request_headers = log.request_headers diff --git a/india_compliance/gst_india/api_classes/taxpayer_base.py b/india_compliance/gst_india/api_classes/taxpayer_base.py index 2533063180..3900abf0b9 100644 --- a/india_compliance/gst_india/api_classes/taxpayer_base.py +++ b/india_compliance/gst_india/api_classes/taxpayer_base.py @@ -115,6 +115,9 @@ class TaxpayerAuthenticate(BaseAPI): # "AUTH4034": "invalid_otp", # Invalid OTP "AUTH4038": "authorization_failed", # Session Expired "TEC4002": "invalid_public_key", + "RET13506": "OTP is either expired or incorrect", + "RET00003": "Return Form already ready to be filed", # Actions performed on portal directly + "RET09001": "Latest Summary is not available. Please generate summary and try again.", # Actions performed on portal directly } def request_otp(self): @@ -189,6 +192,13 @@ def refresh_auth_token(self): endpoint="authenticate", ) + def initiate_otp_for_evc(self, pan, form_type): + return self.get( + action="EVCOTP", + params={"pan": pan, "form_type": form_type}, + endpoint="authenticate", + ) + def decrypt_response(self, response): values = {} @@ -317,6 +327,7 @@ def _request( self, method, action=None, + return_type=None, return_period=None, params=None, endpoint=None, @@ -331,6 +342,10 @@ def _request( return response headers = {"auth-token": auth_token} + if return_type: + headers["rtn_typ"] = return_type + headers["userrole"] = return_type + if return_period: headers["ret_period"] = return_period @@ -353,6 +368,9 @@ def get(self, *args, **kwargs): def post(self, *args, **kwargs): return self._request("post", *args, **kwargs) + def put(self, *args, **kwargs): + return self._request("put", *args, **kwargs) + def before_request(self, request_args): self.encrypt_request(request_args.get("json")) @@ -382,6 +400,23 @@ def decrypt_response(self, response): return response + def encrypt_request(self, json): + if not json: + return + + super().encrypt_request(json) + + if json.get("data"): + b64_data = b64encode(frappe.as_json(json.get("data")).encode()) + json["data"] = aes_encrypt_data(b64_data.decode(), self.session_key) + + if json.get("st") == "EVC": + sid_key = json.get("sid").encode() + json["sign"] = hmac_sha256(b64_data, sid_key) + + else: + json["hmac"] = hmac_sha256(b64_data, self.session_key) + def handle_error_response(self, response): success_value = response.get("status_cd") != 0 diff --git a/india_compliance/gst_india/api_classes/taxpayer_returns.py b/india_compliance/gst_india/api_classes/taxpayer_returns.py index 268fae30d3..dffda10347 100644 --- a/india_compliance/gst_india/api_classes/taxpayer_returns.py +++ b/india_compliance/gst_india/api_classes/taxpayer_returns.py @@ -27,6 +27,30 @@ def download_files(self, return_period, token, otp=None): return_period, token, action="FILEDET", endpoint="returns", otp=otp ) + def get_return_status(self, return_period, reference_id, otp=None): + return self.get( + action="RETSTATUS", + return_period=return_period, + params={"ret_period": return_period, "ref_id": reference_id}, + endpoint="returns", + otp=otp, + ) + + def proceed_to_file(self, return_type, return_period, otp=None): + return self.post( + return_type=return_type, + return_period=return_period, + json={ + "action": "RETNEWPTF", + "data": { + "gstin": self.company_gstin, + "ret_period": return_period, + }, # "isnil": "N" / "Y" + }, + endpoint="returns/gstrptf", + otp=otp, + ) + class GSTR2bAPI(ReturnsAPI): API_NAME = "GSTR-2B" @@ -75,6 +99,7 @@ def setup(self, doc=None, *, company_gstin=None): super().setup(company_gstin=company_gstin) def get_gstr_1_data(self, action, return_period, otp=None): + # action: RETSUM for summary return self.get( action=action, return_period=return_period, @@ -91,3 +116,37 @@ def get_einvoice_data(self, section, return_period, otp=None): endpoint="returns/einvoice", otp=otp, ) + + def save_gstr_1_data(self, return_period, data, otp=None): + return self.put( + return_period=return_period, + json={"action": "RETSAVE", "data": data}, + endpoint="returns/gstr1", + otp=otp, + ) + + def reset_gstr_1_data(self, return_period, otp=None): + return self.post( + return_period=return_period, + json={ + "action": "RESET", + "data": { + "gstin": self.company_gstin, + "ret_period": return_period, + }, + }, + endpoint="returns/gstr1", + otp=otp, + ) + + def file_gstr_1(self, return_period, summary_data, pan, evc_otp): + return self.post( + return_period=return_period, + json={ + "action": "RETFILE", + "data": summary_data, + "st": "EVC", + "sid": f"{pan}|{evc_otp}", + }, + endpoint="returns/gstr1", + ) diff --git a/india_compliance/gst_india/client_scripts/party.js b/india_compliance/gst_india/client_scripts/party.js index f6e7931d56..9f70e26ff4 100644 --- a/india_compliance/gst_india/client_scripts/party.js +++ b/india_compliance/gst_india/client_scripts/party.js @@ -85,15 +85,7 @@ function validate_pan(doctype) { let { pan } = frm.doc; if (!pan || pan.length < 10) return; - if (pan.length > 10) { - frappe.throw(__("PAN should be 10 characters long")); - } - - pan = pan.trim().toUpperCase(); - - if (!PAN_REGEX.test(pan)) { - frappe.throw(__("Invalid PAN format")); - } + pan = india_compliance.validate_pan(pan); frm.doc.pan = pan; frm.refresh_field("pan"); diff --git a/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py b/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py index 3b23b2b4d3..e29b40661d 100644 --- a/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py +++ b/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py @@ -3,15 +3,17 @@ import itertools import frappe -from frappe import unscrub +from frappe import _, unscrub from frappe.utils import flt +from india_compliance.gst_india.api_classes.taxpayer_returns import GSTR1API from india_compliance.gst_india.utils.__init__ import get_month_or_quarter_dict -from india_compliance.gst_india.utils.gstr_1 import GSTR1_SubCategory +from india_compliance.gst_india.utils.gstr_1 import GovJsonKey, GSTR1_SubCategory from india_compliance.gst_india.utils.gstr_1.__init__ import ( CATEGORY_SUB_CATEGORY_MAPPING, SUBCATEGORIES_NOT_CONSIDERED_IN_TOTAL_TAX, SUBCATEGORIES_NOT_CONSIDERED_IN_TOTAL_TAXABLE_VALUE, + GSTR1_Category, GSTR1_DataField, ) from india_compliance.gst_india.utils.gstr_1.gstr_1_download import ( @@ -19,11 +21,19 @@ ) from india_compliance.gst_india.utils.gstr_1.gstr_1_json_map import ( GSTR1BooksData, + convert_to_internal_data_format, summarize_retsum_data, ) MONTH = list(get_month_or_quarter_dict().keys())[4:] QUARTER = ["Jan-Mar", "Apr-Jun", "Jul-Sep", "Oct-Dec"] +status_code_map = { + "P": "Processed", + "PE": "Processed with Errors", + "ER": "Error", + "IP": "In Progress", +} +MAXIMUM_UPLOAD_SIZE = 5200000 class SummarizeGSTR1: @@ -178,9 +188,11 @@ def default_subcategory_summary(self, subcategory): @staticmethod def count_doc_issue_summary(summary_row, data_row): - summary_row["no_of_records"] += data_row.get( - GSTR1_DataField.TOTAL_COUNT.value, 0 - ) - data_row.get(GSTR1_DataField.CANCELLED_COUNT.value, 0) + summary_row["no_of_records"] += ( + data_row.get(GSTR1_DataField.TOTAL_COUNT.value, 0) + - data_row.get(GSTR1_DataField.CANCELLED_COUNT.value, 0) + - data_row.get(GSTR1_DataField.DRAFT_COUNT.value, 0) + ) @staticmethod def count_hsn_summary(summary_row): @@ -229,15 +241,15 @@ def get_reconcile_gstr1_data(self, gov_data, books_data): if not books_subdata and not gov_subdata: continue - is_list = False # Object Type for the subdata_value + # Object Type for the subdata_value + is_list = self.is_list(books_subdata, gov_subdata) + + self.sanitize_books_data(books_subdata, is_list) reconcile_subdata = {} # Books vs Gov for key, books_value in books_subdata.items(): - if not reconcile_subdata: - is_list = isinstance(books_value, list) - gov_value = gov_subdata.get(key) reconcile_row = self.get_reconciled_row(books_value, gov_value) @@ -252,9 +264,6 @@ def get_reconcile_gstr1_data(self, gov_data, books_data): # Update each row in Books Data for row in books_values: - if row.get("upload_status") == "Missing in Books": - continue - if not gov_value: row["upload_status"] = "Not Uploaded" continue @@ -269,9 +278,6 @@ def get_reconcile_gstr1_data(self, gov_data, books_data): if key in books_subdata: continue - if not reconcile_subdata: - is_list = isinstance(gov_value, list) - reconcile_subdata[key] = self.get_reconciled_row(None, gov_value) if not update_books_match: @@ -290,13 +296,21 @@ def get_reconcile_gstr1_data(self, gov_data, books_data): if reconcile_subdata: reconciled_data[subcategory] = reconcile_subdata - if update_books_match: - self.update_json_for("books", books_data) - + self.update_json_for("books", books_data) self.update_json_for("reconcile", reconciled_data) return reconciled_data + def sanitize_books_data(self, books_subdata, is_list): + for key, value in books_subdata.copy().items(): + values = value if is_list else [value] + if values[0].get("upload_status") == "Missing in Books": + del books_subdata[key] + continue + + for row in values: + row.pop("upload_status", None) + @staticmethod def get_reconciled_row(books_row, gov_row): """ @@ -405,6 +419,13 @@ def get_empty_row(row: dict, unrequired_keys=None): return empty_row + @staticmethod + def is_list(books_subdata: dict, gov_subdata: dict): + book_row = next(iter(books_subdata.values()), None) + gov_row = next(iter(gov_subdata.values()), None) + + return isinstance(book_row or gov_row, list) + class AggregateInvoices: IGNORED_FIELDS = {GSTR1_DataField.TAX_RATE.value, GSTR1_DataField.DOC_VALUE.value} @@ -543,7 +564,7 @@ def generate_gstr1_data(self, filters, callback=None): data["books"] = self.normalize_data(books_data) self.summarize_data(data) - return callback and callback(data, filters) + return callback and callback(filters) def generate_only_books_data(self, data, filters, callback=None): status = "Not Filed" @@ -554,7 +575,7 @@ def generate_only_books_data(self, data, filters, callback=None): data["status"] = status self.summarize_data(data) - return callback and callback(data, filters) + return callback and callback(filters) # GET DATA def get_gov_gstr1_data(self): @@ -673,6 +694,396 @@ def normalize_data(data): return data +class FileGSTR1: + def reset_gstr1(self): + # reset called after proceed to file + verify_request_in_progress(self) + + self.db_set({"filing_status": "Not Filed"}) + + api = GSTR1API(self) + response = api.reset_gstr_1_data(self.return_period) + + set_gstr1_actions(self, "reset", response.get("reference_id"), api.request_id) + + def process_reset_gstr1(self): + if not self.actions: + return + + api = GSTR1API(self) + response = None + + doc = self.get_unprocessed_action("reset") + + if not doc: + return + + response = api.get_return_status(self.return_period, doc.token) + + if response.get("status_cd") != "IP": + doc.db_set({"status": status_code_map.get(response.get("status_cd"))}) + enqueue_notification( + self.return_period, + "reset", + response.get("status_cd"), + self.gstin, + ) + + if response.get("status_cd") == "P": + self.update_json_for("unfiled", {}, reset_reconcile=True) + + return response + + def upload_gstr1(self, json_data): + if not json_data: + return + + verify_request_in_progress(self) + + keys = {category.value for category in GovJsonKey} + if all(key not in json_data for key in keys): + frappe.msgprint(_("No data to upload"), indicator="red") + return + + # upload data after proceed to file + self.db_set({"filing_status": "Not Filed"}) + + # remove error file if it exists + self.remove_json_for("upload_error") + + # Make API Request + api = GSTR1API(self) + response = api.save_gstr_1_data(self.return_period, json_data) + + set_gstr1_actions(self, "upload", response.get("reference_id"), api.request_id) + + def process_upload_gstr1(self): + if not self.actions: + return + + api = GSTR1API(self) + response = None + + doc = self.get_unprocessed_action("upload") + + if not doc: + return + + response = api.get_return_status(self.return_period, doc.token) + status_cd = response.get("status_cd") + + if status_cd != "IP": + doc.db_set({"status": status_code_map.get(status_cd)}) + enqueue_notification( + self.return_period, + "upload", + status_cd, + self.gstin, + api.request_id if status_cd == "ER" else None, + ) + + if status_cd == "PE": + response["error_report"] = convert_to_internal_data_format( + response.get("error_report"), True + ) + self.update_json_for("upload_error", response) + + if status_cd == "P": + self.db_set({"filing_status": "Uploaded"}) + + self.update_json_for("unfiled", self.get_json_for("books")) + self.update_json_for("unfiled_summary", self.get_json_for("books_summary")) + + self.update_json_for("reconcile", {}) + self.update_json_for("reconcile_summary", {}) + + return response + + def proceed_to_file_gstr1(self): + verify_request_in_progress(self) + + api = GSTR1API(self) + response = api.proceed_to_file("GSTR1", self.return_period) + + if response.error and response.error.error_cd == "RET00003": + return self.fetch_and_compare_summary(api) + + set_gstr1_actions( + self, "proceed_to_file", response.get("reference_id"), api.request_id + ) + + def process_proceed_to_file_gstr1(self): + if not self.actions: + return + + api = GSTR1API(self) + response = None + + doc = self.get_unprocessed_action("proceed_to_file") + + if not doc: + return + + response = api.get_return_status(self.return_period, doc.token) + + if response.get("status_cd") == "IP": + return response + + doc.db_set({"status": status_code_map.get(response.get("status_cd"))}) + + return self.fetch_and_compare_summary(api, response) + + def fetch_and_compare_summary(self, api, response=None): + if response is None: + response = {} + + summary = api.get_gstr_1_data("RETSUM", self.return_period) + if summary.error: + return + + self.update_json_for("authenticated_summary", summary) + + mapped_summary = self.get_json_for("books_summary") + gov_summary = convert_to_internal_data_format(summary).get("summary") + gov_summary = summarize_retsum_data(gov_summary.values()) + + differing_categories = get_differing_categories(mapped_summary, gov_summary) + + if not differing_categories: + self.db_set({"filing_status": "Ready to File"}) + response["filing_status"] = "Ready to File" + + else: + self.db_set({"filing_status": "Not Filed"}) + response.update( + { + "filing_status": "Not Filed", + "differing_categories": differing_categories, + } + ) + enqueue_notification( + self.return_period, + "proceed_to_file", + response.get("status_cd"), + self.gstin, + api.request_id, + ) + + return response + + def file_gstr1(self, pan, otp): + verify_request_in_progress(self) + + summary = self.get_json_for("authenticated_summary") + api = GSTR1API(self) + response = api.file_gstr_1(self.return_period, summary, pan, otp) + + if response.error and response.error.error_cd == "RET09001": + self.db_set({"filing_status": "Not Filed"}) + self.update_json_for("authenticated_summary", None) + + if response.get("ack_num"): + frappe.db.set_value("GSTIN", self.gstin, "last_pan_used_for_gstr", pan) + self.db_set( + { + "filing_status": "Filed", + "filing_date": frappe.utils.nowdate(), + "acknowledgement_number": response.get("ack_num"), + } + ) + + set_gstr1_actions(self, "file", response.get("ack_num"), api.request_id) + + self.remove_json_for("upload_error") + + # TODO: 2nd phase Accounting Entry. + + return response + + def get_amendment_data(self): + authenticated_summary = convert_to_internal_data_format( + self.get_json_for("authenticated_summary") + ).get("summary") + authenticated_summary = summarize_retsum_data(authenticated_summary.values()) + + non_amended_entries = { + "total_igst_amount": 0, + "total_cgst_amount": 0, + "total_sgst_amount": 0, + "total_cess_amount": 0, + } + amended_liability = {} + + for data in authenticated_summary: + if "Net Liability from Amendments" == data["description"]: + amended_liability = data + elif data.get("consider_in_total_taxable_value") or data.get( + "consider_in_total_tax" + ): + for key, value in data.items(): + if key not in non_amended_entries: + continue + + non_amended_entries[key] += value + + return { + "non_amended_liability": non_amended_entries, + "amended_liability": amended_liability, + } + + +def verify_request_in_progress(return_log): + for row in return_log.actions: + if not row.status: + frappe.throw( + _( + "There is a {0} request in progress. Please wait for the process to complete." + ).format(row.request_type) + ) + + +def get_differing_categories(mapped_summary, gov_summary): + KEYS_TO_COMPARE = { + "total_cess_amount", + "total_cgst_amount", + "total_igst_amount", + "total_sgst_amount", + "total_taxable_value", + } + + # TODO: Check this for all categories + CATEGORY_KEYS = { + (GSTR1_Category.NIL_EXEMPT.value): { + "total_exempted_amount", + "total_nil_rated_amount", + "total_non_gst_amount", + }, + (GSTR1_Category.DOC_ISSUE.value): { + "no_of_records", + }, + } + + IGNORED_CATEGORIES = {"Net Liability from Amendments"} + + gov_summary = {row["description"]: row for row in gov_summary if row["indent"] == 0} + compared_categories = set() + differing_categories = set() + + # This will intentionally skip the row in govt_summary with amended data + for row in mapped_summary: + if row["indent"] != 0: + continue + + category = row["description"] + if category in IGNORED_CATEGORIES: + continue + + compared_categories.add(category) + gov_entry = gov_summary.get(category, {}) + + keys_to_compare = CATEGORY_KEYS.get(category, KEYS_TO_COMPARE) + + for key in keys_to_compare: + if gov_entry.get(key, 0) != row.get(key): + differing_categories.add(category) + break + + for row in gov_summary.values(): + # Amendments are with indent 1. Hence auto-skipped + category = row["description"] + if category in IGNORED_CATEGORIES: + continue + + if category in compared_categories: + continue + + keys_to_compare = CATEGORY_KEYS.get(row["description"], KEYS_TO_COMPARE) + + for key in keys_to_compare: + if row.get(key, 0) != 0: + differing_categories.add(row["description"]) + break + + return differing_categories + + +def set_gstr1_actions(doc, request_type, token, request_id): + if token: + doc.append( + "actions", + { + "request_type": request_type, + "token": token, + "creation_time": frappe.utils.now_datetime(), + }, + ) + doc.save() + enqueue_actions(token, request_id) + + +def enqueue_actions(token, request_id): + frappe.enqueue( + "india_compliance.gst_india.doctype.gst_return_log.generate_gstr_1.add_integration_request", + queue="long", + token=token, + request_id=request_id, + ) + + +def add_integration_request(token, request_id): + doc_name = frappe.db.get_value("Integration Request", {"request_id": request_id}) + if doc_name: + frappe.db.set_value( + "GSTR Action", {"token": token}, {"integration_request": doc_name} + ) + + +def enqueue_notification( + return_period, request_type, status_cd, gstin, request_id=None +): + frappe.enqueue( + "india_compliance.gst_india.doctype.gst_return_log.generate_gstr_1.create_notification", + queue="long", + return_period=return_period, + request_type=request_type, + status_cd=status_cd, + gstin=gstin, + request_id=request_id, + ) + + +def create_notification(return_period, request_type, status_cd, gstin, request_id=None): + # request_id shows failure response + status_message_map = { + "P": f"Data {request_type} for GSTIN {gstin} and return period {return_period} has been successfully completed.", + "PE": f"Data {request_type} for GSTIN {gstin} and return period {return_period} is completed with errors", + "ER": f"Data {request_type} for GSTIN {gstin} and return period {return_period} has encountered errors", + } + + if request_id and ( + doc_name := frappe.db.get_value( + "Integration Request", {"request_id": request_id} + ) + ): + document_type = "Integration Request" + document_name = doc_name + else: + document_type = document_name = "GSTR-1 Beta" + + notification = frappe.get_doc( + { + "doctype": "Notification Log", + "for_user": frappe.session.user, + "type": "Alert", + "document_type": document_type, + "document_name": document_name, + "subject": f"Data {request_type} for GSTIN {gstin} and return period {return_period}", + "email_content": status_message_map.get(status_cd), + } + ) + notification.insert() + + def update_filters(filters, filing_preference): is_quarterly = 1 if filing_preference == "Quarterly" else 0 diff --git a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.js b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.js index 6c5b7b5bf4..5ddf4c5142 100644 --- a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.js +++ b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.js @@ -2,6 +2,24 @@ // For license information, please see license.txt frappe.ui.form.on("GST Return Log", { + onload(frm) { + const attachFields = ['unfiled', 'unfiled_summary', 'filed', 'filed_summary', 'upload_error', 'authenticated_summary', 'books', 'books_summary', 'reconcile', 'reconcile_summary']; + + attachFields.forEach(field => { + $(frm.fields_dict[field].wrapper).on('click', '.control-value a', function (e) { + e.preventDefault(); + + const args = { + cmd: "india_compliance.gst_india.doctype.gst_return_log.gst_return_log.download_file", + file_field: field, + name: frm.doc.name, + doctype: frm.doc.doctype, + file_name: `${field}.gz` + }; + open_url_post(frappe.request.url, args); + }); + }); + }, refresh(frm) { const [month_or_quarter, year] = india_compliance.get_month_year_from_period( frm.doc.return_period, frm.doc.filing_preference === "Monthly" ? 0 : 1 diff --git a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.json b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.json index 95f1bb77df..79689d8653 100644 --- a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.json +++ b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.json @@ -21,9 +21,11 @@ "section_break_oisv", "unfiled", "filed", + "upload_error", "column_break_hxfu", "unfiled_summary", "filed_summary", + "authenticated_summary", "computed_data_section", "is_latest_data", "section_break_emlz", @@ -33,7 +35,9 @@ "reconciled_data_section", "reconcile", "column_break_ndup", - "reconcile_summary" + "reconcile_summary", + "pending_request_info_section", + "actions" ], "fields": [ { @@ -192,7 +196,29 @@ { "fieldname": "filing_preference", "fieldtype": "Data", - "label": "Filing Preference", + "label": "Filing Preference" + }, + { + "fieldname": "upload_error", + "fieldtype": "Attach", + "label": "Upload Error Data", + "read_only": 1 + }, + { + "fieldname": "authenticated_summary", + "fieldtype": "Attach", + "label": "Authenticated Summary", + "read_only": 1 + }, + { + "fieldname": "pending_request_info_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "actions", + "fieldtype": "Table", + "label": "Actions", + "options": "GSTR Action", "read_only": 1 } ], diff --git a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py index 81f83fd71a..52283eb89a 100644 --- a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py +++ b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py @@ -16,6 +16,7 @@ ) from india_compliance.gst_india.doctype.gst_return_log.generate_gstr_1 import ( + FileGSTR1, GenerateGSTR1, ) from india_compliance.gst_india.utils import is_production_api_enabled @@ -23,7 +24,7 @@ DOCTYPE = "GST Return Log" -class GSTReturnLog(GenerateGSTR1, Document): +class GSTReturnLog(GenerateGSTR1, FileGSTR1, Document): @property def status(self): return self.generation_status @@ -195,6 +196,24 @@ def get_applicable_file_fields(self, settings=None): return fields + def get_unprocessed_action(self, action): + for row in self.get("actions") or []: + if row.request_type == action and not row.status: + return row + + +@frappe.whitelist() +def download_file(): + frappe.has_permission("GST Return Log", "read") + + data = frappe._dict(frappe.local.form_dict) + frappe.response["filename"] = data["file_name"] + + file = get_file_doc(data["doctype"], data["name"], data["file_field"]) + frappe.response["filecontent"] = file.get_content(encodings=[]) + + frappe.response["type"] = "download" + def process_gstr_1_returns_info(company, gstin, response): return_info = {} @@ -302,6 +321,7 @@ def get_file_doc(doctype, docname, attached_to_field): ) except frappe.DoesNotExistError: + frappe.clear_last_message() return None diff --git a/india_compliance/gst_india/doctype/gstin/gstin.json b/india_compliance/gst_india/doctype/gstin/gstin.json index a5acf0c6fd..d0bcb0e52a 100644 --- a/india_compliance/gst_india/doctype/gstin/gstin.json +++ b/india_compliance/gst_india/doctype/gstin/gstin.json @@ -14,6 +14,7 @@ "last_updated_on", "cancelled_date", "is_blocked", + "last_pan_used_for_gstr", "section_break_ttzc", "gstr_1_filed_upto" ], @@ -77,12 +78,18 @@ "fieldtype": "Date", "hidden": 1, "label": "GSTR-1 Filed Upto" + }, + { + "fieldname": "last_pan_used_for_gstr", + "fieldtype": "Data", + "hidden": 1, + "label": "Last PAN Used for GSTR" } ], "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2024-06-14 13:59:53.998262", + "modified": "2024-09-04 17:12:30.951878", "modified_by": "Administrator", "module": "GST India", "name": "GSTIN", diff --git a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.css b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.css index 63ba3901e5..c7092e98c6 100644 --- a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.css +++ b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.css @@ -6,6 +6,10 @@ div[data-page-route="GSTR-1 Beta"] [data-fieldname="tabs_html"] .section-body { margin-top: 0; } +div[data-page-route="GSTR-1 Beta"] .section-body { + max-width: 100% !important; +} + div[data-page-route="GSTR-1 Beta"] [data-fieldname="tabs_html"] .form-tabs-list { margin-top: var(--margin-sm); display: flex; @@ -100,13 +104,17 @@ div[data-page-route="GSTR-1 Beta"] .custom-tabs > .nav-item > .nav-link.active { color: var(--primary); } -div[data-page-route="GSTR-1 Beta"] .custom-tabs > .nav-item:first-child:not(:has(.disabled)):hover { +div[data-page-route="GSTR-1 Beta"] + .custom-tabs + > .nav-item:first-child:not(:has(.disabled)):hover { background-color: var(--btn-default-hover-bg); border-radius: 0.4rem 0 0 0.4rem; color: var(--text-color); } -div[data-page-route="GSTR-1 Beta"] .custom-tabs > .nav-item:last-child:not(:has(.disabled)):hover { +div[data-page-route="GSTR-1 Beta"] + .custom-tabs + > .nav-item:last-child:not(:has(.disabled)):hover { background-color: var(--btn-default-hover-bg); border-radius: 0 0.4rem 0.4rem 0; color: var(--text-color); diff --git a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.js b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.js index 37c3c385b2..509b601834 100644 --- a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.js +++ b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.js @@ -39,7 +39,7 @@ const GSTR1_SubCategory = { DOC_ISSUE: "Document Issued", SUPECOM_52: "Liable to collect tax u/s 52(TCS)", - SUPECOM_9_5: "Liable to pay tax u/s 9(5)" + SUPECOM_9_5: "Liable to pay tax u/s 9(5)", }; const INVOICE_TYPE = { @@ -136,6 +136,9 @@ frappe.ui.form.on(DOCTYPE, { ), "orange" ); + frm.page.set_primary_action(__("Generate"), () => + frm.gstr1.gstr1_action.generate_gstr1_data(frm) + ); }); frappe.realtime.on("show_message", message => { @@ -153,7 +156,7 @@ frappe.ui.form.on(DOCTYPE, { }); frappe.realtime.on("gstr1_data_prepared", message => { - const { data, filters } = message; + const { filters } = message; if ( frm.doc.company_gstin !== filters.company_gstin || @@ -166,8 +169,8 @@ frappe.ui.form.on(DOCTYPE, { frm.set_value("is_quarterly", filters.is_quarterly) } - frappe.after_ajax(() => { - frm.doc.__gst_data = data ; + frm.taxpayer_api_call("generate_gstr1").then(r => { + frm.doc.__gst_data = r.message; frm.trigger("load_gstr1_data"); }); }); @@ -203,15 +206,16 @@ frappe.ui.form.on(DOCTYPE, { }, refresh(frm) { - // Primary Action frm.disable_save(); - frm.page.set_primary_action(__("Generate"), () => - frm.taxpayer_api_call("generate_gstr1") - ); - // After indicator set in frappe refresh - if (frm.doc.__gst_data) frm.gstr1.render_indicator(); - else frm.page.clear_indicator(); + frm.gstr1?.render_form_actions(); + + if (!frm.doc.__gst_data) { + frm.page.clear_indicator(); + return; + } + + frm.gstr1.render_indicator(); }, load_gstr1_data(frm) { @@ -250,6 +254,11 @@ class GSTR1 { name: "filed", _TabManager: FiledTab, }, + { + label: __("Error"), + name: "error", + _TabManager: ErrorTab, + }, ]; constructor(frm) { @@ -259,7 +268,7 @@ class GSTR1 { init(frm) { this.frm = frm; - this.data = frm.doc._data; + this.data = null; this.filters = []; this.$wrapper = frm.fields_dict.tabs_html.$wrapper; } @@ -304,11 +313,20 @@ class GSTR1 { return; } + if ( + this.data.status == "Ready to File" && + ["books", "unfiled", "reconcile"].includes(tab_name) + ) { + tab.hide(); + _tab.shown = false; + return; + } + tab.show(); _tab.shown = true; tab.tabmanager.refresh_data( - this.data[tab_name], - this.data[`${tab_name}_summary`], + this.data[tab_name] || {}, + this.data[`${tab_name}_summary`] || [], this.status ); }); @@ -432,9 +450,48 @@ class GSTR1 { const color = this.status === "Filed" ? "green" : "orange"; this.$wrapper.find(`[data-fieldname="filed_tab"]`).html(tab_name); + this.$wrapper.find(`[data-fieldname="error_tab"]`).html("⛔ Error"); this.frm.page.set_indicator(this.status, color); } + render_form_actions() { + this.gstr1_action = new GSTR1Action(this.frm); + + // Custom Buttons + if (this.data) { + if (this.status === "Filed") return; + if (!is_gstr1_api_enabled()) return; + + this.frm.add_custom_button(__("Reset"), () => + this.gstr1_action.reset_gstr1_data() + ); + } + + // Primary Button + const actions = { + Generate: this.gstr1_action.generate_gstr1_data, + Upload: this.gstr1_action.upload_gstr1_data, + "Proceed to File": this.gstr1_action.proceed_to_file, + File: this.gstr1_action.file_gstr1_data, + }; + + let primary_button_label = { + "Not Filed": "Upload", + "Uploaded": "Proceed to File", + "Ready to File": "File", + }[this.status] || "Generate"; + + if (this.status === "Ready to File") { + this.frm.add_custom_button(__("Mark as Unfiled"), () => { + this.gstr1_action.mark_as_unfiled(); + }); + } + + this.frm.page.set_primary_action(__(primary_button_label), () => + actions[primary_button_label].call(this.gstr1_action) + ); + } + // SETUP setup_filter_button() { @@ -668,6 +725,12 @@ class GSTR1 { let element = $('[data-fieldname="data_section"]'); element.prepend(gst_liability_html); } + + show_suggested_jv_dialog() { + // Get JV table + // show in dialog as a confirm dialog + // on Create JV btn => create JV + } } class TabManager { @@ -991,7 +1054,7 @@ class TabManager { ? `

${value}

` - : value; + : value; return value; } @@ -1741,14 +1804,14 @@ class FiledTab extends GSTR1_TabManager { const { include_uploaded, delete_missing } = dialog ? dialog.get_values() : { - include_uploaded: true, - delete_missing: false, - }; + include_uploaded: true, + delete_missing: false, + }; const doc = me.instance.frm.doc; frappe.call({ - method: "india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_export.download_gstr_1_json", + method: "india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_export.get_gstr_1_json", args: { company_gstin: doc.company_gstin, year: doc.year, @@ -1809,7 +1872,7 @@ class FiledTab extends GSTR1_TabManager { render_empty_state(this.instance.frm); this.instance.frm .taxpayer_api_call("mark_as_filed") - .then(() => this.instance.frm.trigger("load_gstr1_data")); + .then(() => this.instance.frm.trigger("load_gstr1_data") && this.instance.show_suggested_jv_dialog()); } // COLUMNS @@ -1983,7 +2046,7 @@ class ReconcileTab extends FiledTab { }); } - get_creation_time_string() {} // pass + get_creation_time_string() { } // pass get_detail_view_column() { return [ @@ -2013,6 +2076,75 @@ class ReconcileTab extends FiledTab { } } +class ErrorTab extends TabManager { + DEFAULT_SUBTITLE = "Fix these errors and upload again"; + set_default_title() { + this.DEFAULT_TITLE = "Error Summary"; + TabManager.prototype.set_default_title.call(this); + } + + get_summary_columns() { + return [ + { + name: "Category", + fieldname: "category", + width: 120, + }, + { + name: "Error Code", + fieldname: "error_code", + width: 100, + }, + { + name: "Error Message", + fieldname: "error_message", + width: 250, + }, + { + name: "Invoice Number", + fieldname: GSTR1_DataField.DOC_NUMBER, + fieldtype: "Link", + options: "Sales Invoice", + width: 150, + }, + { + name: "Party GSTIN", + fieldname: GSTR1_DataField.CUST_GSTIN, + width: 160, + }, + { + name: "Place Of Supply", + fieldname: GSTR1_DataField.POS, + width: 130, + }, + ]; + } + + setup_actions() { } + set_creation_time_string() { } + + refresh_data(data) { + data = data.error_report + super.refresh_data(data, data, "Error Summary"); + $(".dt-footer").remove(); + } + + setup_wrapper() { + this.wrapper.append(` +
+
+
+
 
+
+
+
+ +
+
+ `); + } +} + class DetailViewDialog { CURRENCY_FIELD_MAP = { [GSTR1_DataField.TAXABLE_VALUE]: "Taxable Value", @@ -2087,6 +2219,500 @@ class DetailViewDialog { } } +class FileGSTR1Dialog { + constructor(frm) { + this.frm = frm; + this.filing_dialog = null; + } + + file_gstr1_data() { + if (this.is_request_in_progress()) return; + + // TODO: EVC Generation, Resend, and Filing + this.filing_dialog = new frappe.ui.Dialog({ + title: "File GSTR-1", + fields: [ + { + label: "Company GSTIN", + fieldname: "company_gstin", + fieldtype: "Data", + read_only: 1, + default: this.frm.doc.company_gstin, + }, + { + label: "Period", + fieldname: "period", + fieldtype: "Data", + read_only: 1, + default: `${this.frm.doc.month_or_quarter} - ${this.frm.doc.year}`, + }, + { + label: "Total Liability", + fieldtype: "Section Break", + fieldname: "total_liability_section", + }, + { + fieldname: "liability_breakup_html", + fieldtype: "HTML", + hidden: 1, + }, + { + label: "Sign using EVC", + fieldtype: "Section Break", + }, + { + label: "Authorised PAN", + fieldname: "pan", + fieldtype: "Data", + reqd: 1, + }, + { + label: "EVC OTP", + fieldname: "otp", + fieldtype: "Data", + read_only: 1, + }, + { + label: "I confirm that this GSTR-1 filing cannot be undone and that all details are correct to the best of my knowledge.", + fieldname: "acknowledged", + fieldtype: "Check", + default: 0, + read_only: 1, + }, + ], + primary_action_label: "Get OTP", + primary_action: async () => { + const pan = this.filing_dialog.get_value("pan"); + india_compliance.validate_pan(pan); + + // generate otp + await india_compliance.generate_evc_otp( + this.frm.doc.company_gstin, + pan, + "R1" + ); + + // show otp field + this.filing_dialog.set_df_property("otp", "read_only", 0); + this.filing_dialog.set_df_property("otp", "reqd", 1); + + this.filing_dialog.set_df_property("acknowledged", "read_only", 0); + this.filing_dialog.set_df_property("acknowledged", "reqd", 1); + + this.update_actions_for_filing(pan); + }, + }); + + // get last used pan + frappe.db + .get_value("GSTIN", this.frm.doc.company_gstin, ["last_pan_used_for_gstr"]) + .then(({ message }) => { + const pan_no = + message.last_pan_used_for_gstr || + this.frm.doc.company_gstin.substr(2, 10); + + this.filing_dialog.set_value("pan", pan_no); + }); + + // update total amendes + taxpayer_api.call({ + method: "india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_beta.handle_gstr1_action", + args: { + action: "get_amendment_data", + month_or_quarter: this.frm.doc.month_or_quarter, + year: this.frm.doc.year, + company_gstin: this.frm.doc.company_gstin, + }, + callback: r => { + if (!r.message) return; + const { amended_liability, non_amended_liability } = r.message; + const liability_html = this.generate_liability_table( + amended_liability, + non_amended_liability + ); + const field = this.filing_dialog.get_field("liability_breakup_html"); + + if (!liability_html) return; + field.toggle(true); + + field.df.options = liability_html; + this.filing_dialog.refresh(); + }, + }); + + this.filing_dialog.show(); + } + + generate_liability_table(amended_liability, non_amended_liability) { + let table_html = ` + + + + + + + + + + + + `; + + table_html += this.generate_table_row( + "For current period", + non_amended_liability + ); + table_html += this.generate_table_row("From amendments", amended_liability); + // TODO: Add total row + table_html += ` + +
DescriptionTotal IGSTTotal CGSTTotal SGSTTotal Cess
+ `; + + return table_html; + } + + generate_table_row(description, liability) { + return ` + + ${description} + ${format_currency(liability.total_igst_amount)} + ${format_currency(liability.total_cgst_amount)} + ${format_currency(liability.total_sgst_amount)} + ${format_currency(liability.total_cess_amount)} + + `; + } + + update_actions_for_filing(pan) { + this.filing_dialog.set_primary_action("File", () => { + this.perform_gstr1_action( + "file", + r => this.handle_filing_response(r.message), + { pan: pan, otp: this.filing_dialog.get_value("otp") } + ); + }); + + this.filing_dialog.set_secondary_action_label("Resend OTP"); + this.filing_dialog.set_secondary_action(() => { + india_compliance.generate_evc_otp(this.frm.doc.company_gstin, pan, "R1"); + }); + } + + handle_filing_response(response) { + if (response.error?.error_cd === "RET13506") { + this.filing_dialog + .get_field("otp") + .set_description(`

OTP is either expired or incorrect.

`); + + this.toggle_actions(true); + return; + } + + this.filing_dialog.hide(); + + if (response.error?.error_cd === "RET09001") { + this.frm.page.set_primary_action("Upload", () => + this.upload_gstr1_data() + ); + this.frm.page.set_indicator("Not Filed", "orange"); + this.frm.gstr1.status = "Not Filed"; + frappe.msgprint( + __( + "Latest Summary is not available. Please generate summary and try again." + ) + ); + } + + if (response.ack_num) { + this.frm.taxpayer_api_call("generate_gstr1", { message: "Verifying filed GSTR-1" }).then(r => { + this.frm.doc.__gst_data = r.message; + this.frm.trigger("load_gstr1_data"); + this.frm.gstr1.show_suggested_jv_dialog(); + }); + } + } +} + +class GSTR1Action extends FileGSTR1Dialog { + RETRY_INTERVALS = [2000, 3000, 15000, 30000, 60000, 120000, 300000, 600000, 720000]; // 5 second, 15 second, 30 second, 1 min, 2 min, 5 min, 10 min, 12 min + + constructor(frm) { + super(frm); + this.frm = frm; + this.defaults = { + month_or_quarter: frm.doc.month_or_quarter, + year: frm.doc.year, + company_gstin: frm.doc.company_gstin, + }; + } + + generate_gstr1_data() { + this.frm.taxpayer_api_call("generate_gstr1").then(r => { + if (!r.message) return; + this.frm.doc.__gst_data = r.message; + this.frm.trigger("load_gstr1_data"); + + const request_types = ["upload", "reset", "proceed_to_file"]; + request_types.map(request_type => + this.fetch_status_with_retry(request_type, true) + ); + }); + } + + upload_gstr1_data() { + if (this.is_request_in_progress()) return; + + const action = "upload"; + + frappe.show_alert(__("Uploading data to GSTN")); + this.perform_gstr1_action(action, (response) => { + // No data to upload + if (response._server_messages && response._server_messages.length) { + this.toggle_actions(true); + return + } + + this.fetch_status_with_retry(action) + }); + } + + reset_gstr1_data() { + if (this.is_request_in_progress()) return; + + const action = "reset"; + + frappe.confirm( + __( + "All the details saved in different tables shall be deleted after reset.
Are you sure, you want to reset the already saved data?" + ), + () => { + frappe.show_alert(__("Resetting GSTR-1 data")); + this.perform_gstr1_action(action, () => + this.fetch_status_with_retry(action) + ); + } + ); + } + + proceed_to_file() { + const action = "proceed_to_file"; + this.perform_gstr1_action(action, r => { + // already proceed to file + if (r.message) this.handle_proceed_to_file_response(r.message); + else this.fetch_status_with_retry(action); + }); + } + + mark_as_unfiled() { + if (this.is_request_in_progress()) return; + + const { company, company_gstin, month_or_quarter, year } = this.frm.doc; + const filters = { + "company": company, "company_gstin": company_gstin, "month_or_quarter": month_or_quarter, "year": year + }; + + frappe.call({ + method: "india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_beta.mark_as_unfiled", + args: { filters }, + callback: () => { + this.frm.gstr1.status = "Not Filed"; + this.frm.refresh(); + } + }) + } + + make_journal_entry() { + // TODO: Refactor + const d = new frappe.ui.Dialog({ + title: "Create Journal Entry", + fields: [ + { + fieldname: "auto_submit", + fieldtype: "Check", + label: "Submit After Creation", + }, + ], + primary_action_label: "Create", + primary_action: async (values) => { + const { company, company_gstin, month_or_quarter, year } = this.frm.doc; + + frappe.call({ + method: "india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_beta.make_journal_entry", + args: { company, company_gstin, month_or_quarter, year, auto_submit: values.auto_submit }, + callback: (r) => { + frappe.open_in_new_tab = true; + frappe.set_route("journal-entry", r.message); + d.hide(); + } + }) + } + }) + d.show(); + } + + perform_gstr1_action(action, callback, additional_args = {}) { + this.toggle_actions(false, action); + const args = { + ...this.defaults, + ...additional_args, + action: `${action}_gstr1`, + }; + + taxpayer_api.call({ + method: "india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_beta.handle_gstr1_action", + args: args, + callback: response => callback && callback(response), + }); + } + + fetch_status_with_retry(action, retries = 0, now = false) { + setTimeout( + async () => { + const { message } = await taxpayer_api.call({ + method: `india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_beta.process_gstr1_request`, + args: { ...this.defaults, action }, + }); + if (!message.status_cd) return; + + if (message.status_cd === "IP" && retries < retry_intervals.length) + return this.fetch_status_with_retry(action, retries + 1); + + // Not IP + + if (action == "upload") { + if (message.status_cd == "P") return this.proceed_to_file(); + else if (message.status_cd == "PE") this.show_errors(message); + } + + this.toggle_actions(true); + + if (message.status_cd == "ER") + frappe.throw(__(message.error_report.error_msg)); + + if (action == "reset") { + render_empty_state(this.frm); + this.frm.taxpayer_api_call("generate_gstr1").then(r => { + this.frm.doc.__gst_data = r.message; + this.frm.trigger("load_gstr1_data"); + }); + } + + if (action == "proceed_to_file") { + this.handle_proceed_to_file_response(message); + action = "upload"; // for notification + } + + this.handle_notification(message, action); + }, + now ? 0 : this.RETRY_INTERVALS[retries] + ); + } + + show_errors(message) { + this.frm.gstr1.tabs.error_tab.show(); + this.frm.gstr1.tabs["error_tab"].tabmanager.refresh_data(message); + } + + handle_proceed_to_file_response(response) { + const filing_status = response.filing_status; + if (!filing_status) return; + + // summary matched + if (filing_status == "Ready to File") { + // only show filed tab + ["books", "unfiled", "reconcile"].map(tab => + this.frm.gstr1.tabs[`${tab}_tab`].hide() + ); + this.frm.gstr1.tabs.filed_tab.set_active(); + + this.frm.gstr1.status = "Ready to File"; + this.frm.refresh(); + return; + } + + // summary not matched + this.frm.page.set_primary_action("Upload", () => this.upload_gstr1_data()); + + const differing_categories = response.differing_categories + .map(item => `
  • ${item}
  • `) + .join(""); + + const message = ` +

    ${__( + "Summary for the following categories has not matched. Please sync with GSTIN." + )}

    + + `; + + frappe.msgprint({ + message: message, + indicator: "red", + title: __("GSTIN Sync Required"), + primary_action: { + label: __("Sync with GSTIN"), + action: () => { + render_empty_state(this.frm); + this.frm.taxpayer_api_call("sync_with_gstn", { + sync_for: "unfiled", + }); + }, + }, + }); + } + + handle_notification(response, action) { + const request_status = + action === "proceed_to_file" ? "Proceed to file" : `Data ${action}ing`; + + const status_message_map = { + P: `${request_status} has been successfully completed.`, + PE: `${request_status} is completed with errors`, + ER: `${request_status} has encountered errors`, + IP: `The request for ${request_status} is currently in progress`, + }; + + const alert_message = status_message_map[response.status_cd]; + + const doc = this.frm.doc; + const on_current_document = + window.location.pathname.includes("gstr-1-beta") && + doc.company_gstin == response.company_gstin && + doc.month_or_quarter == response.month_or_quarter && + doc.year == response.year; + + if (!on_current_document) return; + + frappe.show_alert(__(alert_message)); + } + + is_request_in_progress() { + let in_progress = this.frm.__action_performed; + if (!in_progress) return false; + else if (in_progress == "proceed_to_file") in_progress = "upload"; + + frappe.show_alert({ + message: __('Already ' + in_progress + 'ing'), + indicator: "red", + }); + + return true; + } + + toggle_actions(show, action) { + const actions = ["Upload", "Reset", "File", "Mark%20as%20Unfiled"]; + const btns = $(actions.map(action => `[data-label="${action}"]`).join(",")); + + if (show) { + this.frm.__action_performed = null; + btns && btns.removeClass("disabled"); + } else { + this.frm.__action_performed = action; + btns && btns.addClass("disabled"); + } + } +} + // UTILITY FUNCTIONS function is_gstr1_api_enabled() { return ( @@ -2097,7 +2723,7 @@ function is_gstr1_api_enabled() { } function patch_set_indicator(frm) { - frm.toolbar.set_indicator = function () {}; + frm.toolbar.set_indicator = function () { }; } async function set_default_company_gstin(frm) { @@ -2221,6 +2847,12 @@ function render_empty_state(frm) { if ($(".gst-ledger-difference").length) { $(".gst-ledger-difference").remove(); } + + if (frm.gstr1?.data) { + frm.gstr1.data = null; + frm.gstr1.status = null; + } + frm.doc.__gst_data = null; frm.refresh(); } diff --git a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.py b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.py index 02143be138..13443b9d25 100644 --- a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.py +++ b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_beta.py @@ -1,12 +1,15 @@ # Copyright (c) 2024, Resilient Tech and contributors # For license information, please see license.txt +import json from datetime import datetime import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder.functions import Date, Sum +from frappe.query_builder import Case +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Date, IfNull, Sum from frappe.utils import cint, get_last_day, getdate from india_compliance.gst_india.api_classes.taxpayer_base import ( @@ -51,7 +54,7 @@ def mark_as_filed(self): @frappe.whitelist() @otp_handler - def generate_gstr1(self, sync_for=None, recompute_books=False): + def generate_gstr1(self, sync_for=None, recompute_books=False, message=None): period = get_period(self.month_or_quarter, self.year) # get gstr1 log @@ -100,9 +103,10 @@ def generate_gstr1(self, sync_for=None, recompute_books=False): if data: data = data data["status"] = gstr1_log.filing_status or "Not Filed" + if error_data := gstr1_log.get_json_for("upload_error"): + data["error"] = error_data gstr1_log.update_status("Generated") - self.on_generate(data) - return + return data # validate auth token if gstr1_log.is_sek_needed(settings): @@ -113,7 +117,11 @@ def generate_gstr1(self, sync_for=None, recompute_books=False): # generate gstr1 gstr1_log.update_status("In Progress") frappe.enqueue(self._generate_gstr1, queue="short") - frappe.msgprint(_("GSTR-1 is being prepared"), alert=True) + + if not message: + message = "GSTR-1 is being prepared" + + frappe.msgprint(_(message), alert=True) def _generate_gstr1(self): """ @@ -143,7 +151,7 @@ def _generate_gstr1(self): raise e - def on_generate(self, data, filters=None): + def on_generate(self, filters=None): """ Once data is generated, update the status and publish the data """ @@ -157,12 +165,158 @@ def on_generate(self, data, filters=None): frappe.publish_realtime( "gstr1_data_prepared", - message={"data": data, "filters": filters}, + message={"filters": filters}, user=frappe.session.user, doctype=self.doctype, ) +@frappe.whitelist() +@otp_handler +def handle_gstr1_action(action, month_or_quarter, year, company_gstin, **kwargs): + frappe.has_permission("GSTR-1 Beta", "write", throw=True) + + gstr_1_log = frappe.get_doc( + "GST Return Log", + f"GSTR1-{get_period(month_or_quarter, year)}-{company_gstin}", + ) + del kwargs["cmd"] + + if action == "upload_gstr1": + from india_compliance.gst_india.doctype.gstr_1_beta.gstr_1_export import ( + get_gstr_1_json, + ) + + data = get_gstr_1_json( + company_gstin, + year, + month_or_quarter, + delete_missing=True, + ) + kwargs["json_data"] = data.get("data") + + return getattr(gstr_1_log, action)(**kwargs) + + +@frappe.whitelist() +@otp_handler +def process_gstr1_request(month_or_quarter, year, company_gstin, action): + gstr_1_log = frappe.get_doc( + "GST Return Log", + f"GSTR1-{get_period(month_or_quarter, year)}-{company_gstin}", + ) + + method_name = f"process_{action}_gstr1" + data = getattr(gstr_1_log, method_name)() + + if not data: + data = {} + + data.update( + { + "month_or_quarter": month_or_quarter, + "year": year, + "company_gstin": company_gstin, + } + ) + return data + + +@frappe.whitelist() +def mark_as_unfiled(filters): + frappe.has_permission("GST Return Log", "write", throw=True) + + filters = frappe._dict(json.loads(filters)) + log_name = f"GSTR1-{get_period(filters.month_or_quarter, filters.year)}-{filters.company_gstin}" + + frappe.db.set_value("GST Return Log", log_name, "filing_status", "Not Filed") + + +@frappe.whitelist() +def get_journal_entries(month_or_quarter, year, company): + frappe.has_permission("Journal Entry", "read", throw=True) + + from_date, to_date = get_gstr_1_from_and_to_date(month_or_quarter, year) + + journal_entry = frappe.qb.DocType("Journal Entry") + journal_entry_account = frappe.qb.DocType("Journal Entry Account") + + gst_accounts = list( + get_gst_accounts_by_type(company, "Sales Reverse Charge", throw=False).values() + ) + + if not gst_accounts: + return True + + return bool( + frappe.qb.from_(journal_entry) + .join(journal_entry_account) + .on(journal_entry.name == journal_entry_account.parent) + .select( + journal_entry.name, + ) + .where(journal_entry.posting_date.between(getdate(from_date), getdate(to_date))) + .where(journal_entry_account.account.isin(gst_accounts)) + .where(journal_entry.docstatus == 1) + .run() + ) + + +@frappe.whitelist() +def make_journal_entry(company, company_gstin, month_or_quarter, year, auto_submit): + frappe.has_permission("Journal Entry", "write", throw=True) + + from_date, to_date = get_gstr_1_from_and_to_date(month_or_quarter, year) + sales_invoice = frappe.qb.DocType("Sales Invoice") + sales_invoice_taxes = frappe.qb.DocType("Sales Taxes and Charges") + + data = ( + frappe.qb.from_(sales_invoice) + .join(sales_invoice_taxes) + .on(sales_invoice.name == sales_invoice_taxes.parent) + .select( + sales_invoice_taxes.account_head.as_("account"), + ConstantColumn("Sales Invoice").as_("reference_type"), + Case() + .when( + sales_invoice_taxes.tax_amount > 0, Sum(sales_invoice_taxes.tax_amount) + ) + .as_("debit_in_account_currency"), + Case() + .when( + sales_invoice_taxes.tax_amount < 0, + Sum(sales_invoice_taxes.tax_amount * (-1)), + ) + .as_("credit_in_account_currency"), + ) + .where(sales_invoice.is_reverse_charge == 1) + .where( + Date(sales_invoice.posting_date).between( + getdate(from_date), getdate(to_date) + ) + ) + .where(IfNull(sales_invoice_taxes.gst_tax_type, "") != "") + .groupby(sales_invoice_taxes.account_head) + .run(as_dict=True) + ) + + journal_entry = frappe.get_doc( + { + "doctype": "Journal Entry", + "company": company, + "company_gstin": company_gstin, + "posting_date": get_last_day(to_date), + } + ) + journal_entry.extend("accounts", data) + journal_entry.save() + + if auto_submit == "1": + journal_entry.submit() + + return journal_entry.name + + ####### DATA ###################################################################################### diff --git a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_export.py b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_export.py index 85136e4bc8..f43ac0a71c 100644 --- a/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_export.py +++ b/india_compliance/gst_india/doctype/gstr_1_beta/gstr_1_export.py @@ -2043,7 +2043,7 @@ def download_reconcile_as_excel(company_gstin, month_or_quarter, year): @frappe.whitelist() -def download_gstr_1_json( +def get_gstr_1_json( company_gstin, year, month_or_quarter, @@ -2068,6 +2068,7 @@ def download_gstr_1_json( if subcategory in { GSTR1_SubCategory.NIL_EXEMPT.value, GSTR1_SubCategory.HSN.value, + GSTR1_SubCategory.DOC_ISSUE.value, }: continue diff --git a/india_compliance/gst_india/doctype/gstr_action/__init__.py b/india_compliance/gst_india/doctype/gstr_action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/india_compliance/gst_india/doctype/gstr_action/gstr_action.json b/india_compliance/gst_india/doctype/gstr_action/gstr_action.json new file mode 100644 index 0000000000..39dc83d302 --- /dev/null +++ b/india_compliance/gst_india/doctype/gstr_action/gstr_action.json @@ -0,0 +1,61 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-09 17:43:22.979394", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "request_type", + "token", + "status", + "creation_time", + "integration_request" + ], + "fields": [ + { + "fieldname": "request_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Request Type" + }, + { + "fieldname": "token", + "fieldtype": "Data", + "hidden": 1, + "label": "Token" + }, + { + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Status" + }, + { + "fieldname": "creation_time", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Creation Time" + }, + { + "fieldname": "integration_request", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Integration Request", + "options": "Integration Request", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-09-12 12:36:05.679413", + "modified_by": "Administrator", + "module": "GST India", + "name": "GSTR Action", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/gstr_action/gstr_action.py b/india_compliance/gst_india/doctype/gstr_action/gstr_action.py new file mode 100644 index 0000000000..f267f39ec4 --- /dev/null +++ b/india_compliance/gst_india/doctype/gstr_action/gstr_action.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Resilient Tech and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class GSTRAction(Document): + pass diff --git a/india_compliance/gst_india/utils/gstr_1/__init__.py b/india_compliance/gst_india/utils/gstr_1/__init__.py index 66718a0a89..c4fd2e4924 100644 --- a/india_compliance/gst_india/utils/gstr_1/__init__.py +++ b/india_compliance/gst_india/utils/gstr_1/__init__.py @@ -124,6 +124,9 @@ class GSTR1_DataField(Enum): NET_ISSUE = "net_issue" UPLOAD_STATUS = "upload_status" + ERROR_CD = "error_code" + ERROR_MSG = "error_message" + class GSTR1_ItemField(Enum): INDEX = "idx" @@ -194,6 +197,9 @@ class GovDataField(Enum): SUPECOM_52 = "clttx" SUPECOM_9_5 = "paytx" + ERROR_CD = "error_cd" + ERROR_MSG = "error_msg" + FLAG = "flag" diff --git a/india_compliance/gst_india/utils/gstr_1/gstr_1_json_map.py b/india_compliance/gst_india/utils/gstr_1/gstr_1_json_map.py index 0008a7956b..ca81824650 100644 --- a/india_compliance/gst_india/utils/gstr_1/gstr_1_json_map.py +++ b/india_compliance/gst_india/utils/gstr_1/gstr_1_json_map.py @@ -98,7 +98,11 @@ def format_data( output = {} if default_data: - output.update(default_data) + for key, value in default_data.items(): + if not (value or value == 0): + continue + + output[key] = value key_mapping = self.KEY_MAPPING.copy() @@ -315,6 +319,12 @@ def convert_to_internal_data_format(self, input_data): GSTR1_DataField.CUST_NAME.value: self.guess_customer_name( customer_gstin ), + GSTR1_DataField.ERROR_CD.value: customer_data.get( + GovDataField.ERROR_CD.value + ), + GSTR1_DataField.ERROR_MSG.value: customer_data.get( + GovDataField.ERROR_MSG.value + ), } for invoice in customer_data.get(GovDataField.INVOICES.value): @@ -466,6 +476,12 @@ def convert_to_internal_data_format(self, input_data): default_invoice_data = { GSTR1_DataField.POS.value: pos, GSTR1_DataField.DOC_TYPE.value: self.DOCUMENT_CATEGORY, + GSTR1_DataField.ERROR_CD.value: pos_data.get( + GovDataField.ERROR_CD.value + ), + GSTR1_DataField.ERROR_MSG.value: pos_data.get( + GovDataField.ERROR_MSG.value + ), } for invoice in pos_data.get(GovDataField.INVOICES.value): @@ -600,6 +616,12 @@ def convert_to_internal_data_format(self, input_data): default_invoice_data = { GSTR1_DataField.DOC_TYPE.value: document_type, + GSTR1_DataField.ERROR_CD.value: export_category.get( + GovDataField.ERROR_CD.value + ), + GSTR1_DataField.ERROR_MSG.value: export_category.get( + GovDataField.ERROR_MSG.value + ), } for invoice in export_category.get(GovDataField.INVOICES.value): @@ -694,6 +716,8 @@ class B2CS(GovDataMapper): GovDataField.CGST.value: GSTR1_DataField.CGST.value, GovDataField.SGST.value: GSTR1_DataField.SGST.value, GovDataField.CESS.value: GSTR1_DataField.CESS.value, + GovDataField.ERROR_CD.value: GSTR1_DataField.ERROR_CD.value, + GovDataField.ERROR_MSG.value: GSTR1_DataField.ERROR_MSG.value, } def __init__(self): @@ -804,8 +828,15 @@ def __init__(self): def convert_to_internal_data_format(self, input_data): output = {} + default_data = { + GSTR1_DataField.ERROR_CD.value: input_data.get(GovDataField.ERROR_CD.value), + GSTR1_DataField.ERROR_MSG.value: input_data.get( + GovDataField.ERROR_MSG.value + ), + } + for invoice in input_data[GovDataField.INVOICES.value]: - invoice_data = self.format_data(invoice) + invoice_data = self.format_data(invoice, default_data) if not invoice_data: continue @@ -968,6 +999,12 @@ def convert_to_internal_data_format(self, input_data): GSTR1_DataField.CUST_NAME.value: self.guess_customer_name( customer_gstin ), + GSTR1_DataField.ERROR_CD.value: customer_data.get( + GovDataField.ERROR_CD.value + ), + GSTR1_DataField.ERROR_MSG.value: customer_data.get( + GovDataField.ERROR_MSG.value + ), }, ) self.update_totals( @@ -1097,6 +1134,8 @@ class CDNUR(GovDataMapper): GovDataField.TAXABLE_VALUE.value: GSTR1_ItemField.TAXABLE_VALUE.value, GovDataField.IGST.value: GSTR1_ItemField.IGST.value, GovDataField.CESS.value: GSTR1_ItemField.CESS.value, + GovDataField.ERROR_CD.value: GSTR1_DataField.ERROR_CD.value, + GovDataField.ERROR_MSG.value: GSTR1_DataField.ERROR_MSG.value, } DOCUMENT_TYPES = { "C": "Credit Note", @@ -1239,6 +1278,13 @@ def __init__(self): def convert_to_internal_data_format(self, input_data): output = {} + default_data = { + GSTR1_DataField.ERROR_CD.value: input_data.get(GovDataField.ERROR_CD.value), + GSTR1_DataField.ERROR_MSG.value: input_data.get( + GovDataField.ERROR_MSG.value + ), + } + for invoice in input_data[GovDataField.HSN_DATA.value]: output[ " - ".join( @@ -1248,7 +1294,7 @@ def convert_to_internal_data_format(self, input_data): str(flt(invoice.get(GovDataField.TAX_RATE.value))), ) ) - ] = self.format_data(invoice) + ] = self.format_data(invoice, default_data) return {self.SUBCATEGORY: output} @@ -1345,6 +1391,8 @@ class AT(GovDataMapper): GovDataField.CGST.value: GSTR1_DataField.CGST.value, GovDataField.SGST.value: GSTR1_DataField.SGST.value, GovDataField.CESS.value: GSTR1_DataField.CESS.value, + GovDataField.ERROR_CD.value: GSTR1_DataField.ERROR_CD.value, + GovDataField.ERROR_MSG.value: GSTR1_DataField.ERROR_MSG.value, } DEFAULT_ITEM_AMOUNTS = { GSTR1_DataField.IGST.value: 0, @@ -1864,7 +1912,7 @@ def map_document_types(self, doc_type, *args): } -def convert_to_internal_data_format(gov_data): +def convert_to_internal_data_format(gov_data, for_errors=False): """ Converts Gov data format to internal data format for all categories """ @@ -1878,7 +1926,16 @@ def convert_to_internal_data_format(gov_data): mapper_class().convert_to_internal_data_format(gov_data.get(category)) ) - return output + if not for_errors: + return output + + errors = [] + for category, data in output.items(): + for row in data.values(): + row["category"] = category + errors.append(row) + + return errors def get_category_wise_data( diff --git a/india_compliance/gst_india/utils/gstr_utils.py b/india_compliance/gst_india/utils/gstr_utils.py index f0d68345f3..67ae50e802 100644 --- a/india_compliance/gst_india/utils/gstr_utils.py +++ b/india_compliance/gst_india/utils/gstr_utils.py @@ -42,6 +42,12 @@ def authenticate_otp(company_gstin, otp): return api.process_response(response) +@frappe.whitelist() +def generate_evc_otp(company_gstin, pan, request_type): + frappe.has_permission("GSTR-1 Beta", "write", throw=True) + return TaxpayerBaseAPI(company_gstin).initiate_otp_for_evc(pan, request_type) + + def download_queued_request(): queued_requests = frappe.get_all( "GSTR Import Log", diff --git a/india_compliance/public/js/gst_api_handler.js b/india_compliance/public/js/gst_api_handler.js index 207fd061e6..47a7e2eb9a 100644 --- a/india_compliance/public/js/gst_api_handler.js +++ b/india_compliance/public/js/gst_api_handler.js @@ -77,6 +77,17 @@ Object.assign(india_compliance, { return true; } }, + + generate_evc_otp(company_gstin, pan, request_type) { + return frappe.call({ + method: "india_compliance.gst_india.utils.gstr_utils.generate_evc_otp", + args: { + company_gstin: company_gstin, + pan: pan, + request_type: request_type, + }, + }); + }, }); class IndiaComplianceForm extends frappe.ui.form.Form { diff --git a/india_compliance/public/js/regex_constants.js b/india_compliance/public/js/regex_constants.js index a442b826aa..5b8d1f19d8 100644 --- a/india_compliance/public/js/regex_constants.js +++ b/india_compliance/public/js/regex_constants.js @@ -19,3 +19,4 @@ export const GSTIN_REGEX = new RegExp( ); export const GST_INVOICE_NUMBER_FORMAT = new RegExp("^[^\\W_][A-Za-z\\d\\-/]{0,15}$"); +export const PAN_REGEX = new RegExp("^[A-Z]{5}[0-9]{4}[A-Z]{1}$"); \ No newline at end of file diff --git a/india_compliance/public/js/utils.js b/india_compliance/public/js/utils.js index 1d8cb79120..c82e13d1df 100644 --- a/india_compliance/public/js/utils.js +++ b/india_compliance/public/js/utils.js @@ -6,6 +6,7 @@ import { TDS_REGEX, TCS_REGEX, GST_INVOICE_NUMBER_FORMAT, + PAN_REGEX, } from "./regex_constants"; frappe.provide("india_compliance"); @@ -224,6 +225,22 @@ Object.assign(india_compliance, { return india_compliance.is_api_enabled() && gst_settings.enable_e_invoice; }, + validate_pan(pan) { + if (!pan) return; + + pan = pan.trim().toUpperCase(); + + if (pan.length != 10) { + frappe.throw(__("PAN should be 10 characters long")); + } + + if (!PAN_REGEX.test(pan)) { + frappe.throw(__("Invalid PAN format")); + } + + return pan; + }, + validate_gstin(gstin) { if (!gstin || gstin.length !== 15) { frappe.msgprint(__("GSTIN must be 15 characters long")); @@ -353,12 +370,10 @@ Object.assign(india_compliance, { return position === "start" ? `${current_year - 1}-03-01` : `${current_year - 1}-09-30`; - } else if (current_month <= 9) { return position === "start" ? `${current_year - 1}-10-01` : `${current_year}-03-31`; - } else { return position === "start" ? `${current_year}-04-01`