diff --git a/india_compliance/audit_trail/report/audit_trail/audit_trail.py b/india_compliance/audit_trail/report/audit_trail/audit_trail.py index 37000d094..5565f3104 100644 --- a/india_compliance/audit_trail/report/audit_trail/audit_trail.py +++ b/india_compliance/audit_trail/report/audit_trail/audit_trail.py @@ -69,7 +69,7 @@ def get_columns(self): def get_data(self): pass - def append_rows(self): + def append_rows(self, new_count, modified_count, doctype): pass def get_conditions(self): diff --git a/india_compliance/audit_trail/setup.py b/india_compliance/audit_trail/setup.py index fae387a22..4001102c1 100644 --- a/india_compliance/audit_trail/setup.py +++ b/india_compliance/audit_trail/setup.py @@ -17,17 +17,20 @@ def setup_fixtures(): def create_property_setters_for_versioning(): for doctype in get_audit_trail_doctypes(): + property_setter_data = { + "doctype_or_field": "DocType", + "doc_type": doctype, + "property": "track_changes", + "value": "1", + "property_type": "Check", + "is_system_generated": 1, + } + + if frappe.db.exists("Property Setter", property_setter_data): + continue + property_setter = frappe.new_doc("Property Setter") - property_setter.update( - { - "doctype_or_field": "DocType", - "doc_type": doctype, - "property": "track_changes", - "value": "1", - "property_type": "Check", - "is_system_generated": 1, - } - ) + property_setter.update(property_setter_data) property_setter.flags.ignore_permissions = True property_setter.insert() diff --git a/india_compliance/gst_india/api_classes/base.py b/india_compliance/gst_india/api_classes/base.py index 1d6a8f87f..fb1b6c90c 100644 --- a/india_compliance/gst_india/api_classes/base.py +++ b/india_compliance/gst_india/api_classes/base.py @@ -16,7 +16,7 @@ class BaseAPI: API_NAME = "GST" BASE_PATH = "" - SENSITIVE_HEADERS = ("x-api-key",) + SENSITIVE_INFO = ("x-api-key",) def __init__(self, *args, **kwargs): self.settings = frappe.get_cached_doc("GST Settings") @@ -58,6 +58,9 @@ def fetch_credentials(self, gstin, service, require_password=True): self.username = row.username self.company = row.company + self._fetch_credentials(row, require_password=require_password) + + def _fetch_credentials(self, row, require_password=True): self.password = row.get_password(raise_exception=require_password) def get_url(self, *parts): @@ -94,7 +97,6 @@ def _make_request( params=params, headers={ # auto-generated hash, required by some endpoints - "requestid": self.generate_request_id(), **self.default_headers, **(headers or {}), }, @@ -102,11 +104,6 @@ def _make_request( log_headers = request_args.headers.copy() - # Mask sensitive headers - for header in self.SENSITIVE_HEADERS: - if header in log_headers: - log_headers[header] = "*****" - log = frappe._dict( **self.default_log_values, url=request_args.url, @@ -117,17 +114,20 @@ def _make_request( if method == "POST" and json: request_args.json = json + json_data = json.copy() if not request_args.params: - log.data = json + log.data = json_data else: log.data = { "params": request_args.params, - "body": json, + "body": json_data, } response_json = None try: + self.before_request(request_args) + response = requests.request(method, **request_args) if api_request_id := response.headers.get("x-amzn-RequestId"): log.request_id = api_request_id @@ -145,23 +145,15 @@ def _make_request( # Expect all successful responses to be JSON if not response_json: - frappe.throw(_("Error parsing response: {0}").format(response.content)) - else: - self.response = response_json - - # All error responses have a success key set to false - success_value = response_json.get("success", True) - if isinstance(success_value, str): - success_value = sbool(success_value) - - if not success_value and not self.handle_failed_response(response_json): - frappe.throw( - response_json.get("message") - # Fallback to response body if message is not present - or frappe.as_json(response_json, indent=4), - title=_("API Request Failed"), - ) + if "tar.gz" in request_args.url: + response_json = response.content + else: + frappe.throw( + _("Error parsing response: {0}").format(response.content) + ) + + response_json = self.process_response(response_json) return response_json.get("result", response_json) except Exception as e: @@ -169,7 +161,9 @@ def _make_request( raise e finally: - log.output = response_json + log.output = response_json.copy() + self.mask_sensitive_info(log) + enqueue_integration_request(**log) if self.sandbox_mode and not frappe.flags.ic_sandbox_message_shown: @@ -179,7 +173,29 @@ def _make_request( ) frappe.flags.ic_sandbox_message_shown = True - def handle_failed_response(self, response_json): + def before_request(self, request_args): + return + + def process_response(self, response): + self.handle_error_response(response) + self.response = response + return response + + def handle_error_response(self, response_json): + # All error responses have a success key set to false + success_value = response_json.get("success", True) + if isinstance(success_value, str): + success_value = sbool(success_value) + + if not success_value and not self.is_ignored_error(response_json): + frappe.throw( + response_json.get("message") + # Fallback to response body if message is not present + or frappe.as_json(response_json, indent=4), + title=_("API Request Failed"), + ) + + def is_ignored_error(self, response_json): # Override in subclass, return truthy value to stop frappe.throw pass @@ -219,3 +235,24 @@ def handle_http_code(self, status_code, response_json): def generate_request_id(self, length=12): return frappe.generate_hash(length=length) + + def mask_sensitive_info(self, log): + for key in self.SENSITIVE_INFO: + if key in log.request_headers: + log.request_headers[key] = "*****" + + if key in log.output: + log.output[key] = "*****" + + if not log.data: + return + + if key in log.get("data", {}): + log.data[key] = "*****" + + if key in log.get("data", {}).get("body", {}): + log.data["body"][key] = "*****" + + +def get_public_ip(): + return requests.get("https://api.ipify.org").text diff --git a/india_compliance/gst_india/api_classes/e_invoice.py b/india_compliance/gst_india/api_classes/e_invoice.py index 55f9179d6..6a90aa4c8 100644 --- a/india_compliance/gst_india/api_classes/e_invoice.py +++ b/india_compliance/gst_india/api_classes/e_invoice.py @@ -10,7 +10,7 @@ class EInvoiceAPI(BaseAPI): API_NAME = "e-Invoice" BASE_PATH = "ei/api" - SENSITIVE_HEADERS = BaseAPI.SENSITIVE_HEADERS + ("password",) + SENSITIVE_INFO = BaseAPI.SENSITIVE_INFO + ("password",) IGNORED_ERROR_CODES = { # Generate IRN errors "2150": "Duplicate IRN", @@ -21,6 +21,9 @@ class EInvoiceAPI(BaseAPI): # Cancel IRN errors "9999": "Invoice is not active", "4002": "EwayBill is already generated for this IRN", + # Invalid GSTIN error + "3028": "GSTIN is invalid", + "3029": "GSTIN is not active", } def setup(self, doc=None, *, company_gstin=None): @@ -50,10 +53,11 @@ def setup(self, doc=None, *, company_gstin=None): "gstin": company_gstin, "user_name": self.username, "password": self.password, + "requestid": self.generate_request_id(), } ) - def handle_failed_response(self, response_json): + def is_ignored_error(self, response_json): message = response_json.get("message", "").strip() for error_code in self.IGNORED_ERROR_CODES: @@ -103,3 +107,6 @@ def update_distance(self, result): def get_gstin_info(self, gstin): return self.get(endpoint="master/gstin", params={"gstin": gstin}) + + def sync_gstin_info(self, gstin): + return self.get(endpoint="master/syncgstin", params={"gstin": gstin}) diff --git a/india_compliance/gst_india/api_classes/e_waybill.py b/india_compliance/gst_india/api_classes/e_waybill.py index a7a0da669..4d096dba4 100644 --- a/india_compliance/gst_india/api_classes/e_waybill.py +++ b/india_compliance/gst_india/api_classes/e_waybill.py @@ -10,7 +10,7 @@ class EWaybillAPI(BaseAPI): API_NAME = "e-Waybill" BASE_PATH = "ewb/ewayapi" - SENSITIVE_HEADERS = BaseAPI.SENSITIVE_HEADERS + ("password",) + SENSITIVE_INFO = BaseAPI.SENSITIVE_INFO + ("password",) IGNORED_ERROR_CODES = { # Cancel e-waybill errors "312": "This eway bill is either not generated by you or cancelled", @@ -45,6 +45,7 @@ def setup(self, doc=None, *, company_gstin=None): "gstin": company_gstin, "username": self.username, "password": self.password, + "requestid": self.generate_request_id(), } ) @@ -82,7 +83,7 @@ def update_distance(self, result): ): result.distance = int(distance_match.group()) - def handle_failed_response(self, response_json): + def is_ignored_error(self, response_json): message = response_json.get("message", "") for error_code, error_message in self.IGNORED_ERROR_CODES.items(): diff --git a/india_compliance/gst_india/api_classes/public.py b/india_compliance/gst_india/api_classes/public.py index f9c8b2f03..f45a289a5 100644 --- a/india_compliance/gst_india/api_classes/public.py +++ b/india_compliance/gst_india/api_classes/public.py @@ -15,6 +15,7 @@ def setup(self): "Autofill Party Information based on GSTIN is not supported in sandbox mode" ) ) + self.default_headers.update({"requestid": self.generate_request_id()}) def get_gstin_info(self, gstin): response = self.get("search", params={"action": "TP", "gstin": gstin}) diff --git a/india_compliance/gst_india/api_classes/returns.py b/india_compliance/gst_india/api_classes/returns.py index d0baaccdf..6f6f63145 100644 --- a/india_compliance/gst_india/api_classes/returns.py +++ b/india_compliance/gst_india/api_classes/returns.py @@ -1,8 +1,196 @@ -from india_compliance.gst_india.api_classes.base import BaseAPI +import json +from base64 import b64decode, b64encode +from cryptography import x509 +from cryptography.hazmat.backends import default_backend -class ReturnsAPI(BaseAPI): +import frappe +from frappe import _ +from frappe.utils import add_to_date, cint, now_datetime + +from india_compliance.gst_india.api_classes.base import BaseAPI, get_public_ip +from india_compliance.gst_india.utils import merge_dicts, tar_gz_bytes_to_data +from india_compliance.gst_india.utils.cryptography import ( + aes_decrypt_data, + aes_encrypt_data, + encrypt_using_public_key, + hash_sha256, + hmac_sha256, +) + + +class PublicCertificate(BaseAPI): + BASE_PATH = "static" + + def get_gstn_public_certificate(self) -> str: + response = self.get(endpoint="gstn_g2b_prod_public") + self.settings.db_set("gstn_public_certificate", response.certificate) + + return response.certificate + + +class FilesAPI(BaseAPI): + BASE_PATH = "standard/gstn/files" + + def get_all(self, url_details): + response = frappe._dict() + self.encryption_key = b64decode(url_details.ek) + + for row in url_details.urls: + self.hash = row.get("hash") + self.ul = row.get("ul") + data = self.get(endpoint=self.ul) + + if not response: + response = data + else: + merge_dicts(response, data) + + return response + + def process_response(self, response): + computed_hash = hash_sha256(response) + if computed_hash != self.hash: + frappe.throw( + _( + "Hash of file doesn't match for {0}. File may be corrupted or tampered." + ).format(self.ul) + ) + + encrypted_data = tar_gz_bytes_to_data(response) + data = self.decrypt_data(encrypted_data) + data = json.loads(data, object_hook=frappe._dict) + + return data + + def decrypt_data(self, encrypted_json): + data = aes_decrypt_data(encrypted_json, self.encryption_key) + return b64decode(data).decode() + + +class ReturnsAuthenticate(BaseAPI): + def request_otp(self): + response = super().post( + json={ + "action": "OTPREQUEST", + "app_key": self.app_key, + "username": self.username, + }, + endpoint="authenticate", + ) + + if response.status_cd != 1: + return + + return response.update({"error_type": "otp_requested"}) + + def autheticate_with_otp(self, otp=None): + if not otp: + # reset auth token + frappe.db.set_value( + "GST Credential", + { + "gstin": self.company_gstin, + "username": self.username, + "service": "Returns", + }, + {"auth_token": None}, + ) + + self.auth_token = None + return self.request_otp() + + return super().post( + json={ + "action": "AUTHTOKEN", + "app_key": self.app_key, + "username": self.username, + "otp": otp, + }, + endpoint="authenticate", + ) + + def refresh_auth_token(self, auth_token): + return super().post( + json={ + "action": "REFRESHTOKEN", + "app_key": self.app_key, + "username": self.username, + "auth_token": auth_token, + }, + endpoint="authenticate", + ) + + def decrypt_response(self, response): + values = {} + + if response.get("auth_token"): + self.auth_token = response.auth_token + values["auth_token"] = response.auth_token + + if response.get("expiry"): + session_expiry = add_to_date( + None, minutes=cint(response.expiry), as_datetime=True + ) + self.session_expiry = session_expiry + values["session_expiry"] = session_expiry + + if response.get("sek"): + session_key = aes_decrypt_data(response.sek, self.app_key) + self.session_key = session_key + values["session_key"] = b64encode(session_key).decode() + + if values: + frappe.db.set_value( + "GST Credential", + { + "gstin": self.company_gstin, + "username": self.username, + "service": "Returns", + }, + values, + ) + + return response + + def encrypt_request(self, json): + if not json: + return + + if json.get("app_key"): + json["app_key"] = encrypt_using_public_key( + self.app_key, self.get_public_certificate() + ) + + if json.get("otp"): + json["otp"] = aes_encrypt_data(json.get("otp"), self.app_key) + + def get_public_certificate(self): + certificate = self.settings.gstn_public_certificate + + if not certificate: + certificate = PublicCertificate().get_gstn_public_certificate() + + cert = x509.load_pem_x509_certificate(certificate.encode(), default_backend()) + valid_up_to = cert.not_valid_after + + if valid_up_to < now_datetime(): + certificate = PublicCertificate().get_gstn_public_certificate() + + return certificate.encode() + + +class ReturnsAPI(ReturnsAuthenticate): API_NAME = "GST Returns" + BASE_PATH = "standard/gstn" + SENSITIVE_INFO = BaseAPI.SENSITIVE_INFO + ( + "auth-token", + "auth_token", + "app_key", + "sek", + "rek", + ) + IGNORED_ERROR_CODES = { "RETOTPREQUEST": "otp_requested", "EVCREQUEST": "otp_requested", @@ -14,9 +202,18 @@ class ReturnsAPI(BaseAPI): "RET2B1016": "no_docs_found", "RT-3BAS1009": "no_docs_found", "RET2B1018": "requested_before_cutoff_date", + "RTN_24": "queued", + "AUTH4033": "invalid_otp", # Invalid Session + # "AUTH4034": "invalid_otp", # Invalid OTP + "AUTH4038": "authorization_failed", # Session Expired + "RET11402": "authorization_failed", # API Authorization Failed for 2A + "RET2B1010": "authorization_failed", # API Authorization Failed for 2B } def setup(self, company_gstin): + if self.sandbox_mode: + frappe.throw(_("Sandbox mode not supported for Returns API")) + self.company_gstin = company_gstin self.fetch_credentials(self.company_gstin, "Returns", require_password=False) self.default_headers.update( @@ -24,40 +221,163 @@ def setup(self, company_gstin): "gstin": self.company_gstin, "state-cd": self.company_gstin[:2], "username": self.username, + "ip-usr": frappe.cache.hget("public_ip", "public_ip", get_public_ip), + "txn": self.generate_request_id(length=32), } ) - def handle_failed_response(self, response_json): - error_code = response_json.get("errorCode") + def _fetch_credentials(self, row, require_password=True): + self.app_key = row.app_key or self.generate_app_key() + self.auth_token = row.auth_token + self.session_key = b64decode(row.session_key or "") + self.session_expiry = row.session_expiry + + def _request( + self, + method, + action, + return_period=None, + params=None, + endpoint=None, + json=None, + otp=None, + ): + auth_token = self.get_auth_token() + + if not auth_token: + response = self.autheticate_with_otp(otp=otp) + if response.error_type in ["otp_requested", "invalid_otp"]: + return response + + headers = {"auth-token": auth_token} + if return_period: + headers["ret_period"] = return_period + + response = getattr(super(), method)( + params={"action": action, **(params or {})}, + headers=headers, + json=json, + endpoint=endpoint, + ) + + if response.error_type == "authorization_failed": + return self.autheticate_with_otp() + + return response + + def get(self, action, return_period, params=None, endpoint=None, otp=None): + params = {"gstin": self.company_gstin, **(params or {})} + return self._request("get", action, return_period, params, endpoint, None, otp) + + def post(self, action, params=None, endpoint=None, json=None, otp=None): + return self._request("post", action, None, params, endpoint, json, otp) + + def before_request(self, request_args): + self.encrypt_request(request_args.get("json")) + + def process_response(self, response): + self.handle_error_response(response) + response = self.decrypt_response(response) + return response + + def decrypt_response(self, response): + decrypted_rek = None + + if response.get("auth_token"): + return super().decrypt_response(response) + + if response.get("rek"): + decrypted_rek = aes_decrypt_data(response.rek, self.session_key) + + if response.get("data"): + decrypted_data = aes_decrypt_data(response.pop("data"), decrypted_rek) + + if response.get("hmac"): + hmac = hmac_sha256(decrypted_data, decrypted_rek) + if hmac != response.hmac: + frappe.throw(_("HMAC mismatch")) + + response.result = frappe.parse_json(b64decode(decrypted_data).decode()) + + return response + + def handle_error_response(self, response): + success_value = response.get("status_cd") != 0 + + if not success_value and not self.is_ignored_error(response): + frappe.throw( + response.get("error", {}).get("message") + # Fallback to response body if message is not present + or frappe.as_json(response, indent=4), + title=_("API Request Failed"), + ) + + def is_ignored_error(self, response): + error_code = response.get("error", {}).get("error_cd") if error_code in self.IGNORED_ERROR_CODES: - response_json.error_type = self.IGNORED_ERROR_CODES[error_code] + response.error_type = self.IGNORED_ERROR_CODES[error_code] return True - def get(self, action, return_period, otp=None, params=None): - return super().get( - params={"action": action, "gstin": self.company_gstin, **(params or {})}, - headers={ - "requestid": self.generate_request_id(), - "ret_period": return_period, - "otp": otp, + def generate_app_key(self): + app_key = self.generate_request_id(length=32) + frappe.db.set_value( + "GST Credential", + { + "gstin": self.company_gstin, + "username": self.username, + "service": "Returns", }, + {"app_key": app_key}, ) + return app_key + + def get_auth_token(self): + if not self.auth_token: + return None + + if self.session_expiry <= now_datetime(): + return None + + return self.auth_token + + def download_files(self, return_period, token, otp=None): + response = self.get( + "FILEDET", + return_period, + params={"ret_period": return_period, "token": token}, + endpoint="returns", + otp=otp, + ) + + if response.error_type == "queued": + return response + + return FilesAPI().get_all(response) + class GSTR2bAPI(ReturnsAPI): API_NAME = "GSTR-2B" - BASE_PATH = "returns/gstr2b" - def get_data(self, return_period, otp=None): - # TODO: Create further calls if more than one file to download - return self.get("GET2B", return_period, otp, {"rtnprd": return_period}) + def get_data(self, return_period, otp=None, file_num=None): + params = {"rtnprd": return_period} + if file_num: + params.update({"file_num": file_num}) + + return self.get( + "GET2B", return_period, params=params, endpoint="returns/gstr2b", otp=otp + ) class GSTR2aAPI(ReturnsAPI): API_NAME = "GSTR-2A" - BASE_PATH = "returns/gstr2a" def get_data(self, action, return_period, otp=None): - # TODO: Create further calls if more than one file to download - return self.get(action, return_period, otp, {"ret_period": return_period}) + return self.get( + action, + return_period, + params={"ret_period": return_period}, + endpoint="returns/gstr2a", + otp=otp, + ) diff --git a/india_compliance/gst_india/client_scripts/delivery_note.js b/india_compliance/gst_india/client_scripts/delivery_note.js index 8a13601e7..83529a9f4 100644 --- a/india_compliance/gst_india/client_scripts/delivery_note.js +++ b/india_compliance/gst_india/client_scripts/delivery_note.js @@ -2,6 +2,11 @@ const DOCTYPE = "Delivery Note"; setup_e_waybill_actions(DOCTYPE); frappe.ui.form.on(DOCTYPE, { + onload(frm) { + if (!gst_settings.enable_e_waybill) return; + show_sandbox_mode_indicator(); + }, + after_save(frm) { if ( frm.doc.docstatus || diff --git a/india_compliance/gst_india/client_scripts/e_invoice_actions.js b/india_compliance/gst_india/client_scripts/e_invoice_actions.js index a77ada732..0aa2644ae 100644 --- a/india_compliance/gst_india/client_scripts/e_invoice_actions.js +++ b/india_compliance/gst_india/client_scripts/e_invoice_actions.js @@ -3,6 +3,18 @@ frappe.ui.form.on("Sales Invoice", { if (frm.doc.__onload?.e_invoice_info?.is_generated_in_sandbox_mode) frm.get_field("irn").set_description("Generated in Sandbox Mode"); + if ( + frm.doc.irn && + frm.doc.docstatus == 2 && + frappe.perm.has_perm(frm.doctype, 0, "cancel", frm.doc.name) + ) { + frm.add_custom_button( + __("Mark as Cancelled"), + () => show_mark_e_invoice_as_cancelled_dialog(frm), + "e-Invoice" + ); + } + if (!is_e_invoice_applicable(frm)) return; if ( @@ -104,42 +116,7 @@ function show_cancel_e_invoice_dialog(frm, callback) { title: frm.doc.ewaybill ? __("Cancel e-Invoice and e-Waybill") : __("Cancel e-Invoice"), - fields: [ - { - label: "IRN Number", - fieldname: "irn", - fieldtype: "Data", - read_only: 1, - default: frm.doc.irn, - }, - { - label: "e-Waybill Number", - fieldname: "ewaybill", - fieldtype: "Data", - read_only: 1, - default: frm.doc.ewaybill || "", - }, - { - label: "Reason", - fieldname: "reason", - fieldtype: "Select", - reqd: 1, - default: "Data Entry Mistake", - options: [ - "Duplicate", - "Data Entry Mistake", - "Order Cancelled", - "Others", - ], - }, - { - label: "Remark", - fieldname: "remark", - fieldtype: "Data", - reqd: 1, - mandatory_depends_on: "eval: doc.reason == 'Others'", - }, - ], + fields: get_cancel_e_invoice_dialog_fields(frm), primary_action_label: frm.doc.ewaybill ? __("Cancel IRN & e-Waybill") : __("Cancel IRN"), @@ -162,6 +139,78 @@ function show_cancel_e_invoice_dialog(frm, callback) { d.show(); } +function show_mark_e_invoice_as_cancelled_dialog(frm) { + const d = new frappe.ui.Dialog({ + title: __("Update Cancelled e-Invoice Details"), + fields: get_cancel_e_invoice_dialog_fields(frm, true), + primary_action_label: __("Update"), + primary_action(values) { + frappe.call({ + method: "india_compliance.gst_india.utils.e_invoice.mark_e_invoice_as_cancelled", + args: { + doctype: frm.doctype, + docname: frm.doc.name, + values, + }, + callback: () => { + d.hide(); + frm.refresh(); + }, + }); + }, + }); + + d.show(); +} + +function get_cancel_e_invoice_dialog_fields(frm, manual_cancel = false) { + let fields = [ + { + label: "IRN Number", + fieldname: "irn", + fieldtype: "Data", + read_only: 1, + default: frm.doc.irn, + }, + { + label: "Reason", + fieldname: "reason", + fieldtype: "Select", + reqd: 1, + default: manual_cancel ? "Others" : "Data Entry Mistake", + options: ["Duplicate", "Data Entry Mistake", "Order Cancelled", "Others"], + }, + { + label: "Remark", + fieldname: "remark", + fieldtype: "Data", + reqd: 1, + mandatory_depends_on: "eval: doc.reason == 'Others'", + default: manual_cancel ? "Manually deleted from GSTR-1" : "", + }, + ]; + + if (manual_cancel) { + fields.push({ + label: "Cancelled On", + fieldname: "cancelled_on", + fieldtype: "Datetime", + reqd: 1, + default: frappe.datetime.now_datetime(), + }); + } else { + fields.splice(1, 0, { + label: "e-Waybill Number", + fieldname: "ewaybill", + fieldtype: "Data", + read_only: 1, + default: frm.doc.ewaybill || "", + }); + } + + return fields; +} + function is_e_invoice_applicable(frm) { return ( india_compliance.is_e_invoice_enabled() && diff --git a/india_compliance/gst_india/client_scripts/e_waybill_actions.js b/india_compliance/gst_india/client_scripts/e_waybill_actions.js index 9df883edd..980925914 100644 --- a/india_compliance/gst_india/client_scripts/e_waybill_actions.js +++ b/india_compliance/gst_india/client_scripts/e_waybill_actions.js @@ -1148,3 +1148,24 @@ function get_destination_address_name(frm) { return frm.doc.shipping_address_name || frm.doc.customer_address; } } + +function show_sandbox_mode_indicator() { + $(document).find(".form-sidebar .ic-sandbox-mode").remove(); + + if (!gst_settings.sandbox_mode) return; + + $(document) + .find(".form-sidebar .sidebar-image-section") + .after( + ` + + ` + ); +} diff --git a/india_compliance/gst_india/client_scripts/journal_entry.js b/india_compliance/gst_india/client_scripts/journal_entry.js index 89a963d4f..48b1d3c00 100644 --- a/india_compliance/gst_india/client_scripts/journal_entry.js +++ b/india_compliance/gst_india/client_scripts/journal_entry.js @@ -36,4 +36,4 @@ function _toggle_company_gstin(frm, reqd) { frm.set_df_property("company_gstin", "reqd", reqd); frm.refresh_field("company_gstin"); } -} \ No newline at end of file +} diff --git a/india_compliance/gst_india/client_scripts/payment_entry.js b/india_compliance/gst_india/client_scripts/payment_entry.js index 50b9d8dc9..058182df6 100644 --- a/india_compliance/gst_india/client_scripts/payment_entry.js +++ b/india_compliance/gst_india/client_scripts/payment_entry.js @@ -1,29 +1,51 @@ frappe.ui.form.on("Payment Entry", { - company: function(frm) { + company: function (frm) { frappe.call({ - 'method': 'frappe.contacts.doctype.address.address.get_default_address', - 'args': { - 'doctype': 'Company', - 'name': frm.doc.company + method: "frappe.contacts.doctype.address.address.get_default_address", + args: { + doctype: "Company", + name: frm.doc.company, + }, + callback: function (r) { + frm.set_value("company_address", r.message); }, - 'callback': function(r) { - frm.set_value('company_address', r.message); - } }); }, - party: function(frm) { - if (frm.doc.party_type == "Customer" && frm.doc.party) { - frappe.call({ - 'method': 'frappe.contacts.doctype.address.address.get_default_address', - 'args': { - 'doctype': 'Customer', - 'name': frm.doc.party - }, - 'callback': function(r) { - frm.set_value('customer_address', r.message); - } - }); - } - } + party: function (frm) { + update_gst_details( + frm, + "india_compliance.gst_india.overrides.payment_entry.update_party_details" + ); + }, + + customer_address(frm) { + update_gst_details(frm); + }, }); + +async function update_gst_details(frm, method) { + if ( + frm.doc.party_type != "Customer" || + !frm.doc.party || + frm.__updating_gst_details + ) + return; + + // wait for GSTINs to get fetched + await frappe.after_ajax(); + + args = { + doctype: frm.doc.doctype, + party_details: { + customer: frm.doc.party, + customer_address: frm.doc.customer_address, + billing_address_gstin: frm.doc.billing_address_gstin, + gst_category: frm.doc.gst_category, + company_gstin: frm.doc.company_gstin, + }, + company: frm.doc.company, + }; + + india_compliance.fetch_and_update_gst_details(frm, args, method); +} diff --git a/india_compliance/gst_india/client_scripts/purchase_invoice.js b/india_compliance/gst_india/client_scripts/purchase_invoice.js index 4033caaf2..da60fda9d 100644 --- a/india_compliance/gst_india/client_scripts/purchase_invoice.js +++ b/india_compliance/gst_india/client_scripts/purchase_invoice.js @@ -2,6 +2,11 @@ const DOCTYPE = "Purchase Invoice"; setup_e_waybill_actions(DOCTYPE); frappe.ui.form.on(DOCTYPE, { + onload(frm) { + if (!gst_settings.enable_e_waybill) return; + show_sandbox_mode_indicator(); + }, + after_save(frm) { if ( frm.doc.docstatus || @@ -18,7 +23,10 @@ frappe.ui.form.on(DOCTYPE, { 10 ); }, + refresh(frm) { + india_compliance.set_reconciliation_status(frm, "bill_no"); + if ( frm.doc.docstatus !== 1 || frm.doc.gst_category !== "Overseas" || @@ -37,4 +45,28 @@ frappe.ui.form.on(DOCTYPE, { __("Create") ); }, + + before_save: function (frm) { + // hack: values set in frm.doc are not available after save + if (frm._inward_supply) frm.doc._inward_supply = frm._inward_supply; + }, + + on_submit: function (frm) { + if (!frm._inward_supply) return; + + // go back to previous page and match the invoice with the inward supply + setTimeout(() => { + frappe.route_hooks.after_load = reco_frm => { + if (!reco_frm.purchase_reconciliation_tool) return; + purchase_reconciliation_tool.link_documents( + reco_frm, + frm.doc.name, + frm._inward_supply.name, + "Purchase Invoice", + false + ); + }; + frappe.set_route("Form", "Purchase Reconciliation Tool"); + }, 2000); + }, }); diff --git a/india_compliance/gst_india/client_scripts/sales_invoice.js b/india_compliance/gst_india/client_scripts/sales_invoice.js index 16ffd74ec..d6c6d94e6 100644 --- a/india_compliance/gst_india/client_scripts/sales_invoice.js +++ b/india_compliance/gst_india/client_scripts/sales_invoice.js @@ -24,6 +24,11 @@ frappe.ui.form.on(DOCTYPE, { }); }, + onload(frm) { + if (!(gst_settings.enable_e_waybill || gst_settings.enable_e_invoice)) return; + show_sandbox_mode_indicator(); + }, + before_submit(frm) { frm.doc._submitted_from_ui = 1; }, diff --git a/india_compliance/gst_india/constants/__init__.py b/india_compliance/gst_india/constants/__init__.py index 5b34f348b..903688f7c 100644 --- a/india_compliance/gst_india/constants/__init__.py +++ b/india_compliance/gst_india/constants/__init__.py @@ -1418,4 +1418,28 @@ SALES_DOCTYPES = set(sales_doctypes) BUG_REPORT_URL = "https://github.com/resilient-tech/india-compliance/issues/new" + +ORIGINAL_VS_AMENDED = ( + { + "original": "B2B", + "amended": "B2BA", + }, + { + "original": "CDNR", + "amended": "CDNRA", + }, + { + "original": "ISD", + "amended": "ISDA", + }, + { + "original": "IMPG", + "amended": "", + }, + { + "original": "IMPGSEZ", + "amended": "", + }, +) + E_INVOICE_MASTER_CODES_URL = "https://einvoice1.gst.gov.in/Others/MasterCodes" diff --git a/india_compliance/gst_india/constants/custom_fields.py b/india_compliance/gst_india/constants/custom_fields.py index bd52d7a7f..16b2844c7 100644 --- a/india_compliance/gst_india/constants/custom_fields.py +++ b/india_compliance/gst_india/constants/custom_fields.py @@ -390,10 +390,20 @@ "default": "All Other ITC", "translatable": 0, }, + { + "fieldname": "reconciliation_status", + "label": "Reconciliation Status", + "fieldtype": "Select", + "insert_after": "eligibility_for_itc", + "print_hide": 1, + "options": ("\nNot Applicable\nReconciled\nUnreconciled\nIgnored"), + "no_copy": 1, + "read_only": 1, + }, { "fieldname": "gst_col_break", "fieldtype": "Column Break", - "insert_after": "eligibility_for_itc", + "insert_after": "reconciliation_status", }, { "fieldname": "itc_integrated_tax", @@ -526,11 +536,13 @@ { "fieldname": "place_of_supply", "label": "Place of Supply", - "fieldtype": "Data", + "fieldtype": "Autocomplete", + "options": get_place_of_supply_options(), "insert_after": "company_gstin", "print_hide": 1, - "read_only": 1, + "read_only": 0, "translatable": 0, + "depends_on": 'eval:doc.party_type === "Customer"', }, { "fieldname": "gst_column_break", @@ -547,7 +559,7 @@ "depends_on": 'eval:doc.party_type == "Customer"', }, { - "fieldname": "customer_gstin", + "fieldname": "billing_address_gstin", "label": "Customer GSTIN", "fieldtype": "Data", "insert_after": "customer_address", @@ -555,6 +567,19 @@ "print_hide": 1, "read_only": 1, "translatable": 0, + "depends_on": 'eval:doc.party_type === "Customer"', + }, + { + "fieldname": "gst_category", + "label": "GST Category", + "fieldtype": "Data", + "insert_after": "billing_address_gstin", + "read_only": 1, + "print_hide": 1, + "fetch_from": "customer_address.gst_category", + "translatable": 0, + "fetch_if_empty": 0, + "depends_on": 'eval:doc.party_type === "Customer"', }, ], "Journal Entry": [ @@ -675,7 +700,7 @@ "label": "e-Invoice Status", "fieldtype": "Select", "insert_after": "status", - "options": "\nPending\nGenerated\nAuto-Retry\nCancelled\nFailed\nNot Applicable", + "options": "\nPending\nGenerated\nAuto-Retry\nCancelled\nManually Cancelled\nFailed\nNot Applicable", "default": None, "hidden": 1, "no_copy": 1, @@ -693,6 +718,7 @@ "fieldtype": "Int", "insert_after": "vehicle_no", "print_hide": 1, + "no_copy": 1, "description": ( "Set as zero to update distance as per the e-Waybill portal (if available)" ), @@ -704,6 +730,7 @@ "insert_after": "transporter", "fetch_from": "transporter.gst_transporter_id", "print_hide": 1, + "no_copy": 1, "translatable": 0, }, { @@ -714,6 +741,7 @@ "default": "Road", "insert_after": "transporter_name", "print_hide": 1, + "no_copy": 1, "translatable": 0, }, { @@ -726,6 +754,7 @@ "default": "Regular", "insert_after": "lr_date", "print_hide": 1, + "no_copy": 1, "translatable": 0, }, ] @@ -747,6 +776,7 @@ "insert_after": "transporter_info", "options": "Supplier", "print_hide": 1, + "no_copy": 1, }, { "fieldname": "driver", @@ -755,6 +785,7 @@ "insert_after": "gst_transporter_id", "options": "Driver", "print_hide": 1, + "no_copy": 1, }, { "fieldname": "lr_no", @@ -762,6 +793,7 @@ "fieldtype": "Data", "insert_after": "driver", "print_hide": 1, + "no_copy": 1, "translatable": 0, "length": 30, }, @@ -771,6 +803,7 @@ "fieldtype": "Data", "insert_after": "lr_no", "print_hide": 1, + "no_copy": 1, "translatable": 0, "length": 15, }, @@ -787,6 +820,7 @@ "fetch_from": "transporter.supplier_name", "read_only": 1, "print_hide": 1, + "no_copy": 1, "translatable": 0, }, { @@ -796,6 +830,7 @@ "insert_after": "mode_of_transport", "fetch_from": "driver.full_name", "print_hide": 1, + "no_copy": 1, "translatable": 0, }, { @@ -805,6 +840,7 @@ "insert_after": "driver_name", "default": "Today", "print_hide": 1, + "no_copy": 1, }, *E_WAYBILL_DN_FIELDS, ] diff --git a/india_compliance/gst_india/data/test_e_invoice.json b/india_compliance/gst_india/data/test_e_invoice.json index 29db98e8a..61089bbec 100644 --- a/india_compliance/gst_india/data/test_e_invoice.json +++ b/india_compliance/gst_india/data/test_e_invoice.json @@ -1,596 +1,702 @@ { - "goods_item_with_ewaybill": { - "kwargs": { - "vehicle_no": "GJ07DL9009" - }, - "request_data": { - "BuyerDtls": { - "Addr1": "Test Address - 3", - "Gstin": "36AMBPG7773M002", - "LglNm": "_Test Registered Customer", - "Loc": "Test City", - "Pin": 500055, - "Pos": "02", - "Stcd": "36", - "TrdNm": "_Test Registered Customer" - }, - "DocDtls": { - "Dt": "16/09/2022", - "No": "test_invoice_no", - "Typ": "INV" - }, - "EwbDtls": { - "Distance": 0, - "TransMode": "1", - "VehNo": "GJ07DL9009", - "VehType": "R" - }, - "ItemList": [ - { - "AssAmt": 100000, - "CesAmt": 0, - "CesNonAdvlAmt": 0, - "CesRt": 0, - "CgstAmt": 0, - "Discount": 0, - "GstRt": 0, - "HsnCd": "61149090", - "IgstAmt": 0, - "IsServc": "N", - "PrdDesc": "Test Trading Goods 1", - "Qty": 1000, - "SgstAmt": 0, - "SlNo": "1", - "TotAmt": 100000, - "TotItemVal": 100000, - "Unit": "NOS", - "UnitPrice": 100 - } - ], - "PayDtls": { - "CrDay": 0, - "PaidAmt": 0, - "PaymtDue": 100000 - }, - "SellerDtls": { - "Addr1": "Test Address - 1", - "Gstin": "02AMBPG7773M002", - "LglNm": "_Test Indian Registered Company", - "Loc": "Test City", - "Pin": 171302, - "Stcd": "02", - "TrdNm": "_Test Indian Registered Company" - }, - "TranDtls": { - "RegRev": "N", - "SupTyp": "B2B", - "TaxSch": "GST" - }, - "ValDtls": { - "AssVal": 100000, - "CesVal": 0, - "CgstVal": 0, - "Discount": 0, - "IgstVal": 0, - "OthChrg": 0, - "RndOffAmt": 0, - "SgstVal": 0, - "TotInvVal": 100000 - }, - "Version": "1.1" - }, - "response_data": { - "info": [ - { - "InfCd": "EWBPPD", - "Desc": "Pin-Pin calc distance: 2467KM" - } - ], - "message": "IRN generated successfully", - "result": { - "AckDt": "2022-09-16 19:29:00", - "AckNo": 232210036743849, - "EwbDt": "2022-09-16 19:29:00", - "EwbNo": 391009149369, - "EwbValidTill": "2022-09-29 23:59:00", - "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7", - "Remarks": null, - "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NDM4NDksXCJBY2tEdFwiOlwiMjAyMi0wOS0xNiAxOToyOTowMFwiLFwiSXJuXCI6XCI3MDZkYWVjY2RhMGVmNmY4MThkYTc4ZjNhMmEwNWExMjg4NzMxMDU3MzczMDAyMjg5YjQ2YzMyMjkyODlhMmU3XCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJJTlZcIixcIk5vXCI6XCJTSU5WLUNGWS0wMDA2N1wiLFwiRHRcIjpcIjE2LzA5LzIwMjJcIn0sXCJTZWxsZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIlRyZE5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJCdXllckR0bHNcIjp7XCJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJMZ2xObVwiOlwiX1Rlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiVHJkTm1cIjpcIlRlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiUG9zXCI6XCIwMVwiLFwiQWRkcjFcIjpcIlRlc3QgQWRkcmVzcyAtIDNcIixcIkxvY1wiOlwiVGVzdCBDaXR5XCIsXCJQaW5cIjo1MDAwNTUsXCJTdGNkXCI6XCIzNlwifSxcIkRpc3BEdGxzXCI6e1wiTm1cIjpcIlRlc3QgSW5kaWFuIFJlZ2lzdGVyZWQgQ29tcGFueVwiLFwiQWRkcjFcIjpcIlRlc3QgQWRkcmVzcyAtIDFcIixcIkxvY1wiOlwiVGVzdCBDaXR5XCIsXCJQaW5cIjoxOTM1MDEsXCJTdGNkXCI6XCIwMVwifSxcIlNoaXBEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIlRlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiVHJkTm1cIjpcIlRlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiQWRkcjFcIjpcIlRlc3QgQWRkcmVzcyAtIDNcIixcIkxvY1wiOlwiVGVzdCBDaXR5XCIsXCJQaW5cIjo1MDAwNTUsXCJTdGNkXCI6XCIzNlwifSxcIkl0ZW1MaXN0XCI6W3tcIkl0ZW1Ob1wiOjAsXCJTbE5vXCI6XCIxXCIsXCJJc1NlcnZjXCI6XCJOXCIsXCJQcmREZXNjXCI6XCJUZXN0IFRyYWRpbmcgR29vZHMgMVwiLFwiSHNuQ2RcIjpcIjYxMTQ5MDkwXCIsXCJRdHlcIjoxLjAsXCJVbml0XCI6XCJOT1NcIixcIlVuaXRQcmljZVwiOjEwMC4wLFwiVG90QW10XCI6MTAwLjAsXCJEaXNjb3VudFwiOjAsXCJBc3NBbXRcIjoxMDAuMCxcIkdzdFJ0XCI6MC4wLFwiSWdzdEFtdFwiOjAsXCJDZ3N0QW10XCI6MCxcIlNnc3RBbXRcIjowLFwiQ2VzUnRcIjowLFwiQ2VzQW10XCI6MCxcIkNlc05vbkFkdmxBbXRcIjowLFwiVG90SXRlbVZhbFwiOjEwMC4wfV0sXCJWYWxEdGxzXCI6e1wiQXNzVmFsXCI6MTAwLjAsXCJDZ3N0VmFsXCI6MCxcIlNnc3RWYWxcIjowLFwiSWdzdFZhbFwiOjAsXCJDZXNWYWxcIjowLFwiRGlzY291bnRcIjowLFwiT3RoQ2hyZ1wiOjAuMCxcIlJuZE9mZkFtdFwiOjAuMCxcIlRvdEludlZhbFwiOjEwMC4wfSxcIlBheUR0bHNcIjp7XCJDckRheVwiOjAsXCJQYWlkQW10XCI6MCxcIlBheW10RHVlXCI6MTAwLjB9LFwiRXdiRHRsc1wiOntcIlRyYW5zTW9kZVwiOlwiMVwiLFwiRGlzdGFuY2VcIjowLFwiVmVoTm9cIjpcIkdKMDdETDkwMDlcIixcIlZlaFR5cGVcIjpcIlJcIn19IiwiaXNzIjoiTklDIn0.ZOqrLJLsoXHf1QMRPBJoBesVluRB0a0ISsBGn6gqLuiJLfsAG1Oxmimqi9c7dboRnsW1eEj78Yps5D2A05WPMXwdkOy9Ahb_t4jXSGH-ijq_ed8z-xAtyiWH16YfIc9Zg020VkrlZiHdbfkx53hOwEA3aUhHIdPwQE5Kk-O9KWES3cttl9r5lrtzueTlTKB0GqXqiNlmuuQnCAJpWe34Coko1__kyPLLMdKgpOSB0EX2j7NjaZ5KPhu-GZHBtTvKczuSXvli6lwSQLaKpBm1IGvwMo2IzGW62pXp4XdMlcncLuc8wLTExSlKwHhsSspOxhMNBRx3NqcU0PQZOq050Q", - "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiU0lOVi1DRlktMDAwNjdcIixcIkRvY1R5cFwiOlwiSU5WXCIsXCJEb2NEdFwiOlwiMTYvMDkvMjAyMlwiLFwiVG90SW52VmFsXCI6MTAwLjAsXCJJdGVtQ250XCI6MSxcIk1haW5Ic25Db2RlXCI6XCI2MTE0OTA5MFwiLFwiSXJuXCI6XCI3MDZkYWVjY2RhMGVmNmY4MThkYTc4ZjNhMmEwNWExMjg4NzMxMDU3MzczMDAyMjg5YjQ2YzMyMjkyODlhMmU3XCIsXCJJcm5EdFwiOlwiMjAyMi0wOS0xNiAxOToyOTowMFwifSIsImlzcyI6Ik5JQyJ9.j7Fpl3ol0G6akp1-ukVzOK-8Dqoey3iKLf9SCaXGfb3crIcpniezqevH1qBTgCtUDYnOa0tRk5Nhyi-ER-W8Hu2a4Ug28AJFp3S8Xv2RwdMe9HvJN1b8KBKz6N4_WcO7wD2VcXyoEKDuTP2KFlXRjuZx7tBh5ttjQ4vRNtVwpR2qy-lRtMquEbZsJ-JOPBLUTXimdpVwt9EW8xKUxRKT_7-8kwK-DGHePADVBUjD6kv-GSpbxgfM4UAPg1TRlRz_BHMbbi9adZVZn5l9GA-WRuSP_7C-Qd_ucYg1cmP2zswh1XClMEjwjmxpuFkhqdDsRfl8unnEi--FxA0lvn4nmw", - "Status": "ACT", - "distance": 2467 - }, - "success": true - } - }, - "service_item": { - "kwargs": { - "item_code": "_Test Service Item", - "customer_address": "_Test Registered Customer-Billing", - "shipping_address_name": "_Test Registered Customer-Billing" - }, - "request_data": { - "BuyerDtls": { - "Addr1": "Test Address - 3", - "Gstin": "36AMBPG7773M002", - "LglNm": "_Test Registered Customer", - "Loc": "Test City", - "Pin": 500055, - "Pos": "02", - "Stcd": "36", - "TrdNm": "_Test Registered Customer" - }, - "DocDtls": { - "Dt": "17/09/2022", - "No": "test_invoice_no", - "Typ": "INV" - }, - "ItemList": [ - { - "AssAmt": 100.0, - "CesAmt": 0, - "CesNonAdvlAmt": 0, - "CesRt": 0, - "CgstAmt": 0, - "Discount": 0, - "GstRt": 0.0, - "HsnCd": "999900", - "IgstAmt": 0, - "IsServc": "Y", - "PrdDesc": "Test Service Item", - "Qty": 1.0, - "SgstAmt": 0, - "SlNo": "1", - "TotAmt": 100.0, - "TotItemVal": 100.0, - "Unit": "NOS", - "UnitPrice": 100.0 - } - ], - "PayDtls": { - "CrDay": 0, - "PaidAmt": 0, - "PaymtDue": 100.0 - }, - "SellerDtls": { - "Addr1": "Test Address - 1", - "Gstin": "02AMBPG7773M002", - "LglNm": "_Test Indian Registered Company", - "Loc": "Test City", - "Pin": 171302, - "Stcd": "02", - "TrdNm": "_Test Indian Registered Company" - }, - "TranDtls": { - "RegRev": "N", - "SupTyp": "B2B", - "TaxSch": "GST" - }, - "ValDtls": { - "AssVal": 100.0, - "CesVal": 0, - "CgstVal": 0, - "Discount": 0, - "IgstVal": 0, - "OthChrg": 0.0, - "RndOffAmt": 0.0, - "SgstVal": 0, - "TotInvVal": 100.0 - }, - "Version": "1.1" - }, - "response_data": { - "success": true, - "message": "IRN generated successfully", - "result": { - "AckNo": 232210036754863, - "AckDt": "2022-09-17 16:26:00", - "Irn": "68fb4fab44aee99fb23292478c4bd838e664837c9f1b04e3d9134ffed0b40b60", - "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTQ4NjMsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNjoyNjowMFwiLFwiSXJuXCI6XCI2OGZiNGZhYjQ0YWVlOTlmYjIzMjkyNDc4YzRiZDgzOGU2NjQ4MzdjOWYxYjA0ZTNkOTEzNGZmZWQwYjQwYjYwXCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJJTlZcIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIzNkFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiWVwiLFwiUHJkRGVzY1wiOlwiVGVzdCBTZXJ2aWNlIEl0ZW1cIixcIkhzbkNkXCI6XCI5OTU0MTFcIixcIlF0eVwiOjEuMCxcIlVuaXRcIjpcIk5PU1wiLFwiVW5pdFByaWNlXCI6MTAwLjAsXCJUb3RBbXRcIjoxMDAuMCxcIkRpc2NvdW50XCI6MCxcIkFzc0FtdFwiOjEwMC4wLFwiR3N0UnRcIjowLjAsXCJJZ3N0QW10XCI6MCxcIkNnc3RBbXRcIjowLFwiU2dzdEFtdFwiOjAsXCJDZXNSdFwiOjAsXCJDZXNBbXRcIjowLFwiQ2VzTm9uQWR2bEFtdFwiOjAsXCJUb3RJdGVtVmFsXCI6MTAwLjB9XSxcIlZhbER0bHNcIjp7XCJBc3NWYWxcIjoxMDAuMCxcIkNnc3RWYWxcIjowLFwiU2dzdFZhbFwiOjAsXCJJZ3N0VmFsXCI6MCxcIkNlc1ZhbFwiOjAsXCJEaXNjb3VudFwiOjAsXCJPdGhDaHJnXCI6MC4wLFwiUm5kT2ZmQW10XCI6MC4wLFwiVG90SW52VmFsXCI6MTAwLjB9LFwiUGF5RHRsc1wiOntcIkNyRGF5XCI6MCxcIlBhaWRBbXRcIjowLFwiUGF5bXREdWVcIjoxMDAuMH0sXCJFd2JEdGxzXCI6e1wiRGlzdGFuY2VcIjowfX0iLCJpc3MiOiJOSUMifQ.rcfXkciqDJypX-xCqaUU3xAk2gccHK_qBD_FBIUsEr-SyWVs4LStgXwQEWUhTYnEfcGGm_sWX15ewC0jn9iWVFCNNFnjKc8vsFQqnbzvi-bnr6CWkjRXOxVqQTfis6PbtTrblojBq2hhBaT1B_ZlgLePi5qFNWnxxaHItjYtBEeBzW5JxzXWQTqESrBy02iLQgQMOexmQ6jKGdUR3tRRG5MVB7QfXbL9BpQr-DXbHhbDGllT2S_xyj9SBsN6ICeluCG-ZJdHE_kCoIxc8iY_nXneb2PsBciij14cHb96h4ddNZtapxTPTh4CumVDmAgLBSVsBTugp8vm0L-jd7n3dg", - "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIklOVlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiOTk1NDExXCIsXCJJcm5cIjpcIjY4ZmI0ZmFiNDRhZWU5OWZiMjMyOTI0NzhjNGJkODM4ZTY2NDgzN2M5ZjFiMDRlM2Q5MTM0ZmZlZDBiNDBiNjBcIixcIklybkR0XCI6XCIyMDIyLTA5LTE3IDE2OjI2OjAwXCJ9IiwiaXNzIjoiTklDIn0.A99BPXfKiGSNjnEcqmxc7RGutWakaeW0NMan9oC5yMw6zAoTNcVc34GtQKV7iajBZQhyiFrNwn5n6QtYOXafpitHcI_yrUWSojQBPPpPlslqj4hbnbCy7kmOZZ8mOKISHJrsIZJxpjRlSquIzfDN4aP1aT_qHDqwFqyA8RyJM-id5EpDaTrUFK12HwjKfAXHn4shEUBBgEWrHYOKK6VdpCNi6F_5I5bbJRivrvUJxMLjk3Ux9fyylqnEeyE2NThs9hFuV9EgoVzGE3FhfPZsooToAuG_npYEv3f6Q9KbOw3pNQ3NkqFwvmFjfNJLXdbxZIPe9fe9F1c-CRrIoNo_9w", - "Status": "ACT", - "EwbNo": null, - "EwbDt": null, - "EwbValidTill": null, - "Remarks": null - }, - "info": [ - { - "InfCd": "EWBERR", - "Desc": [ - { - "ErrorCode": "4019", - "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" - } - ] - } - ] - } - }, - "return_invoice": { - "kwargs": { - "qty": -1, - "is_return": 1, - "customer_address": "_Test Registered Customer-Billing", - "shipping_address_name": "_Test Registered Customer-Billing" - }, - "request_data": { - "BuyerDtls": { - "Addr1": "Test Address - 3", - "Gstin": "36AMBPG7773M002", - "LglNm": "_Test Registered Customer", - "Loc": "Test City", - "Pin": 500055, - "Pos": "02", - "Stcd": "36", - "TrdNm": "_Test Registered Customer" - }, - "DocDtls": { - "Dt": "17/09/2022", - "No": "test_invoice_no", - "Typ": "CRN" - }, - "ItemList": [ - { - "SlNo": "1", - "PrdDesc": "Test Trading Goods 1", - "IsServc": "N", - "HsnCd": "61149090", - "Unit": "NOS", - "Qty": 1.0, - "UnitPrice": 7.6, - "TotAmt": 7.6, - "Discount": 0, - "AssAmt": 7.6, - "GstRt": 12.0, - "IgstAmt": 0, - "CgstAmt": 0.46, - "SgstAmt": 0.46, - "CesRt": 0, - "CesAmt": 0, - "CesNonAdvlAmt": 0, - "TotItemVal": 8.52 - }, - { - "SlNo": "2", - "PrdDesc": "Test Trading Goods 1", - "IsServc": "N", - "HsnCd": "61149090", - "Unit": "NOS", - "Qty": 1.0, - "UnitPrice": 7.6, - "TotAmt": 7.6, - "Discount": 0, - "AssAmt": 7.6, - "GstRt": 12.0, - "IgstAmt": 0, - "CgstAmt": 0.45, - "SgstAmt": 0.45, - "CesRt": 0, - "CesAmt": 0, - "CesNonAdvlAmt": 0, - "TotItemVal": 8.5 - } - ], - "PayDtls": { - "CrDay": 0, - "PaidAmt": 0, - "PaymtDue": 0.0 - }, - "RefDtls": { - "PrecDocDtls": [ - { - "InvDt": "17/09/2022", - "InvNo": "SINV-CFY-00092" - } - ] - }, - "SellerDtls": { - "Addr1": "Test Address - 1", - "Gstin": "02AMBPG7773M002", - "LglNm": "_Test Indian Registered Company", - "Loc": "Test City", - "Pin": 171302, - "Stcd": "02", - "TrdNm": "_Test Indian Registered Company" - }, - "TranDtls": { - "RegRev": "N", - "SupTyp": "B2B", - "TaxSch": "GST" - }, - "ValDtls": { - "AssVal": 15.2, - "CesVal": 0, - "CgstVal": 0.91, - "Discount": 0, - "IgstVal": 0, - "OthChrg": 0.0, - "RndOffAmt": -0.02, - "SgstVal": 0.91, - "TotInvVal": 17.0 - }, - "Version": "1.1" - }, - "response_data": { - "success": true, - "message": "IRN generated successfully", - "result": { - "AckNo": 232210036755145, - "AckDt": "2022-09-17 17:05:00", - "Irn": "1c96258af085e45da556494ea5e5a7b401a598ab80af4136309c2dac7b54d795", - "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTUxNDUsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNzowNTowMFwiLFwiSXJuXCI6XCIxYzk2MjU4YWYwODVlNDVkYTU1NjQ5NGVhNWU1YTdiNDAxYTU5OGFiODBhZjQxMzYzMDljMmRhYzdiNTRkNzk1XCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJDUk5cIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIlRyZE5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiTlwiLFwiUHJkRGVzY1wiOlwiVGVzdCBUcmFkaW5nIEdvb2RzIDFcIixcIkhzbkNkXCI6XCI2MTE0OTA5MFwiLFwiUXR5XCI6MS4wLFwiVW5pdFwiOlwiTk9TXCIsXCJVbml0UHJpY2VcIjoxMDAuMCxcIlRvdEFtdFwiOjEwMC4wLFwiRGlzY291bnRcIjowLFwiQXNzQW10XCI6MTAwLjAsXCJHc3RSdFwiOjAuMCxcIklnc3RBbXRcIjowLFwiQ2dzdEFtdFwiOjAsXCJTZ3N0QW10XCI6MCxcIkNlc1J0XCI6MCxcIkNlc0FtdFwiOjAsXCJDZXNOb25BZHZsQW10XCI6MCxcIlRvdEl0ZW1WYWxcIjoxMDAuMH1dLFwiVmFsRHRsc1wiOntcIkFzc1ZhbFwiOjEwMC4wLFwiQ2dzdFZhbFwiOjAsXCJTZ3N0VmFsXCI6MCxcIklnc3RWYWxcIjowLFwiQ2VzVmFsXCI6MCxcIkRpc2NvdW50XCI6MCxcIk90aENocmdcIjowLjAsXCJSbmRPZmZBbXRcIjowLjAsXCJUb3RJbnZWYWxcIjoxMDAuMH0sXCJQYXlEdGxzXCI6e1wiQ3JEYXlcIjowLFwiUGFpZEFtdFwiOjAsXCJQYXltdER1ZVwiOjAuMH0sXCJSZWZEdGxzXCI6e1wiUHJlY0RvY0R0bHNcIjpbe1wiSW52Tm9cIjpcIlNJTlYtQ0ZZLTAwMDkyXCIsXCJJbnZEdFwiOlwiMTcvMDkvMjAyMlwifV19LFwiRXdiRHRsc1wiOntcIkRpc3RhbmNlXCI6MH19IiwiaXNzIjoiTklDIn0.OZpYYN2pXIPJmLTi79NuVxVWWWGwdUzdnIEKXpgXyCEtXqgfwm9opf6EbyEt6wjwiYV4b2HWAfmkOXYJ-TO3dMc_98nOW8SeupGC6k-aV5kcDK9DNiRNWZBCiTMCRhlfmYC3oEZtd5f6yVoHvESl-QKeBlXcK2L8M1C0Guuq3QYqyhFp_gFqz_xY6ImKi65hEMDBghCaKINatRvPtFiwmtIspxUaHid5YcyqXdBxerEDjz-D2RjHOnhq1K_JX07X9xqcncLa106XltEeVFswJiEfqwtZTKDzkd4klymGJL5je7L1tgO8sNTKXGtFETKZfKmFSvxsMrBy1Dlp0LXXtg", - "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIkNSTlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiNjExNDkwOTBcIixcIklyblwiOlwiMWM5NjI1OGFmMDg1ZTQ1ZGE1NTY0OTRlYTVlNWE3YjQwMWE1OThhYjgwYWY0MTM2MzA5YzJkYWM3YjU0ZDc5NVwiLFwiSXJuRHRcIjpcIjIwMjItMDktMTcgMTc6MDU6MDBcIn0iLCJpc3MiOiJOSUMifQ.fgLb2ILeWK-MHxs61jZeo3kgy6w-sFXkVSh9GbzH0yWCeVx8MTsRwkKEXklPlmdwudG1lUPVjWsOioCgLL1Q8EE_wqnpGY00JEWr5X74GduBLe0lo5ZKtsSIVf10REy1E7_JJV8qitSZa3JeSqdjPlTUowFpxvPiw-nu3fyBP92IdVFbGO6oMvodI66kanqEyKj26Rn0nfnHP3KT3u61RBUSaVm_gch79cnPQanDPrJtd_0Ra2Vn7FoopdfcNIEdASB71IDRHsMFCNs8LyHTtFoJVI2LqU8wic_A6oWZkswOTBHdsBauMa_CMF9-2QbwTHFv60yvS7KuS2HvBw-oyQ", - "Status": "ACT", - "EwbNo": null, - "EwbDt": null, - "EwbValidTill": null, - "Remarks": null - }, - "info": [ - { - "InfCd": "EWBERR", - "Desc": [ - { - "ErrorCode": "4019", - "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" - } - ] - } - ] - } - }, - "debit_invoice": { - "kwargs": { - "is_debit_note": 1, - "qty": 0, - "customer_address": "_Test Registered Customer-Billing", - "shipping_address_name": "_Test Registered Customer-Billing" - }, - "request_data": { - "BuyerDtls": { - "Addr1": "Test Address - 3", - "Gstin": "36AMBPG7773M002", - "LglNm": "_Test Registered Customer", - "Loc": "Test City", - "Pin": 500055, - "Pos": "02", - "Stcd": "36", - "TrdNm": "_Test Registered Customer" - }, - "DocDtls": { - "Dt": "17/09/2022", - "No": "test_invoice_no", - "Typ": "DBN" - }, - "ItemList": [ - { - "AssAmt": 100.0, - "CesAmt": 0, - "CesNonAdvlAmt": 0, - "CesRt": 0, - "CgstAmt": 0, - "Discount": 0, - "GstRt": 0.0, - "HsnCd": "61149090", - "IgstAmt": 0, - "IsServc": "N", - "PrdDesc": "Test Trading Goods 1", - "Qty": 0.0, - "SgstAmt": 0, - "SlNo": "1", - "TotAmt": 100.0, - "TotItemVal": 100.0, - "Unit": "NOS", - "UnitPrice": 100.0 - } - ], - "PayDtls": { - "CrDay": 0, - "PaidAmt": 0, - "PaymtDue": 100.0 - }, - "SellerDtls": { - "Addr1": "Test Address - 1", - "Gstin": "02AMBPG7773M002", - "LglNm": "_Test Indian Registered Company", - "Loc": "Test City", - "Pin": 171302, - "Stcd": "02", - "TrdNm": "_Test Indian Registered Company" - }, - "TranDtls": { - "RegRev": "N", - "SupTyp": "B2B", - "TaxSch": "GST" - }, - "ValDtls": { - "AssVal": 100.0, - "CesVal": 0, - "CgstVal": 0, - "Discount": 0, - "IgstVal": 0, - "OthChrg": 0.0, - "RndOffAmt": 0.0, - "SgstVal": 0, - "TotInvVal": 100.0 - }, - "Version": "1.1" - }, - "response_data": { - "success": true, - "message": "IRN generated successfully", - "result": { - "AckNo": 232210036755701, - "AckDt": "2022-09-17 17:50:00", - "Irn": "24f37c80532583c6894d8153e2b12494daa80ddbb197f0fc2c1bac07db67f933", - "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTU3MDEsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNzo1MDowMFwiLFwiSXJuXCI6XCIyNGYzN2M4MDUzMjU4M2M2ODk0ZDgxNTNlMmIxMjQ5NGRhYTgwZGRiYjE5N2YwZmMyYzFiYWMwN2RiNjdmOTMzXCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJEQk5cIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIzNkFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiTlwiLFwiUHJkRGVzY1wiOlwiVGVzdCBUcmFkaW5nIEdvb2RzIDFcIixcIkhzbkNkXCI6XCI2MTE0OTA5MFwiLFwiUXR5XCI6MS4wLFwiVW5pdFwiOlwiTk9TXCIsXCJVbml0UHJpY2VcIjoxMDAuMCxcIlRvdEFtdFwiOjEwMC4wLFwiRGlzY291bnRcIjowLFwiQXNzQW10XCI6MTAwLjAsXCJHc3RSdFwiOjAuMCxcIklnc3RBbXRcIjowLFwiQ2dzdEFtdFwiOjAsXCJTZ3N0QW10XCI6MCxcIkNlc1J0XCI6MCxcIkNlc0FtdFwiOjAsXCJDZXNOb25BZHZsQW10XCI6MCxcIlRvdEl0ZW1WYWxcIjoxMDAuMH1dLFwiVmFsRHRsc1wiOntcIkFzc1ZhbFwiOjEwMC4wLFwiQ2dzdFZhbFwiOjAsXCJTZ3N0VmFsXCI6MCxcIklnc3RWYWxcIjowLFwiQ2VzVmFsXCI6MCxcIkRpc2NvdW50XCI6MCxcIk90aENocmdcIjowLjAsXCJSbmRPZmZBbXRcIjowLjAsXCJUb3RJbnZWYWxcIjoxMDAuMH0sXCJQYXlEdGxzXCI6e1wiQ3JEYXlcIjowLFwiUGFpZEFtdFwiOjAsXCJQYXltdER1ZVwiOjEwMC4wfSxcIkV3YkR0bHNcIjp7XCJEaXN0YW5jZVwiOjB9fSIsImlzcyI6Ik5JQyJ9.ad1NfDA-H8FgBHr_kaTeiVWUj-f8T6NXuLFWa1gprxGhACXoI9h6sU47U9PBxHcZ7qVXcYAKzA9CbNvAfxWCLKtjur5p85uIrksZYDD494lodGn3QeXbyjXMJeh7eM0mcKKN3Chp0TxaUfi9C7mA9W0R8HYKNnXIOT1CVlD-brrAw09_QiDsNgMhLBX5QfpHKIHPKCIEl_DgmWlnMzduy1iKYPpNreNPCV-J-ZaVQjxl93LjKBUb5AF1XWyWvPw_e8ePZEttviX_bU_Nnm1M4zCj-QWYzj8A0bauzl7kjp5UajEM7_7CLAI4sjZnqonGKYFfR5rf2Qj76exbs_pWGw", - "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIkRCTlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiNjExNDkwOTBcIixcIklyblwiOlwiMjRmMzdjODA1MzI1ODNjNjg5NGQ4MTUzZTJiMTI0OTRkYWE4MGRkYmIxOTdmMGZjMmMxYmFjMDdkYjY3ZjkzM1wiLFwiSXJuRHRcIjpcIjIwMjItMDktMTcgMTc6NTA6MDBcIn0iLCJpc3MiOiJOSUMifQ.TUE7iIvF9Orc1bEuOaxtSuj0D6vP1MwV-hcoh3IZqV7EKXwMmm0PkVrN87vRzSu97NMKfQIHJDRgPYv4prhuT0dshWJ9A_kC4jiRSm5Naj_R1egBKsv5ykTojOKKrkGy35DtdUcJK_FyiD0qFfmMmInFD8u6D8W83eEo93i99RONgVKUyCkd_uqs-cz1P-PTlsi2xWbeDVVIcRoAmf-lcsbwkl2Hn6ECHgorKJHPJC1FGo1jRQ2Ktq0ODiJdncplbxbdYN19vUz61JJB4DPzWtf8wOkX11N0fDhdUdEINfJURWEOIGQRYip5GIJuA2qdqxZieVk0CetnsckcGgfE8g", - "Status": "ACT", - "EwbNo": null, - "EwbDt": null, - "EwbValidTill": null, - "Remarks": null - }, - "info": [ - { - "InfCd": "EWBERR", - "Desc": [ - { - "ErrorCode": "4019", - "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" - } - ] - } - ] - } - }, - "cancel_e_invoice": { - "request_data": { - "Cnlrem": "Data Entry Mistake", - "Cnlrsn": "2", - "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7" - }, - "response_data": { - "message": "E-Invoice is cancelled successfully", - "result": { - "CancelDate": "2022-09-17 18:09:00", - "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7" - }, - "success": true - } - }, - "cancel_e_waybill": { - "request_data": { - "cancelRmrk": "Data Entry Mistake", - "cancelRsnCode": "3", - "ewbNo": "391009149369" - }, - "response_data": { - "message": "E-Way bill cancelled successfully", - "result": { - "cancelDate": "20/09/2022 12:10:00 PM", - "ewayBillNo": "391009149369" - }, - "success": true - } - }, - "dispatch_details": { - "DispDtls": { - "Nm": "Test Indian Registered Company", - "Addr1": "Test Address - 5", - "Loc": "Test City", - "Pin": 171302, - "Stcd": "02" - } - }, - "shipping_details": { - "ShipDtls": { - "Gstin": "36AMBPG7773M002", - "LglNm": "Test Registered Customer", - "TrdNm": "Test Registered Customer", - "Addr1": "Test Address - 4", - "Loc": "Test City", - "Pin": 500055, - "Stcd": "36" - } - }, - "foreign_transaction": { - "kwargs": { - "customer": "_Test Foreign Customer", - "port_address": "_Test Indian Unregistered Company-Billing", - "vehicle_no": "GJ07DL9009" - }, - "request_data": { - "BuyerDtls": { - "Addr1": "Test Address - 9", - "Gstin": "URP", - "LglNm": "_Test Foreign Customer", - "Loc": "Test City", - "Pin": 999999, - "Pos": "96", - "Stcd": "96", - "TrdNm": "_Test Foreign Customer" - }, - "DocDtls": { - "Dt": "16/09/2022", - "No": "test_invoice_no", - "Typ": "INV" - }, - "EwbDtls": { - "Distance": 0, - "TransMode": "1", - "VehNo": "GJ07DL9009", - "VehType": "R" - }, - "ItemList": [ - { - "AssAmt": 100000, - "CesAmt": 0, - "CesNonAdvlAmt": 0, - "CesRt": 0, - "CgstAmt": 0, - "Discount": 0, - "GstRt": 0, - "HsnCd": "61149090", - "IgstAmt": 0, - "IsServc": "N", - "PrdDesc": "Test Trading Goods 1", - "Qty": 1000, - "SgstAmt": 0, - "SlNo": "1", - "TotAmt": 100000, - "TotItemVal": 100000, - "Unit": "NOS", - "UnitPrice": 100 - } - ], - "PayDtls": { - "CrDay": 0, - "PaidAmt": 0, - "PaymtDue": 100000 - }, - "SellerDtls": { - "Addr1": "Test Address - 1", - "Gstin": "02AMBPG7773M002", - "LglNm": "_Test Indian Registered Company", - "Loc": "Test City", - "Pin": 171302, - "Stcd": "02", - "TrdNm": "_Test Indian Registered Company" - }, - "ShipDtls": { - "Gstin": "URP", - "LglNm": "Test Indian Unregistered Company", - "TrdNm": "Test Indian Unregistered Company", - "Addr1": "Test Address - 2", - "Loc": "Test City", - "Pin": 380015, - "Stcd": "24" - }, - "TranDtls": { - "RegRev": "N", - "SupTyp": "EXPWOP", - "TaxSch": "GST" - }, - "ValDtls": { - "AssVal": 100000, - "CesVal": 0, - "CgstVal": 0, - "Discount": 0, - "IgstVal": 0, - "OthChrg": 0, - "RndOffAmt": 0, - "SgstVal": 0, - "TotInvVal": 100000 - }, - "ExpDtls": { - "CntCode": "US", - "ShipBNo": "1234", - "ShipBDt": "2023-06-10", - "Port": "INABG1" - }, - "Version": "1.1" - } - } -} \ No newline at end of file + "goods_item_with_ewaybill": { + "kwargs": { + "vehicle_no": "GJ07DL9009" + }, + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 3", + "Gstin": "36AMBPG7773M002", + "LglNm": "_Test Registered Customer", + "Loc": "Test City", + "Pin": 500055, + "Pos": "02", + "Stcd": "36", + "TrdNm": "_Test Registered Customer" + }, + "DocDtls": { + "Dt": "16/09/2022", + "No": "test_invoice_no", + "Typ": "INV" + }, + "EwbDtls": { + "Distance": 0, + "TransMode": "1", + "VehNo": "GJ07DL9009", + "VehType": "R" + }, + "ItemList": [ + { + "AssAmt": 100000, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "CesRt": 0, + "CgstAmt": 0, + "Discount": 0, + "GstRt": 0, + "HsnCd": "61149090", + "IgstAmt": 0, + "IsServc": "N", + "PrdDesc": "Test Trading Goods 1", + "Qty": 1000, + "SgstAmt": 0, + "SlNo": "1", + "TotAmt": 100000, + "TotItemVal": 100000, + "Unit": "NOS", + "UnitPrice": 100 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 100000 + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "B2B", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 100000, + "CesVal": 0, + "CgstVal": 0, + "Discount": 0, + "IgstVal": 0, + "OthChrg": 0, + "RndOffAmt": 0, + "SgstVal": 0, + "TotInvVal": 100000 + }, + "Version": "1.1" + }, + "response_data": { + "info": [ + { + "InfCd": "EWBPPD", + "Desc": "Pin-Pin calc distance: 2467KM" + } + ], + "message": "IRN generated successfully", + "result": { + "AckDt": "2022-09-16 19:29:00", + "AckNo": 232210036743849, + "EwbDt": "2022-09-16 19:29:00", + "EwbNo": 391009149369, + "EwbValidTill": "2022-09-29 23:59:00", + "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7", + "Remarks": null, + "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NDM4NDksXCJBY2tEdFwiOlwiMjAyMi0wOS0xNiAxOToyOTowMFwiLFwiSXJuXCI6XCI3MDZkYWVjY2RhMGVmNmY4MThkYTc4ZjNhMmEwNWExMjg4NzMxMDU3MzczMDAyMjg5YjQ2YzMyMjkyODlhMmU3XCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJJTlZcIixcIk5vXCI6XCJTSU5WLUNGWS0wMDA2N1wiLFwiRHRcIjpcIjE2LzA5LzIwMjJcIn0sXCJTZWxsZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIlRyZE5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJCdXllckR0bHNcIjp7XCJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJMZ2xObVwiOlwiX1Rlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiVHJkTm1cIjpcIlRlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiUG9zXCI6XCIwMVwiLFwiQWRkcjFcIjpcIlRlc3QgQWRkcmVzcyAtIDNcIixcIkxvY1wiOlwiVGVzdCBDaXR5XCIsXCJQaW5cIjo1MDAwNTUsXCJTdGNkXCI6XCIzNlwifSxcIkRpc3BEdGxzXCI6e1wiTm1cIjpcIlRlc3QgSW5kaWFuIFJlZ2lzdGVyZWQgQ29tcGFueVwiLFwiQWRkcjFcIjpcIlRlc3QgQWRkcmVzcyAtIDFcIixcIkxvY1wiOlwiVGVzdCBDaXR5XCIsXCJQaW5cIjoxOTM1MDEsXCJTdGNkXCI6XCIwMVwifSxcIlNoaXBEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIlRlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiVHJkTm1cIjpcIlRlc3QgUmVnaXN0ZXJlZCBDdXN0b21lclwiLFwiQWRkcjFcIjpcIlRlc3QgQWRkcmVzcyAtIDNcIixcIkxvY1wiOlwiVGVzdCBDaXR5XCIsXCJQaW5cIjo1MDAwNTUsXCJTdGNkXCI6XCIzNlwifSxcIkl0ZW1MaXN0XCI6W3tcIkl0ZW1Ob1wiOjAsXCJTbE5vXCI6XCIxXCIsXCJJc1NlcnZjXCI6XCJOXCIsXCJQcmREZXNjXCI6XCJUZXN0IFRyYWRpbmcgR29vZHMgMVwiLFwiSHNuQ2RcIjpcIjYxMTQ5MDkwXCIsXCJRdHlcIjoxLjAsXCJVbml0XCI6XCJOT1NcIixcIlVuaXRQcmljZVwiOjEwMC4wLFwiVG90QW10XCI6MTAwLjAsXCJEaXNjb3VudFwiOjAsXCJBc3NBbXRcIjoxMDAuMCxcIkdzdFJ0XCI6MC4wLFwiSWdzdEFtdFwiOjAsXCJDZ3N0QW10XCI6MCxcIlNnc3RBbXRcIjowLFwiQ2VzUnRcIjowLFwiQ2VzQW10XCI6MCxcIkNlc05vbkFkdmxBbXRcIjowLFwiVG90SXRlbVZhbFwiOjEwMC4wfV0sXCJWYWxEdGxzXCI6e1wiQXNzVmFsXCI6MTAwLjAsXCJDZ3N0VmFsXCI6MCxcIlNnc3RWYWxcIjowLFwiSWdzdFZhbFwiOjAsXCJDZXNWYWxcIjowLFwiRGlzY291bnRcIjowLFwiT3RoQ2hyZ1wiOjAuMCxcIlJuZE9mZkFtdFwiOjAuMCxcIlRvdEludlZhbFwiOjEwMC4wfSxcIlBheUR0bHNcIjp7XCJDckRheVwiOjAsXCJQYWlkQW10XCI6MCxcIlBheW10RHVlXCI6MTAwLjB9LFwiRXdiRHRsc1wiOntcIlRyYW5zTW9kZVwiOlwiMVwiLFwiRGlzdGFuY2VcIjowLFwiVmVoTm9cIjpcIkdKMDdETDkwMDlcIixcIlZlaFR5cGVcIjpcIlJcIn19IiwiaXNzIjoiTklDIn0.ZOqrLJLsoXHf1QMRPBJoBesVluRB0a0ISsBGn6gqLuiJLfsAG1Oxmimqi9c7dboRnsW1eEj78Yps5D2A05WPMXwdkOy9Ahb_t4jXSGH-ijq_ed8z-xAtyiWH16YfIc9Zg020VkrlZiHdbfkx53hOwEA3aUhHIdPwQE5Kk-O9KWES3cttl9r5lrtzueTlTKB0GqXqiNlmuuQnCAJpWe34Coko1__kyPLLMdKgpOSB0EX2j7NjaZ5KPhu-GZHBtTvKczuSXvli6lwSQLaKpBm1IGvwMo2IzGW62pXp4XdMlcncLuc8wLTExSlKwHhsSspOxhMNBRx3NqcU0PQZOq050Q", + "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiU0lOVi1DRlktMDAwNjdcIixcIkRvY1R5cFwiOlwiSU5WXCIsXCJEb2NEdFwiOlwiMTYvMDkvMjAyMlwiLFwiVG90SW52VmFsXCI6MTAwLjAsXCJJdGVtQ250XCI6MSxcIk1haW5Ic25Db2RlXCI6XCI2MTE0OTA5MFwiLFwiSXJuXCI6XCI3MDZkYWVjY2RhMGVmNmY4MThkYTc4ZjNhMmEwNWExMjg4NzMxMDU3MzczMDAyMjg5YjQ2YzMyMjkyODlhMmU3XCIsXCJJcm5EdFwiOlwiMjAyMi0wOS0xNiAxOToyOTowMFwifSIsImlzcyI6Ik5JQyJ9.j7Fpl3ol0G6akp1-ukVzOK-8Dqoey3iKLf9SCaXGfb3crIcpniezqevH1qBTgCtUDYnOa0tRk5Nhyi-ER-W8Hu2a4Ug28AJFp3S8Xv2RwdMe9HvJN1b8KBKz6N4_WcO7wD2VcXyoEKDuTP2KFlXRjuZx7tBh5ttjQ4vRNtVwpR2qy-lRtMquEbZsJ-JOPBLUTXimdpVwt9EW8xKUxRKT_7-8kwK-DGHePADVBUjD6kv-GSpbxgfM4UAPg1TRlRz_BHMbbi9adZVZn5l9GA-WRuSP_7C-Qd_ucYg1cmP2zswh1XClMEjwjmxpuFkhqdDsRfl8unnEi--FxA0lvn4nmw", + "Status": "ACT", + "distance": 2467 + }, + "success": true + } + }, + "service_item": { + "kwargs": { + "item_code": "_Test Service Item", + "customer_address": "_Test Registered Customer-Billing", + "shipping_address_name": "_Test Registered Customer-Billing" + }, + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 3", + "Gstin": "36AMBPG7773M002", + "LglNm": "_Test Registered Customer", + "Loc": "Test City", + "Pin": 500055, + "Pos": "02", + "Stcd": "36", + "TrdNm": "_Test Registered Customer" + }, + "DocDtls": { + "Dt": "17/09/2022", + "No": "test_invoice_no", + "Typ": "INV" + }, + "ItemList": [ + { + "AssAmt": 100.0, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "CesRt": 0, + "CgstAmt": 0, + "Discount": 0, + "GstRt": 0.0, + "HsnCd": "999900", + "IgstAmt": 0, + "IsServc": "Y", + "PrdDesc": "Test Service Item", + "Qty": 1.0, + "SgstAmt": 0, + "SlNo": "1", + "TotAmt": 100.0, + "TotItemVal": 100.0, + "Unit": "NOS", + "UnitPrice": 100.0 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 100.0 + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "B2B", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 100.0, + "CesVal": 0, + "CgstVal": 0, + "Discount": 0, + "IgstVal": 0, + "OthChrg": 0.0, + "RndOffAmt": 0.0, + "SgstVal": 0, + "TotInvVal": 100.0 + }, + "Version": "1.1" + }, + "response_data": { + "success": true, + "message": "IRN generated successfully", + "result": { + "AckNo": 232210036754863, + "AckDt": "2022-09-17 16:26:00", + "Irn": "68fb4fab44aee99fb23292478c4bd838e664837c9f1b04e3d9134ffed0b40b60", + "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTQ4NjMsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNjoyNjowMFwiLFwiSXJuXCI6XCI2OGZiNGZhYjQ0YWVlOTlmYjIzMjkyNDc4YzRiZDgzOGU2NjQ4MzdjOWYxYjA0ZTNkOTEzNGZmZWQwYjQwYjYwXCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJJTlZcIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIzNkFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiWVwiLFwiUHJkRGVzY1wiOlwiVGVzdCBTZXJ2aWNlIEl0ZW1cIixcIkhzbkNkXCI6XCI5OTU0MTFcIixcIlF0eVwiOjEuMCxcIlVuaXRcIjpcIk5PU1wiLFwiVW5pdFByaWNlXCI6MTAwLjAsXCJUb3RBbXRcIjoxMDAuMCxcIkRpc2NvdW50XCI6MCxcIkFzc0FtdFwiOjEwMC4wLFwiR3N0UnRcIjowLjAsXCJJZ3N0QW10XCI6MCxcIkNnc3RBbXRcIjowLFwiU2dzdEFtdFwiOjAsXCJDZXNSdFwiOjAsXCJDZXNBbXRcIjowLFwiQ2VzTm9uQWR2bEFtdFwiOjAsXCJUb3RJdGVtVmFsXCI6MTAwLjB9XSxcIlZhbER0bHNcIjp7XCJBc3NWYWxcIjoxMDAuMCxcIkNnc3RWYWxcIjowLFwiU2dzdFZhbFwiOjAsXCJJZ3N0VmFsXCI6MCxcIkNlc1ZhbFwiOjAsXCJEaXNjb3VudFwiOjAsXCJPdGhDaHJnXCI6MC4wLFwiUm5kT2ZmQW10XCI6MC4wLFwiVG90SW52VmFsXCI6MTAwLjB9LFwiUGF5RHRsc1wiOntcIkNyRGF5XCI6MCxcIlBhaWRBbXRcIjowLFwiUGF5bXREdWVcIjoxMDAuMH0sXCJFd2JEdGxzXCI6e1wiRGlzdGFuY2VcIjowfX0iLCJpc3MiOiJOSUMifQ.rcfXkciqDJypX-xCqaUU3xAk2gccHK_qBD_FBIUsEr-SyWVs4LStgXwQEWUhTYnEfcGGm_sWX15ewC0jn9iWVFCNNFnjKc8vsFQqnbzvi-bnr6CWkjRXOxVqQTfis6PbtTrblojBq2hhBaT1B_ZlgLePi5qFNWnxxaHItjYtBEeBzW5JxzXWQTqESrBy02iLQgQMOexmQ6jKGdUR3tRRG5MVB7QfXbL9BpQr-DXbHhbDGllT2S_xyj9SBsN6ICeluCG-ZJdHE_kCoIxc8iY_nXneb2PsBciij14cHb96h4ddNZtapxTPTh4CumVDmAgLBSVsBTugp8vm0L-jd7n3dg", + "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIklOVlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiOTk1NDExXCIsXCJJcm5cIjpcIjY4ZmI0ZmFiNDRhZWU5OWZiMjMyOTI0NzhjNGJkODM4ZTY2NDgzN2M5ZjFiMDRlM2Q5MTM0ZmZlZDBiNDBiNjBcIixcIklybkR0XCI6XCIyMDIyLTA5LTE3IDE2OjI2OjAwXCJ9IiwiaXNzIjoiTklDIn0.A99BPXfKiGSNjnEcqmxc7RGutWakaeW0NMan9oC5yMw6zAoTNcVc34GtQKV7iajBZQhyiFrNwn5n6QtYOXafpitHcI_yrUWSojQBPPpPlslqj4hbnbCy7kmOZZ8mOKISHJrsIZJxpjRlSquIzfDN4aP1aT_qHDqwFqyA8RyJM-id5EpDaTrUFK12HwjKfAXHn4shEUBBgEWrHYOKK6VdpCNi6F_5I5bbJRivrvUJxMLjk3Ux9fyylqnEeyE2NThs9hFuV9EgoVzGE3FhfPZsooToAuG_npYEv3f6Q9KbOw3pNQ3NkqFwvmFjfNJLXdbxZIPe9fe9F1c-CRrIoNo_9w", + "Status": "ACT", + "EwbNo": null, + "EwbDt": null, + "EwbValidTill": null, + "Remarks": null + }, + "info": [ + { + "InfCd": "EWBERR", + "Desc": [ + { + "ErrorCode": "4019", + "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" + } + ] + } + ] + } + }, + "return_invoice": { + "kwargs": { + "qty": -1, + "is_return": 1, + "customer_address": "_Test Registered Customer-Billing", + "shipping_address_name": "_Test Registered Customer-Billing" + }, + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 3", + "Gstin": "36AMBPG7773M002", + "LglNm": "_Test Registered Customer", + "Loc": "Test City", + "Pin": 500055, + "Pos": "02", + "Stcd": "36", + "TrdNm": "_Test Registered Customer" + }, + "DocDtls": { + "Dt": "17/09/2022", + "No": "test_invoice_no", + "Typ": "CRN" + }, + "ItemList": [ + { + "SlNo": "1", + "PrdDesc": "Test Trading Goods 1", + "IsServc": "N", + "HsnCd": "61149090", + "Unit": "NOS", + "Qty": 1.0, + "UnitPrice": 7.6, + "TotAmt": 7.6, + "Discount": 0, + "AssAmt": 7.6, + "GstRt": 12.0, + "IgstAmt": 0, + "CgstAmt": 0.46, + "SgstAmt": 0.46, + "CesRt": 0, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "TotItemVal": 8.52 + }, + { + "SlNo": "2", + "PrdDesc": "Test Trading Goods 1", + "IsServc": "N", + "HsnCd": "61149090", + "Unit": "NOS", + "Qty": 1.0, + "UnitPrice": 7.6, + "TotAmt": 7.6, + "Discount": 0, + "AssAmt": 7.6, + "GstRt": 12.0, + "IgstAmt": 0, + "CgstAmt": 0.45, + "SgstAmt": 0.45, + "CesRt": 0, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "TotItemVal": 8.5 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 0 + }, + "RefDtls": { + "PrecDocDtls": [ + { + "InvDt": "17/09/2022", + "InvNo": "SINV-CFY-00092" + } + ] + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "B2B", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 15.2, + "CesVal": 0, + "CgstVal": 0.91, + "Discount": 0, + "IgstVal": 0, + "OthChrg": 0.0, + "RndOffAmt": -0.02, + "SgstVal": 0.91, + "TotInvVal": 17.0 + }, + "Version": "1.1" + }, + "response_data": { + "success": true, + "message": "IRN generated successfully", + "result": { + "AckNo": 232210036755145, + "AckDt": "2022-09-17 17:05:00", + "Irn": "1c96258af085e45da556494ea5e5a7b401a598ab80af4136309c2dac7b54d795", + "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTUxNDUsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNzowNTowMFwiLFwiSXJuXCI6XCIxYzk2MjU4YWYwODVlNDVkYTU1NjQ5NGVhNWU1YTdiNDAxYTU5OGFiODBhZjQxMzYzMDljMmRhYzdiNTRkNzk1XCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJDUk5cIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIlRyZE5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiTlwiLFwiUHJkRGVzY1wiOlwiVGVzdCBUcmFkaW5nIEdvb2RzIDFcIixcIkhzbkNkXCI6XCI2MTE0OTA5MFwiLFwiUXR5XCI6MS4wLFwiVW5pdFwiOlwiTk9TXCIsXCJVbml0UHJpY2VcIjoxMDAuMCxcIlRvdEFtdFwiOjEwMC4wLFwiRGlzY291bnRcIjowLFwiQXNzQW10XCI6MTAwLjAsXCJHc3RSdFwiOjAuMCxcIklnc3RBbXRcIjowLFwiQ2dzdEFtdFwiOjAsXCJTZ3N0QW10XCI6MCxcIkNlc1J0XCI6MCxcIkNlc0FtdFwiOjAsXCJDZXNOb25BZHZsQW10XCI6MCxcIlRvdEl0ZW1WYWxcIjoxMDAuMH1dLFwiVmFsRHRsc1wiOntcIkFzc1ZhbFwiOjEwMC4wLFwiQ2dzdFZhbFwiOjAsXCJTZ3N0VmFsXCI6MCxcIklnc3RWYWxcIjowLFwiQ2VzVmFsXCI6MCxcIkRpc2NvdW50XCI6MCxcIk90aENocmdcIjowLjAsXCJSbmRPZmZBbXRcIjowLjAsXCJUb3RJbnZWYWxcIjoxMDAuMH0sXCJQYXlEdGxzXCI6e1wiQ3JEYXlcIjowLFwiUGFpZEFtdFwiOjAsXCJQYXltdER1ZVwiOjAuMH0sXCJSZWZEdGxzXCI6e1wiUHJlY0RvY0R0bHNcIjpbe1wiSW52Tm9cIjpcIlNJTlYtQ0ZZLTAwMDkyXCIsXCJJbnZEdFwiOlwiMTcvMDkvMjAyMlwifV19LFwiRXdiRHRsc1wiOntcIkRpc3RhbmNlXCI6MH19IiwiaXNzIjoiTklDIn0.OZpYYN2pXIPJmLTi79NuVxVWWWGwdUzdnIEKXpgXyCEtXqgfwm9opf6EbyEt6wjwiYV4b2HWAfmkOXYJ-TO3dMc_98nOW8SeupGC6k-aV5kcDK9DNiRNWZBCiTMCRhlfmYC3oEZtd5f6yVoHvESl-QKeBlXcK2L8M1C0Guuq3QYqyhFp_gFqz_xY6ImKi65hEMDBghCaKINatRvPtFiwmtIspxUaHid5YcyqXdBxerEDjz-D2RjHOnhq1K_JX07X9xqcncLa106XltEeVFswJiEfqwtZTKDzkd4klymGJL5je7L1tgO8sNTKXGtFETKZfKmFSvxsMrBy1Dlp0LXXtg", + "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIkNSTlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiNjExNDkwOTBcIixcIklyblwiOlwiMWM5NjI1OGFmMDg1ZTQ1ZGE1NTY0OTRlYTVlNWE3YjQwMWE1OThhYjgwYWY0MTM2MzA5YzJkYWM3YjU0ZDc5NVwiLFwiSXJuRHRcIjpcIjIwMjItMDktMTcgMTc6MDU6MDBcIn0iLCJpc3MiOiJOSUMifQ.fgLb2ILeWK-MHxs61jZeo3kgy6w-sFXkVSh9GbzH0yWCeVx8MTsRwkKEXklPlmdwudG1lUPVjWsOioCgLL1Q8EE_wqnpGY00JEWr5X74GduBLe0lo5ZKtsSIVf10REy1E7_JJV8qitSZa3JeSqdjPlTUowFpxvPiw-nu3fyBP92IdVFbGO6oMvodI66kanqEyKj26Rn0nfnHP3KT3u61RBUSaVm_gch79cnPQanDPrJtd_0Ra2Vn7FoopdfcNIEdASB71IDRHsMFCNs8LyHTtFoJVI2LqU8wic_A6oWZkswOTBHdsBauMa_CMF9-2QbwTHFv60yvS7KuS2HvBw-oyQ", + "Status": "ACT", + "EwbNo": null, + "EwbDt": null, + "EwbValidTill": null, + "Remarks": null + }, + "info": [ + { + "InfCd": "EWBERR", + "Desc": [ + { + "ErrorCode": "4019", + "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" + } + ] + } + ] + } + }, + "debit_invoice": { + "kwargs": { + "is_debit_note": 1, + "qty": 0, + "customer_address": "_Test Registered Customer-Billing", + "shipping_address_name": "_Test Registered Customer-Billing" + }, + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 3", + "Gstin": "36AMBPG7773M002", + "LglNm": "_Test Registered Customer", + "Loc": "Test City", + "Pin": 500055, + "Pos": "02", + "Stcd": "36", + "TrdNm": "_Test Registered Customer" + }, + "DocDtls": { + "Dt": "17/09/2022", + "No": "test_invoice_no", + "Typ": "DBN" + }, + "ItemList": [ + { + "AssAmt": 100.0, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "CesRt": 0, + "CgstAmt": 0, + "Discount": 0, + "GstRt": 0.0, + "HsnCd": "61149090", + "IgstAmt": 0, + "IsServc": "N", + "PrdDesc": "Test Trading Goods 1", + "Qty": 0.0, + "SgstAmt": 0, + "SlNo": "1", + "TotAmt": 100.0, + "TotItemVal": 100.0, + "Unit": "NOS", + "UnitPrice": 100.0 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 100.0 + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "B2B", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 100.0, + "CesVal": 0, + "CgstVal": 0, + "Discount": 0, + "IgstVal": 0, + "OthChrg": 0.0, + "RndOffAmt": 0.0, + "SgstVal": 0, + "TotInvVal": 100.0 + }, + "Version": "1.1" + }, + "response_data": { + "success": true, + "message": "IRN generated successfully", + "result": { + "AckNo": 232210036755701, + "AckDt": "2022-09-17 17:50:00", + "Irn": "24f37c80532583c6894d8153e2b12494daa80ddbb197f0fc2c1bac07db67f933", + "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTU3MDEsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNzo1MDowMFwiLFwiSXJuXCI6XCIyNGYzN2M4MDUzMjU4M2M2ODk0ZDgxNTNlMmIxMjQ5NGRhYTgwZGRiYjE5N2YwZmMyYzFiYWMwN2RiNjdmOTMzXCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJEQk5cIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIzNkFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiTlwiLFwiUHJkRGVzY1wiOlwiVGVzdCBUcmFkaW5nIEdvb2RzIDFcIixcIkhzbkNkXCI6XCI2MTE0OTA5MFwiLFwiUXR5XCI6MS4wLFwiVW5pdFwiOlwiTk9TXCIsXCJVbml0UHJpY2VcIjoxMDAuMCxcIlRvdEFtdFwiOjEwMC4wLFwiRGlzY291bnRcIjowLFwiQXNzQW10XCI6MTAwLjAsXCJHc3RSdFwiOjAuMCxcIklnc3RBbXRcIjowLFwiQ2dzdEFtdFwiOjAsXCJTZ3N0QW10XCI6MCxcIkNlc1J0XCI6MCxcIkNlc0FtdFwiOjAsXCJDZXNOb25BZHZsQW10XCI6MCxcIlRvdEl0ZW1WYWxcIjoxMDAuMH1dLFwiVmFsRHRsc1wiOntcIkFzc1ZhbFwiOjEwMC4wLFwiQ2dzdFZhbFwiOjAsXCJTZ3N0VmFsXCI6MCxcIklnc3RWYWxcIjowLFwiQ2VzVmFsXCI6MCxcIkRpc2NvdW50XCI6MCxcIk90aENocmdcIjowLjAsXCJSbmRPZmZBbXRcIjowLjAsXCJUb3RJbnZWYWxcIjoxMDAuMH0sXCJQYXlEdGxzXCI6e1wiQ3JEYXlcIjowLFwiUGFpZEFtdFwiOjAsXCJQYXltdER1ZVwiOjEwMC4wfSxcIkV3YkR0bHNcIjp7XCJEaXN0YW5jZVwiOjB9fSIsImlzcyI6Ik5JQyJ9.ad1NfDA-H8FgBHr_kaTeiVWUj-f8T6NXuLFWa1gprxGhACXoI9h6sU47U9PBxHcZ7qVXcYAKzA9CbNvAfxWCLKtjur5p85uIrksZYDD494lodGn3QeXbyjXMJeh7eM0mcKKN3Chp0TxaUfi9C7mA9W0R8HYKNnXIOT1CVlD-brrAw09_QiDsNgMhLBX5QfpHKIHPKCIEl_DgmWlnMzduy1iKYPpNreNPCV-J-ZaVQjxl93LjKBUb5AF1XWyWvPw_e8ePZEttviX_bU_Nnm1M4zCj-QWYzj8A0bauzl7kjp5UajEM7_7CLAI4sjZnqonGKYFfR5rf2Qj76exbs_pWGw", + "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIkRCTlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiNjExNDkwOTBcIixcIklyblwiOlwiMjRmMzdjODA1MzI1ODNjNjg5NGQ4MTUzZTJiMTI0OTRkYWE4MGRkYmIxOTdmMGZjMmMxYmFjMDdkYjY3ZjkzM1wiLFwiSXJuRHRcIjpcIjIwMjItMDktMTcgMTc6NTA6MDBcIn0iLCJpc3MiOiJOSUMifQ.TUE7iIvF9Orc1bEuOaxtSuj0D6vP1MwV-hcoh3IZqV7EKXwMmm0PkVrN87vRzSu97NMKfQIHJDRgPYv4prhuT0dshWJ9A_kC4jiRSm5Naj_R1egBKsv5ykTojOKKrkGy35DtdUcJK_FyiD0qFfmMmInFD8u6D8W83eEo93i99RONgVKUyCkd_uqs-cz1P-PTlsi2xWbeDVVIcRoAmf-lcsbwkl2Hn6ECHgorKJHPJC1FGo1jRQ2Ktq0ODiJdncplbxbdYN19vUz61JJB4DPzWtf8wOkX11N0fDhdUdEINfJURWEOIGQRYip5GIJuA2qdqxZieVk0CetnsckcGgfE8g", + "Status": "ACT", + "EwbNo": null, + "EwbDt": null, + "EwbValidTill": null, + "Remarks": null + }, + "info": [ + { + "InfCd": "EWBERR", + "Desc": [ + { + "ErrorCode": "4019", + "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" + } + ] + } + ] + } + }, + "cancel_e_invoice": { + "request_data": { + "Cnlrem": "Data Entry Mistake", + "Cnlrsn": "2", + "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7" + }, + "response_data": { + "message": "E-Invoice is cancelled successfully", + "result": { + "CancelDate": "2022-09-17 18:09:00", + "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7" + }, + "success": true + } + }, + "cancel_e_waybill": { + "request_data": { + "cancelRmrk": "Data Entry Mistake", + "cancelRsnCode": "3", + "ewbNo": "391009149369" + }, + "response_data": { + "message": "E-Way bill cancelled successfully", + "result": { + "cancelDate": "20/09/2022 12:10:00 PM", + "ewayBillNo": "391009149369" + }, + "success": true + } + }, + "dispatch_details": { + "DispDtls": { + "Nm": "Test Indian Registered Company", + "Addr1": "Test Address - 5", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02" + } + }, + "shipping_details": { + "ShipDtls": { + "Gstin": "36AMBPG7773M002", + "LglNm": "Test Registered Customer", + "TrdNm": "Test Registered Customer", + "Addr1": "Test Address - 4", + "Loc": "Test City", + "Pin": 500055, + "Stcd": "36" + } + }, + "foreign_transaction": { + "kwargs": { + "customer": "_Test Foreign Customer", + "port_address": "_Test Indian Unregistered Company-Billing", + "vehicle_no": "GJ07DL9009" + }, + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 9", + "Gstin": "URP", + "LglNm": "_Test Foreign Customer", + "Loc": "Test City", + "Pin": 999999, + "Pos": "96", + "Stcd": "96", + "TrdNm": "_Test Foreign Customer" + }, + "DocDtls": { + "Dt": "16/09/2022", + "No": "test_invoice_no", + "Typ": "INV" + }, + "EwbDtls": { + "Distance": 0, + "TransMode": "1", + "VehNo": "GJ07DL9009", + "VehType": "R" + }, + "ItemList": [ + { + "AssAmt": 100000, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "CesRt": 0, + "CgstAmt": 0, + "Discount": 0, + "GstRt": 0, + "HsnCd": "61149090", + "IgstAmt": 0, + "IsServc": "N", + "PrdDesc": "Test Trading Goods 1", + "Qty": 1000, + "SgstAmt": 0, + "SlNo": "1", + "TotAmt": 100000, + "TotItemVal": 100000, + "Unit": "NOS", + "UnitPrice": 100 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 100000 + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "ShipDtls": { + "Gstin": "URP", + "LglNm": "Test Indian Unregistered Company", + "TrdNm": "Test Indian Unregistered Company", + "Addr1": "Test Address - 2", + "Loc": "Test City", + "Pin": 380015, + "Stcd": "24" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "EXPWOP", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 100000, + "CesVal": 0, + "CgstVal": 0, + "Discount": 0, + "IgstVal": 0, + "OthChrg": 0, + "RndOffAmt": 0, + "SgstVal": 0, + "TotInvVal": 100000 + }, + "ExpDtls": { + "CntCode": "US", + "ShipBNo": "1234", + "ShipBDt": "2023-06-10", + "Port": "INABG1" + }, + "Version": "1.1" + } + }, + "duplicate_irn": { + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 3", + "Gstin": "36AMBPG7773M002", + "LglNm": "_Test Registered Customer", + "Loc": "Test City", + "Pin": 500055, + "Pos": "02", + "Stcd": "36", + "TrdNm": "_Test Registered Customer" + }, + "DocDtls": { + "Dt": "03/10/2023", + "No": "test_invoice_no", + "Typ": "INV" + }, + "ItemList": [ + { + "AssAmt": 1400.0, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "CesRt": 0, + "CgstAmt": 126.0, + "Discount": 0, + "GstRt": 18.0, + "HsnCd": "61149090", + "IgstAmt": 0, + "IsServc": "N", + "PrdDesc": "Test Trading Goods 1", + "Qty": 1.0, + "SgstAmt": 126.0, + "SlNo": "1", + "TotAmt": 1400.0, + "TotItemVal": 1652.0, + "Unit": "NOS", + "UnitPrice": 1400.0 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 1652.0 + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "B2B", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 1400.0, + "CesVal": 0, + "CgstVal": 126.0, + "Discount": 0, + "IgstVal": 0, + "OthChrg": 0.0, + "RndOffAmt": 0.0, + "SgstVal": 126.0, + "TotInvVal": 1652.0 + }, + "Version": "1.1" + }, + "response_data": { + "error_code": "2150", + "message": "2150 : Duplicate IRN", + "result": [ + { + "Desc": { + "AckDt": "2023-10-02 16:26:00", + "AckNo": 232210036743849, + "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7" + }, + "InfCd": "DUPIRN" + } + ], + "success": false + } + }, + "get_e_invoice_by_irn": { + "request_data": "irn=706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7", + "response_data": { + "message": "E-Invoice fetched successfully", + "result": { + "AckDt": "2023-10-03 15:09:00", + "AckNo": 232210036743849, + "EwbDt": null, + "EwbNo": null, + "EwbValidTill": null, + "Irn": "706daeccda0ef6f818da78f3a2a05a1288731057373002289b46c3229289a2e7", + "Remarks": null, + "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE1MTNCODIxRUU0NkM3NDlBNjNCODZFMzE4QkY3MTEwOTkyODdEMUYiLCJ4NXQiOiJGUk80SWU1R3gwbW1PNGJqR0w5eEVKa29mUjgiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoxMzIzMTAwNDYwNjM2MDEsXCJBY2tEdFwiOlwiMjAyMy0xMC0wMyAxNTowODozOVwiLFwiSXJuXCI6XCIyZWNjNDJhOGU4MWRjYmJiNTY5MjNlNGJkMDQ2Yzg2MWNlNzM3NDA2MGJkMzE4NzVjZTM1NDQ3ZTkyMmM5Mzk5XCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJJTlZcIixcIk5vXCI6XCI5c1E1RTJcIixcIkR0XCI6XCIwMi8xMC8yMDIzXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMkFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJSZXNpbGllbnQgVGVzdFwiLFwiVHJkTm1cIjpcIlJlc2lsaWVudCBUZXN0XCIsXCJBZGRyMVwiOlwiU2h1YmhhbnB1cmFcIixcIkxvY1wiOlwiVmFkb2RhcmFcIixcIlBpblwiOjE3MTMwMixcIlN0Y2RcIjpcIjAyXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ29tcG9zaXRpb24gQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJfVGVzdCBSZWdpc3RlcmVkIENvbXBvc2l0aW9uIEN1c3RvbWVyXCIsXCJQb3NcIjpcIjM2XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gNlwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjUwMDA1NSxcIlN0Y2RcIjpcIjM2XCJ9LFwiSXRlbUxpc3RcIjpbe1wiSXRlbU5vXCI6MCxcIlNsTm9cIjpcIjFcIixcIklzU2VydmNcIjpcIk5cIixcIlByZERlc2NcIjpcIlRlc3QgVHJhZGluZyBHb29kcyAxXCIsXCJIc25DZFwiOlwiNjExNDkwOTBcIixcIlF0eVwiOjEuMCxcIlVuaXRcIjpcIk5PU1wiLFwiVW5pdFByaWNlXCI6MTIwMC4wLFwiVG90QW10XCI6MTIwMC4wLFwiRGlzY291bnRcIjowLFwiQXNzQW10XCI6MTIwMC4wLFwiR3N0UnRcIjoxOC4wLFwiSWdzdEFtdFwiOjIxNi4wLFwiQ2dzdEFtdFwiOjAsXCJTZ3N0QW10XCI6MCxcIkNlc1J0XCI6MCxcIkNlc0FtdFwiOjAsXCJDZXNOb25BZHZsQW10XCI6MCxcIlRvdEl0ZW1WYWxcIjoxNDE2LjB9XSxcIlZhbER0bHNcIjp7XCJBc3NWYWxcIjoxMjAwLjAsXCJDZ3N0VmFsXCI6MCxcIlNnc3RWYWxcIjowLFwiSWdzdFZhbFwiOjIxNi4wLFwiQ2VzVmFsXCI6MCxcIkRpc2NvdW50XCI6MCxcIk90aENocmdcIjowLjAsXCJSbmRPZmZBbXRcIjowLjAsXCJUb3RJbnZWYWxcIjoxNDE2LjB9LFwiUGF5RHRsc1wiOntcIkNyRGF5XCI6MCxcIlBhaWRBbXRcIjowLFwiUGF5bXREdWVcIjoxNDE2LjB9fSIsImlzcyI6Ik5JQyBTYW5kYm94In0.B3VNUOB_DlHiiOgdl3MpMFiY3uQVrp2mTs0FUP9wxp_djYm3fRTraReju2Hq-eFn1FOE_lHuK7-oIqnjr1uTNOzB62pcQ_iF0M79OGhhVgGgelz-WQQH-DfdhktTn1OlA_ZjeIhU8qM3-ksfH5lncQ_chgLrK1lH3SsarXmqMxNR4vZY_arEniymEDluE9CMxlkvcCIXVcYiAQvP785aG3Lf2vzC7Z9dAToghxDAeU2b1wJ-k_i5SNYZOdIOfPfZWYhIDXsHW4hVkt4EQdkTV5HBTUYA29EIwbwzDfwdAszvGxJVJgtoXjJk5NWMhhUnq19XHJ0pAr8hUOdI6so9_w", + "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjE1MTNCODIxRUU0NkM3NDlBNjNCODZFMzE4QkY3MTEwOTkyODdEMUYiLCJ4NXQiOiJGUk80SWU1R3gwbW1PNGJqR0w5eEVKa29mUjgiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAyQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiOXNRNUUyXCIsXCJEb2NUeXBcIjpcIklOVlwiLFwiRG9jRHRcIjpcIjAyLzEwLzIwMjNcIixcIlRvdEludlZhbFwiOjE0MTYuMCxcIkl0ZW1DbnRcIjoxLFwiTWFpbkhzbkNvZGVcIjpcIjYxMTQ5MDkwXCIsXCJJcm5cIjpcIjJlY2M0MmE4ZTgxZGNiYmI1NjkyM2U0YmQwNDZjODYxY2U3Mzc0MDYwYmQzMTg3NWNlMzU0NDdlOTIyYzkzOTlcIixcIklybkR0XCI6XCIyMDIzLTEwLTAzIDE1OjA4OjM5XCJ9IiwiaXNzIjoiTklDIFNhbmRib3gifQ.fTW2bXRGYvCma3UpS0axy26c6dTW0nY9RtDI5lGX4x2n29iwdcJOHvBwr6rskVKvnLEWHX9VgxDu2Jknyeu6RBlPLD1pGTORATj0nWo_kNwXJi-l_--6J0WC5is8G11eK04Mnepk63SKrbPTr8uYRd5OigMhDmSCde_tqFfUIIMtnx8AY6z0snkx3KQaooPJW06eU_QaACgrmypE1q-hfpNJOhzlHy-a4VC4zW2HljUZkubbK_5Dyb0Zy2tGc15enZeHi9DhvDFEUqiyJavjwsijbugJumR81LyOC1M4JtFEWoMrCA8bsbwAAFUtRgVC6pDVNZlthHqHxObSf9yv6g", + "Status": "ACT" + }, + "success": true + } + } +} diff --git a/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.js b/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.js index 4b603f5fe..85397837a 100644 --- a/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.js +++ b/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.js @@ -8,6 +8,8 @@ frappe.ui.form.on("Bill of Entry", { }, refresh(frm) { + india_compliance.set_reconciliation_status(frm, "bill_of_entry_no"); + if (frm.doc.docstatus === 0) return; // check if Journal Entry exists; diff --git a/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.json b/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.json index 0fea1603f..ec1001839 100644 --- a/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.json +++ b/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.json @@ -9,9 +9,9 @@ "engine": "InnoDB", "field_order": [ "naming_series", - "column_break_2wjs", - "amended_from", "column_break_hbfk", + "amended_from", + "column_break_2wjs", "section_break_fqdh", "purchase_invoice", "column_break_epap", @@ -31,6 +31,7 @@ "column_break_xjvk", "customs_expense_account", "column_break_zsed", + "reconciliation_status", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -269,13 +270,22 @@ "fieldname": "bill_of_lading_no", "fieldtype": "Data", "label": "Bill of Lading No." + }, + { + "fieldname": "reconciliation_status", + "fieldtype": "Select", + "label": "Reconciliation Status", + "no_copy": 1, + "options": "\nNot Applicable\nReconciled\nUnreconciled\nIgnored", + "print_hide": 1, + "read_only": 1 } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-04-28 07:26:31.704232", + "modified": "2023-09-14 16:43:49.370165", "modified_by": "Administrator", "module": "GST India", "name": "Bill of Entry", diff --git a/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.py b/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.py index c0ff0efef..7dc7d780c 100644 --- a/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.py +++ b/india_compliance/gst_india/doctype/bill_of_entry/bill_of_entry.py @@ -41,6 +41,7 @@ def before_validate(self): def validate(self): self.validate_purchase_invoice() self.validate_taxes() + self.reconciliation_status = "Unreconciled" def on_submit(self): make_gl_entries(self.get_gl_entries()) diff --git a/india_compliance/gst_india/doctype/gst_credential/gst_credential.json b/india_compliance/gst_india/doctype/gst_credential/gst_credential.json index be47bc4d0..190f4689b 100644 --- a/india_compliance/gst_india/doctype/gst_credential/gst_credential.json +++ b/india_compliance/gst_india/doctype/gst_credential/gst_credential.json @@ -10,7 +10,11 @@ "service", "gstin", "username", - "password" + "password", + "app_key", + "session_key", + "auth_token", + "session_expiry" ], "fields": [ { @@ -49,11 +53,39 @@ "fieldtype": "Password", "in_list_view": 1, "label": "Password" + }, + { + "fieldname": "session_key", + "fieldtype": "Data", + "hidden": 1, + "label": "Session Key", + "read_only": 1 + }, + { + "fieldname": "auth_token", + "fieldtype": "Data", + "hidden": 1, + "label": "Auth Token", + "read_only": 1 + }, + { + "fieldname": "app_key", + "fieldtype": "Data", + "hidden": 1, + "in_list_view": 1, + "label": "App Key" + }, + { + "fieldname": "session_expiry", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Session Expiry Date", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2022-07-24 02:04:03.084096", + "modified": "2023-10-04 12:44:54.332474", "modified_by": "Administrator", "module": "GST India", "name": "GST Credential", diff --git a/india_compliance/gst_india/doctype/gst_inward_supply/__init__.py b/india_compliance/gst_india/doctype/gst_inward_supply/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.js b/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.js new file mode 100644 index 000000000..747be87f6 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.js @@ -0,0 +1,8 @@ +// Copyright (c) 2022, Resilient Tech and contributors +// For license information, please see license.txt + +frappe.ui.form.on('GST Inward Supply', { + // refresh: function(frm) { + + // } +}); diff --git a/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.json b/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.json new file mode 100644 index 000000000..f6091d6b6 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.json @@ -0,0 +1,402 @@ +{ + "actions": [], + "autoname": "format:GST-IS-{######}", + "creation": "2022-04-12 17:45:44.603856", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "section_break_1", + "company", + "column_break_3", + "company_gstin", + "section_break_4", + "supplier_name", + "column_break_8", + "supplier_gstin", + "section_break_8", + "bill_no", + "doc_type", + "place_of_supply", + "column_break_13", + "bill_date", + "supply_type", + "classification", + "is_reverse_charge", + "section_break_16", + "items", + "document_value", + "section_break_18", + "diffprcnt", + "column_break_15", + "port_code", + "section_break_27", + "irn_source", + "irn_number", + "column_break_30", + "irn_gen_date", + "section_break_25", + "itc_availability", + "return_period_2b", + "column_break_28", + "reason_itc_unavailability", + "gen_date_2b", + "section_break_32", + "other_return_period", + "original_bill_no", + "is_amended", + "column_break_36", + "amendment_type", + "original_bill_date", + "original_doc_type", + "section_break_22", + "action", + "link_doctype", + "column_break_27", + "match_status", + "link_name", + "section_break_43", + "sup_return_period", + "gstr_1_filled", + "gstr_3b_filled", + "column_break_47", + "gstr_1_filing_date", + "registration_cancel_date" + ], + "fields": [ + { + "fieldname": "section_break_1", + "fieldtype": "Section Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "company_gstin", + "fieldtype": "Data", + "label": "Company GSTIN" + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Supplier Details" + }, + { + "fieldname": "supplier_name", + "fieldtype": "Data", + "label": "Supplier Name" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "fieldname": "supplier_gstin", + "fieldtype": "Data", + "label": "Supplier GSTIN" + }, + { + "fieldname": "section_break_8", + "fieldtype": "Section Break", + "label": "Document Details" + }, + { + "fieldname": "doc_type", + "fieldtype": "Select", + "label": "Document Type", + "options": "\nInvoice\nCredit Note\nDebit Note\nISD Invoice\nISD Credit Note\nBill of Entry" + }, + { + "fieldname": "place_of_supply", + "fieldtype": "Data", + "label": "Place of Supply" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "fieldname": "supply_type", + "fieldtype": "Select", + "label": "Supply Type", + "options": "\nRegular\nSEZ Without Payment of Tax\nSEZ With Payment of Tax\nDeemend Exports" + }, + { + "fieldname": "classification", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Classification", + "options": "\nB2B\nB2BA\nCDNR\nCDNRA\nISD\nISDA\nIMPG\nIMPGSEZ" + }, + { + "fieldname": "section_break_16", + "fieldtype": "Section Break" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "GST Inward Supply Item" + }, + { + "fieldname": "document_value", + "fieldtype": "Float", + "label": "Document Value" + }, + { + "fieldname": "section_break_18", + "fieldtype": "Section Break" + }, + { + "fieldname": "diffprcnt", + "fieldtype": "Data", + "label": "Applicable % of tax rate" + }, + { + "fieldname": "column_break_15", + "fieldtype": "Column Break" + }, + { + "fieldname": "port_code", + "fieldtype": "Data", + "label": "Port Code" + }, + { + "fieldname": "section_break_27", + "fieldtype": "Section Break" + }, + { + "fieldname": "irn_source", + "fieldtype": "Data", + "label": "IRN Source" + }, + { + "fieldname": "irn_number", + "fieldtype": "Data", + "label": "IRN Number" + }, + { + "fieldname": "column_break_30", + "fieldtype": "Column Break" + }, + { + "fieldname": "irn_gen_date", + "fieldtype": "Date", + "label": "IRN Generation Date" + }, + { + "fieldname": "section_break_25", + "fieldtype": "Section Break", + "label": "GSTR 2B Details" + }, + { + "fieldname": "itc_availability", + "fieldtype": "Select", + "label": "ITC Availability", + "options": "\nYes\nNo\nTemporary" + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "fieldname": "reason_itc_unavailability", + "fieldtype": "Data", + "label": "Reason for ITC Unavailability" + }, + { + "fieldname": "section_break_32", + "fieldtype": "Section Break", + "label": "Amendment Details" + }, + { + "fieldname": "other_return_period", + "fieldtype": "Data", + "label": "Amendment/Original Period" + }, + { + "default": "0", + "fieldname": "is_amended", + "fieldtype": "Check", + "label": "Is Amended" + }, + { + "fieldname": "column_break_36", + "fieldtype": "Column Break" + }, + { + "fieldname": "amendment_type", + "fieldtype": "Data", + "label": "Amendment Type" + }, + { + "fieldname": "original_doc_type", + "fieldtype": "Select", + "label": "Original Document Type", + "options": "\nCredit Note\nDebit Note\nISD Invoice\nISD Credit Note" + }, + { + "fieldname": "section_break_22", + "fieldtype": "Section Break", + "label": "Links" + }, + { + "default": "No Action", + "fieldname": "action", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Action", + "options": "No Action\nAccept My Values\nAccept Supplier Values\nIgnore\nPending" + }, + { + "fieldname": "link_doctype", + "fieldtype": "Link", + "label": "Link Document Type", + "options": "DocType" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "fieldname": "match_status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Match Status", + "options": "\nExact Match\nSuggested Match\nMismatch\nManual Match\nUnlinked\nAmended" + }, + { + "fieldname": "link_name", + "fieldtype": "Dynamic Link", + "label": "Link Name", + "options": "link_doctype", + "search_index": 1 + }, + { + "fieldname": "section_break_43", + "fieldtype": "Section Break", + "label": "Supplier Filing Status" + }, + { + "fieldname": "sup_return_period", + "fieldtype": "Data", + "label": "Return Period" + }, + { + "default": "0", + "fieldname": "gstr_1_filled", + "fieldtype": "Check", + "label": "GSTR 1 Filled" + }, + { + "default": "0", + "fieldname": "gstr_3b_filled", + "fieldtype": "Check", + "label": "GSTR 3B Filled" + }, + { + "fieldname": "column_break_47", + "fieldtype": "Column Break" + }, + { + "fieldname": "gstr_1_filing_date", + "fieldtype": "Date", + "label": "GSTR 1 Filing Date" + }, + { + "fieldname": "registration_cancel_date", + "fieldtype": "Date", + "label": "Registration Cancel Date" + }, + { + "fieldname": "return_period_2b", + "fieldtype": "Data", + "label": "2B Return Period" + }, + { + "fieldname": "gen_date_2b", + "fieldtype": "Date", + "label": "2B Generation Date" + }, + { + "default": "0", + "fieldname": "is_reverse_charge", + "fieldtype": "Check", + "label": "Reverse Charge" + }, + { + "fieldname": "bill_no", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Document Number" + }, + { + "fieldname": "bill_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Document Date" + }, + { + "fieldname": "original_bill_no", + "fieldtype": "Data", + "label": "Original Document Number" + }, + { + "fieldname": "original_bill_date", + "fieldtype": "Date", + "label": "Original Document Date" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-09-06 21:35:11.153554", + "modified_by": "Administrator", + "module": "GST India", + "name": "GST Inward Supply", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1 + }, + { + "export": 1, + "read": 1, + "report": 1, + "role": "Accounts User" + } + ], + "search_fields": "bill_date, supplier_name, bill_no, document_value", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "supplier_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.py b/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.py new file mode 100644 index 000000000..4663c1ef6 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_inward_supply/gst_inward_supply.py @@ -0,0 +1,200 @@ +# Copyright (c) 2022, Resilient Tech and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import get_link_to_form + +from india_compliance.gst_india.constants import ORIGINAL_VS_AMENDED + + +class GSTInwardSupply(Document): + def before_save(self): + if self.classification.endswith("A"): + self.is_amended = True + + if self.gstr_1_filing_date: + self.gstr_1_filled = True + + if self.match_status != "Amended" and ( + self.other_return_period or self.is_amended + ): + update_docs_for_amendment(self) + + +def create_inward_supply(transaction): + filters = { + "bill_no": transaction.bill_no, + "bill_date": transaction.bill_date, + "classification": transaction.classification, + "supplier_gstin": transaction.supplier_gstin, + } + + if name := frappe.get_value("GST Inward Supply", filters): + gst_inward_supply = frappe.get_doc("GST Inward Supply", name) + else: + gst_inward_supply = frappe.new_doc("GST Inward Supply") + + gst_inward_supply.update(transaction) + return gst_inward_supply.save(ignore_permissions=True) + + +def update_docs_for_amendment(doc): + fields = [ + "name", + "match_status", + "action", + "link_doctype", + "link_name", + "sup_return_period", + ] + if doc.is_amended: + original = frappe.db.get_value( + "GST Inward Supply", + filters={ + "bill_no": doc.original_bill_no, + "bill_date": doc.original_bill_date, + "supplier_gstin": doc.supplier_gstin, + "classification": get_other_classification(doc), + }, + fieldname=fields, + as_dict=True, + ) + if not original: + # handle amendment from amendments where original is not available + original = frappe.db.get_value( + "GST Inward Supply", + filters={ + "original_bill_no": doc.original_bill_no, + "original_bill_date": doc.original_bill_date, + "supplier_gstin": doc.supplier_gstin, + "classification": doc.classification, + "name": ["!=", doc.name], + }, + fieldname=fields, + as_dict=True, + ) + if not original: + return + + # handle future amendments + if ( + original.match_status == "Amended" + and original.link_name + and original.link_name != doc.name + and doc.is_new() + ): + frappe.db.set_value( + "GST Inward Supply", original.name, "link_name", doc.name + ) + + # new original + original = frappe.db.get_value( + "GST Inward Supply", original.link_name, fields + ) + + if original.match_status == "Amended": + return + + # update_original_from_amended + frappe.db.set_value( + "GST Inward Supply", + original.name, + { + "match_status": "Amended", + "action": "No Action", + "link_doctype": "GST Inward Supply", + "link_name": doc.name, + }, + ) + + # update_amended_from_original + doc.update( + { + "match_status": original.match_status, + "action": original.action, + "link_doctype": original.link_doctype, + "link_name": original.link_name, + } + ) + if not doc.other_return_period: + doc.other_return_period = original.sup_return_period + + ensure_valid_match(doc, original) + + else: + # Handle case where original is imported after amended + ensure_valid_match(doc, doc) + if doc.match_status == "Amended": + return + + doc.update( + { + "match_status": "Amended", + "action": "No Action", + } + ) + amended = frappe.db.get_value( + "GST Inward Supply", + filters={ + "original_bill_no": doc.bill_no, + "original_bill_date": doc.bill_date, + "supplier_gstin": doc.supplier_gstin, + "classification": get_other_classification(doc), + }, + fieldname=fields, + as_dict=True, + ) + + if not amended: + return + + # update_original_from_amended + doc.update( + { + "link_doctype": "GST Inward Supply", + "link_name": amended.name, + } + ) + + +def ensure_valid_match(doc, original): + """ + Where receiver GSTIN is amended, company cannot claim credit for the original document. + """ + + if doc.amendment_type != "Receiver GSTIN Amended": + return + + if original.link_doctype: + frappe.msgprint( + _( + "You have claimend credit for {0} {1} against GST Inward Supply {2} where receiver GSTIN is amended. The same has been reversed." + ).format( + original.link_doctype, + get_link_to_form(original.link_doctype, original.link_name), + get_link_to_form("GST Inward Supply", original.name), + ), + title=_("Invalid Match"), + ) + + doc.update( + { + "match_status": "Amended", + "action": "No Action", + "link_doctype": None, + "link_name": None, + } + ) + + +def get_other_classification(doc): + if doc.is_amended: + for row in ORIGINAL_VS_AMENDED: + if row["original"] in doc.classification: + return row["original"] + + for row in ORIGINAL_VS_AMENDED: + if row["original"] == doc.classification: + return row["amended"] or row["original"] diff --git a/india_compliance/gst_india/doctype/gst_inward_supply/test_gst_inward_supply.py b/india_compliance/gst_india/doctype/gst_inward_supply/test_gst_inward_supply.py new file mode 100644 index 000000000..39b556615 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_inward_supply/test_gst_inward_supply.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Resilient Tech and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestGSTInwardSupply(FrappeTestCase): + pass diff --git a/india_compliance/gst_india/doctype/gst_inward_supply_item/__init__.py b/india_compliance/gst_india/doctype/gst_inward_supply_item/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/india_compliance/gst_india/doctype/gst_inward_supply_item/gst_inward_supply_item.json b/india_compliance/gst_india/doctype/gst_inward_supply_item/gst_inward_supply_item.json new file mode 100644 index 000000000..36f99c2d6 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_inward_supply_item/gst_inward_supply_item.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "creation": "2022-04-12 17:40:41.914298", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_number", + "taxable_value", + "rate", + "igst", + "cgst", + "sgst", + "cess" + ], + "fields": [ + { + "fieldname": "item_number", + "fieldtype": "Int", + "label": "Item Number" + }, + { + "fieldname": "taxable_value", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Taxable Value" + }, + { + "fieldname": "rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Tax Rate" + }, + { + "fieldname": "igst", + "fieldtype": "Float", + "in_list_view": 1, + "label": "IGST Amount" + }, + { + "fieldname": "cgst", + "fieldtype": "Float", + "in_list_view": 1, + "label": "CGST Amount" + }, + { + "fieldname": "sgst", + "fieldtype": "Float", + "in_list_view": 1, + "label": "SGST Amount" + }, + { + "fieldname": "cess", + "fieldtype": "Float", + "label": "CESS Amount" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2022-04-12 17:40:41.914298", + "modified_by": "Administrator", + "module": "GST India", + "name": "GST Inward Supply Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/gst_inward_supply_item/gst_inward_supply_item.py b/india_compliance/gst_india/doctype/gst_inward_supply_item/gst_inward_supply_item.py new file mode 100644 index 000000000..e7d7cc53f --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_inward_supply_item/gst_inward_supply_item.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Resilient Tech and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class GSTInwardSupplyItem(Document): + pass diff --git a/india_compliance/gst_india/doctype/gst_settings/gst_settings.js b/india_compliance/gst_india/doctype/gst_settings/gst_settings.js index 2ac6bef70..668b6a2f6 100644 --- a/india_compliance/gst_india/doctype/gst_settings/gst_settings.js +++ b/india_compliance/gst_india/doctype/gst_settings/gst_settings.js @@ -3,8 +3,6 @@ frappe.ui.form.on("GST Settings", { setup(frm) { - frm.get_field("credentials").grid.get_docfield("password").reqd = 1; - ["cgst_account", "sgst_account", "igst_account", "cess_account"].forEach( field => filter_accounts(frm, field) ); @@ -38,15 +36,6 @@ frappe.ui.form.on("GST Settings", { }, }); -frappe.ui.form.on("GST Credential", { - service(frm, cdt, cdn) { - const doc = frappe.get_doc(cdt, cdn); - const row = frm.get_field("credentials").grid.grid_rows_by_docname[doc.name]; - - row.toggle_reqd("password", doc.service !== "Returns"); - }, -}); - function filter_accounts(frm, account_field) { frm.set_query(account_field, "gst_accounts", (_, cdt, cdn) => { const row = frappe.get_doc(cdt, cdn); diff --git a/india_compliance/gst_india/doctype/gst_settings/gst_settings.json b/india_compliance/gst_india/doctype/gst_settings/gst_settings.json index d76a3ed9a..bd555f921 100644 --- a/india_compliance/gst_india/doctype/gst_settings/gst_settings.json +++ b/india_compliance/gst_india/doctype/gst_settings/gst_settings.json @@ -51,7 +51,8 @@ "gst_accounts", "credentials_tab", "credentials", - "api_secret" + "api_secret", + "gstn_public_certificate" ], "fields": [ { @@ -364,12 +365,18 @@ "fieldname": "validate_gstin_status", "fieldtype": "Check", "label": "Refresh and Validate GSTIN Status in Transactions" + }, + { + "fieldname": "gstn_public_certificate", + "fieldtype": "Code", + "hidden": 1, + "label": "GSTN Public Certificate" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-08-24 19:19:53.537062", + "modified": "2023-10-05 20:27:28.909862", "modified_by": "Administrator", "module": "GST India", "name": "GST Settings", diff --git a/india_compliance/gst_india/doctype/gst_settings/gst_settings.py b/india_compliance/gst_india/doctype/gst_settings/gst_settings.py index 11a575602..7699c48fc 100644 --- a/india_compliance/gst_india/doctype/gst_settings/gst_settings.py +++ b/india_compliance/gst_india/doctype/gst_settings/gst_settings.py @@ -144,7 +144,11 @@ def validate_credentials(self): return for credential in self.credentials: - if credential.service == "Returns" or credential.password: + if credential.service == "Returns": + self.validate_app_key(credential) + continue + + if credential.password: continue frappe.throw( @@ -170,6 +174,10 @@ def validate_credentials(self): alert=True, ) + def validate_app_key(self, credential): + if not credential.app_key or len(credential.app_key) != 32: + credential.app_key = frappe.generate_hash(length=32) + def validate_enable_api(self): if ( self.enable_api diff --git a/india_compliance/gst_india/doctype/gstin/gstin.py b/india_compliance/gst_india/doctype/gstin/gstin.py index b0ae3ca85..e56bb247b 100644 --- a/india_compliance/gst_india/doctype/gstin/gstin.py +++ b/india_compliance/gst_india/doctype/gstin/gstin.py @@ -42,14 +42,18 @@ def update_gstin_status(self): @frappe.whitelist() -def get_gstin_status(gstin, transaction_date=None, is_request_from_ui=0): +def get_gstin_status( + gstin, transaction_date=None, is_request_from_ui=0, force_update=0 +): """ Permission check not required as GSTIN details are public where GSTIN is known. """ if not gstin: return - if not is_status_refresh_required(gstin, transaction_date): + if not int(force_update) and not is_status_refresh_required( + gstin, transaction_date + ): if not frappe.db.exists("GSTIN", gstin): return @@ -176,8 +180,9 @@ def _throw(message): if gstin_doc.status not in ("Active", "Cancelled"): return _throw( - _("Status of Party GSTIN {1} is {0}").format(gstin_doc.status), - gstin_doc.gstin, + _("Status of Party GSTIN {1} is {0}").format( + gstin_doc.status, gstin_doc.gstin + ) ) diff --git a/india_compliance/gst_india/doctype/gstr_import_log/__init__.py b/india_compliance/gst_india/doctype/gstr_import_log/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.js b/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.js new file mode 100644 index 000000000..68cd1a577 --- /dev/null +++ b/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2022, Resilient Tech and contributors +// For license information, please see license.txt + +frappe.ui.form.on('GSTR Import Log', { + // refresh: function(frm) { + + // } +}); diff --git a/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.json b/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.json new file mode 100644 index 000000000..92ec72e0b --- /dev/null +++ b/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.json @@ -0,0 +1,106 @@ +{ + "actions": [], + "creation": "2022-04-12 18:57:59.122090", + "description": "This document will maintain time log of latest download of GST Returns", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gstin", + "return_type", + "classification", + "return_period", + "last_updated_on", + "data_not_found", + "request_id", + "request_time" + ], + "fields": [ + { + "fieldname": "gstin", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "GSTIN" + }, + { + "fieldname": "classification", + "fieldtype": "Select", + "in_standard_filter": 1, + "label": "Classification", + "options": "\nB2B\nB2BA\nCDNR\nCDNRA\nISD\nISDA\nIMPG\nIMPGSEZ" + }, + { + "fieldname": "return_period", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Return Period" + }, + { + "fieldname": "last_updated_on", + "fieldtype": "Datetime", + "label": "Last updated on" + }, + { + "fieldname": "return_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Return Type" + }, + { + "default": "0", + "fieldname": "data_not_found", + "fieldtype": "Check", + "label": "Data not Found" + }, + { + "fieldname": "request_id", + "fieldtype": "Data", + "hidden": 1, + "label": "Request ID" + }, + { + "fieldname": "request_time", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Request Time" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-09-06 09:18:47.885426", + "modified_by": "Administrator", + "module": "GST India", + "name": "GSTR Import Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "export": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager" + }, + { + "export": 1, + "read": 1, + "report": 1, + "role": "Accounts User" + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "gstin" +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.py b/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.py new file mode 100644 index 000000000..98a33cb65 --- /dev/null +++ b/india_compliance/gst_india/doctype/gstr_import_log/gstr_import_log.py @@ -0,0 +1,81 @@ +# Copyright (c) 2022, Resilient Tech and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.utils import add_to_date + + +class GSTRImportLog(Document): + pass + + +def create_import_log( + gstin, + return_type, + return_period, + classification=None, + data_not_found=False, + request_id=None, + retry_after_mins=None, +): + frappe.enqueue( + _create_import_log, + queue="short", + now=frappe.flags.in_test, + gstin=gstin, + return_type=return_type, + return_period=return_period, + data_not_found=data_not_found, + classification=classification, + request_id=request_id, + retry_after_mins=retry_after_mins, + ) + + +def _create_import_log( + gstin, + return_type, + return_period, + classification=None, + data_not_found=False, + request_id=None, + retry_after_mins=None, +): + doctype = "GSTR Import Log" + fields = { + "gstin": gstin, + "return_type": return_type, + "return_period": return_period, + } + + # TODO: change classification to gstr_category + if classification: + fields["classification"] = classification + + if log := frappe.db.get_value(doctype, fields): + log = frappe.get_doc(doctype, log) + else: + log = frappe.get_doc({"doctype": doctype, **fields}) + + if retry_after_mins: + log.request_time = add_to_date(None, minutes=retry_after_mins) + + log.request_id = request_id + log.data_not_found = data_not_found + log.last_updated_on = frappe.utils.now() + log.save(ignore_permissions=True) + if request_id: + toggle_scheduled_jobs(False) + + +def toggle_scheduled_jobs(stopped): + scheduled_job = frappe.db.get_value( + "Scheduled Job Type", + { + "method": "india_compliance.gst_india.utils.gstr.download_queued_request", + }, + ) + + if scheduled_job: + frappe.db.set_value("Scheduled Job Type", scheduled_job, "stopped", stopped) diff --git a/india_compliance/gst_india/doctype/gstr_import_log/test_gstr_import_log.py b/india_compliance/gst_india/doctype/gstr_import_log/test_gstr_import_log.py new file mode 100644 index 000000000..de70f84ec --- /dev/null +++ b/india_compliance/gst_india/doctype/gstr_import_log/test_gstr_import_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Resilient Tech and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestGSTRImportLog(FrappeTestCase): + pass diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/__init__.py b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/__init__.py new file mode 100644 index 000000000..f1f06752a --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/__init__.py @@ -0,0 +1,1336 @@ +# Copyright (c) 2022, Resilient Tech and contributors +# For license information, please see license.txt + +from enum import Enum + +from dateutil.rrule import MONTHLY, rrule +from rapidfuzz import fuzz, process + +import frappe +from frappe.query_builder import Case +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import Abs, IfNull, Sum +from frappe.utils import add_months, format_date, getdate, rounded + +from india_compliance.gst_india.constants import GST_TAX_TYPES +from india_compliance.gst_india.utils import ( + get_gst_accounts_by_type, + get_party_for_gstin, +) +from india_compliance.gst_india.utils.gstr import IMPORT_CATEGORY, ReturnType + + +class Fields(Enum): + FISCAL_YEAR = "fy" + SUPPLIER_GSTIN = "supplier_gstin" + BILL_NO = "bill_no" + PLACE_OF_SUPPLY = "place_of_supply" + REVERSE_CHARGE = "is_reverse_charge" + TAXABLE_VALUE = "taxable_value" + CGST = "cgst" + SGST = "sgst" + IGST = "igst" + CESS = "cess" + + +class Rule(Enum): + EXACT_MATCH = "Exact Match" + FUZZY_MATCH = "Fuzzy Match" + MISMATCH = "Mismatch" + ROUNDING_DIFFERENCE = "Rounding Difference" # <= 1 hardcoded + + +class MatchStatus(Enum): + EXACT_MATCH = "Exact Match" + SUGGESTED_MATCH = "Suggested Match" + MISMATCH = "Mismatch" + RESIDUAL_MATCH = "Residual Match" + MANUAL_MATCH = "Manual Match" + MISSING_IN_PI = "Missing in PI" + MISSING_IN_2A_2B = "Missing in 2A/2B" + + +# Summary of rules: +# GSTIN_RULES = [ +# {"Exact Match": ["E", "E", "E", "E", "E", 0, 0, 0, 0, 0]}, +# {"Suggested Match": ["E", "E", "F", "E", "E", 0, 0, 0, 0, 0]}, +# {"Suggested Match": ["E", "E", "E", "E", "E", 1, 1, 1, 1, 2]}, +# {"Suggested Match": ["E", "E", "F", "E", "E", 1, 1, 1, 1, 2]}, +# {"Mismatch": ["E", "E", "E", "N", "N", "N", "N", "N", "N", "N"]}, +# {"Mismatch": ["E", "E", "F", "N", "N", "N", "N", "N", "N", "N"]}, +# {"Residual Match": ["E", "E", "N", "E", "E", 1, 1, 1, 1, 2]}, +# ] + +# PAN_RULES = [ +# {"Mismatch": ["E", "N", "E", "E", "E", 1, 1, 1, 1, 2]}, +# {"Mismatch": ["E", "N", "F", "E", "E", 1, 1, 1, 1, 2]}, +# {"Mismatch": ["E", "N", "F", "N", "N", "N", "N", "N", "N", "N"]}, +# {"Residual Match": ["E", "N", "N", "E", "E", 1, 1, 1, 1, 2]}, +# ] + +GSTIN_RULES = ( + { + "match_status": MatchStatus.EXACT_MATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + Fields.BILL_NO: Rule.EXACT_MATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.EXACT_MATCH, + Fields.CGST: Rule.EXACT_MATCH, + Fields.SGST: Rule.EXACT_MATCH, + Fields.IGST: Rule.EXACT_MATCH, + Fields.CESS: Rule.EXACT_MATCH, + }, + }, + { + "match_status": MatchStatus.SUGGESTED_MATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + Fields.BILL_NO: Rule.FUZZY_MATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.EXACT_MATCH, + Fields.CGST: Rule.EXACT_MATCH, + Fields.SGST: Rule.EXACT_MATCH, + Fields.IGST: Rule.EXACT_MATCH, + Fields.CESS: Rule.EXACT_MATCH, + }, + }, + { + "match_status": MatchStatus.SUGGESTED_MATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + Fields.BILL_NO: Rule.EXACT_MATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.ROUNDING_DIFFERENCE, + Fields.CGST: Rule.ROUNDING_DIFFERENCE, + Fields.SGST: Rule.ROUNDING_DIFFERENCE, + Fields.IGST: Rule.ROUNDING_DIFFERENCE, + Fields.CESS: Rule.ROUNDING_DIFFERENCE, + }, + }, + { + "match_status": MatchStatus.SUGGESTED_MATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + Fields.BILL_NO: Rule.FUZZY_MATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.ROUNDING_DIFFERENCE, + Fields.CGST: Rule.ROUNDING_DIFFERENCE, + Fields.SGST: Rule.ROUNDING_DIFFERENCE, + Fields.IGST: Rule.ROUNDING_DIFFERENCE, + Fields.CESS: Rule.ROUNDING_DIFFERENCE, + }, + }, + { + "match_status": MatchStatus.MISMATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + Fields.BILL_NO: Rule.EXACT_MATCH, + # Fields.PLACE_OF_SUPPLY: Rule.MISMATCH, + # Fields.IS_REVERSE_CHARGE: Rule.MISMATCH, + # Fields.TAXABLE_VALUE: Rule.MISMATCH, + # Fields.CGST: Rule.MISMATCH, + # Fields.SGST: Rule.MISMATCH, + # Fields.IGST: Rule.MISMATCH, + # Fields.CESS: Rule.MISMATCH, + }, + }, + { + "match_status": MatchStatus.MISMATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + Fields.BILL_NO: Rule.FUZZY_MATCH, + # Fields.PLACE_OF_SUPPLY: Rule.MISMATCH, + # Fields.IS_REVERSE_CHARGE: Rule.MISMATCH, + # Fields.TAXABLE_VALUE: Rule.MISMATCH, + # Fields.CGST: Rule.MISMATCH, + # Fields.SGST: Rule.MISMATCH, + # Fields.IGST: Rule.MISMATCH, + # Fields.CESS: Rule.MISMATCH, + }, + }, + { + "match_status": MatchStatus.RESIDUAL_MATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + Fields.SUPPLIER_GSTIN: Rule.EXACT_MATCH, + # Fields.BILL_NO: Rule.MISMATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.ROUNDING_DIFFERENCE, + Fields.CGST: Rule.ROUNDING_DIFFERENCE, + Fields.SGST: Rule.ROUNDING_DIFFERENCE, + Fields.IGST: Rule.ROUNDING_DIFFERENCE, + Fields.CESS: Rule.ROUNDING_DIFFERENCE, + }, + }, +) + + +PAN_RULES = ( + { + "match_status": MatchStatus.MISMATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + # Fields.SUPPLIER_GSTIN: Rule.MISMATCH, + Fields.BILL_NO: Rule.EXACT_MATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.ROUNDING_DIFFERENCE, + Fields.CGST: Rule.ROUNDING_DIFFERENCE, + Fields.SGST: Rule.ROUNDING_DIFFERENCE, + Fields.IGST: Rule.ROUNDING_DIFFERENCE, + Fields.CESS: Rule.ROUNDING_DIFFERENCE, + }, + }, + { + "match_status": MatchStatus.MISMATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + # Fields.SUPPLIER_GSTIN: Rule.MISMATCH, + Fields.BILL_NO: Rule.FUZZY_MATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.ROUNDING_DIFFERENCE, + Fields.CGST: Rule.ROUNDING_DIFFERENCE, + Fields.SGST: Rule.ROUNDING_DIFFERENCE, + Fields.IGST: Rule.ROUNDING_DIFFERENCE, + Fields.CESS: Rule.ROUNDING_DIFFERENCE, + }, + }, + { + "match_status": MatchStatus.MISMATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + # Fields.SUPPLIER_GSTIN: Rule.MISMATCH, + Fields.BILL_NO: Rule.FUZZY_MATCH, + # Fields.PLACE_OF_SUPPLY: Rule.MISMATCH, + # Fields.IS_REVERSE_CHARGE: Rule.MISMATCH, + # Fields.TAXABLE_VALUE: Rule.MISMATCH, + # Fields.CGST: Rule.MISMATCH, + # Fields.SGST: Rule.MISMATCH, + # Fields.IGST: Rule.MISMATCH, + # Fields.CESS: Rule.MISMATCH, + }, + }, + { + "match_status": MatchStatus.RESIDUAL_MATCH, + "rule": { + Fields.FISCAL_YEAR: Rule.EXACT_MATCH, + # Fields.SUPPLIER_GSTIN: Rule.MISMATCH, + # Fields.BILL_NO: Rule.MISMATCH, + Fields.PLACE_OF_SUPPLY: Rule.EXACT_MATCH, + Fields.REVERSE_CHARGE: Rule.EXACT_MATCH, + Fields.TAXABLE_VALUE: Rule.ROUNDING_DIFFERENCE, + Fields.CGST: Rule.ROUNDING_DIFFERENCE, + Fields.SGST: Rule.ROUNDING_DIFFERENCE, + Fields.IGST: Rule.ROUNDING_DIFFERENCE, + Fields.CESS: Rule.ROUNDING_DIFFERENCE, + }, + }, +) + + +class InwardSupply: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + self.GSTR2 = frappe.qb.DocType("GST Inward Supply") + self.GSTR2_ITEM = frappe.qb.DocType("GST Inward Supply Item") + + def get_all(self, additional_fields=None, names=None, only_names=False): + query = self.with_period_filter(additional_fields) + if only_names and not names: + return + + elif names: + query = query.where(self.GSTR2.name.isin(names)) + + return query.run(as_dict=True) + + def get_unmatched(self, category, amended_category): + categories = [category, amended_category or None] + query = self.with_period_filter() + data = ( + query.where(IfNull(self.GSTR2.match_status, "") == "") + .where(self.GSTR2.classification.isin(categories)) + .run(as_dict=True) + ) + + for doc in data: + doc.fy = BaseUtil.get_fy(doc.bill_date) + + return BaseUtil.get_dict_for_key("supplier_gstin", data) + + def with_period_filter(self, additional_fields=None): + query = self.get_query(additional_fields) + periods = BaseUtil._get_periods(self.from_date, self.to_date) + + if self.gst_return == "GSTR 2B": + query = query.where((self.GSTR2.return_period_2b.isin(periods))) + else: + query = query.where( + (self.GSTR2.return_period_2b.isin(periods)) + | (self.GSTR2.sup_return_period.isin(periods)) + | (self.GSTR2.other_return_period.isin(periods)) + ) + + return query + + def get_query(self, additional_fields=None): + """ + Query without filtering for return period + """ + fields = self.get_fields(additional_fields) + query = ( + frappe.qb.from_(self.GSTR2) + .left_join(self.GSTR2_ITEM) + .on(self.GSTR2_ITEM.parent == self.GSTR2.name) + .where(self.company_gstin == self.GSTR2.company_gstin) + .where(IfNull(self.GSTR2.match_status, "") != "Amended") + .groupby(self.GSTR2_ITEM.parent) + .select(*fields, ConstantColumn("GST Inward Supply").as_("doctype")) + ) + if self.include_ignored == 0: + query = query.where(IfNull(self.GSTR2.action, "") != "Ignore") + + return query + + def get_fields(self, additional_fields=None, table=None): + if not table: + table = self.GSTR2 + + fields = [ + "bill_no", + "bill_date", + "name", + "supplier_gstin", + "is_reverse_charge", + "place_of_supply", + ] + + if additional_fields: + fields += additional_fields + + fields = [table[field] for field in fields] + fields += self.get_tax_fields(table) + + return fields + + def get_tax_fields(self, table): + fields = GST_TAX_TYPES[:-1] + ("taxable_value",) + + if table == frappe.qb.DocType("GST Inward Supply"): + return [Sum(self.GSTR2_ITEM[field]).as_(field) for field in fields] + + return [table[field] for field in fields] + + +class PurchaseInvoice: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + self.PI = frappe.qb.DocType("Purchase Invoice") + self.PI_TAX = frappe.qb.DocType("Purchase Taxes and Charges") + + def get_all(self, additional_fields=None, names=None, only_names=False): + query = self.get_query(additional_fields) + + if only_names and not names: + return + + elif only_names: + query = query.where(self.PI.name.isin(names)) + + elif names: + query = query.where( + (self.PI.posting_date[self.from_date : self.to_date]) + | (self.PI.name.isin(names)) + ) + + else: + query = query.where(self.PI.posting_date[self.from_date : self.to_date]) + + return query.run(as_dict=True) + + def get_unmatched(self, category): + gst_category = ( + ("Registered Regular", "Tax Deductor") + if category in ("B2B", "CDNR", "ISD") + else ("SEZ", "Overseas", "UIN Holders") + ) + is_return = 1 if category == "CDNR" else 0 + + query = ( + self.get_query(is_return=is_return) + .where(self.PI.posting_date[self.from_date : self.to_date]) + .where( + self.PI.name.notin( + PurchaseInvoice.query_matched_purchase_invoice( + self.from_date, self.to_date + ) + ) + ) + .where(self.PI.gst_category.isin(gst_category)) + .where(self.PI.is_return == is_return) + ) + + data = query.run(as_dict=True) + + for doc in data: + doc.fy = BaseUtil.get_fy(doc.bill_date or doc.posting_date) + + return BaseUtil.get_dict_for_key("supplier_gstin", data) + + def get_query(self, additional_fields=None, is_return=False): + PI_ITEM = frappe.qb.DocType("Purchase Invoice Item") + + fields = self.get_fields(additional_fields, is_return) + pi_item = ( + frappe.qb.from_(PI_ITEM) + .select( + Abs(Sum(PI_ITEM.taxable_value)).as_("taxable_value"), + PI_ITEM.parent, + ) + .groupby(PI_ITEM.parent) + ) + + query = ( + frappe.qb.from_(self.PI) + .left_join(self.PI_TAX) + .on(self.PI_TAX.parent == self.PI.name) + .left_join(pi_item) + .on(pi_item.parent == self.PI.name) + .where(self.company_gstin == self.PI.company_gstin) + .where(self.PI.docstatus == 1) + .where(IfNull(self.PI.reconciliation_status, "") != "Not Applicable") + .groupby(self.PI.name) + .select( + *fields, + pi_item.taxable_value, + ConstantColumn("Purchase Invoice").as_("doctype"), + ) + ) + + if self.include_ignored == 0: + query = query.where(IfNull(self.PI.reconciliation_status, "") != "Ignored") + + return query + + def get_fields(self, additional_fields=None, is_return=False): + gst_accounts = get_gst_accounts_by_type(self.company, "Input") + tax_fields = [ + self.query_tax_amount(account).as_(tax[:-8]) + for tax, account in gst_accounts.items() + ] + + fields = [ + "name", + "supplier_gstin", + "bill_no", + "place_of_supply", + "is_reverse_charge", + *tax_fields, + ] + + if is_return: + # return is initiated by the customer. So bill date may not be available or known. + fields += [self.PI.posting_date.as_("bill_date")] + else: + fields += ["bill_date"] + + if additional_fields: + fields += additional_fields + + return fields + + def query_tax_amount(self, account): + return Abs( + Sum( + Case() + .when( + self.PI_TAX.account_head == account, + self.PI_TAX.base_tax_amount_after_discount_amount, + ) + .else_(0) + ) + ) + + @staticmethod + def query_matched_purchase_invoice(from_date=None, to_date=None): + GSTR2 = frappe.qb.DocType("GST Inward Supply") + PI = frappe.qb.DocType("Purchase Invoice") + + query = ( + frappe.qb.from_(GSTR2) + .select("link_name") + .where(GSTR2.link_doctype == "Purchase Invoice") + .join(PI) + .on(PI.name == GSTR2.link_name) + ) + + if from_date and to_date: + query = query.where(PI.posting_date[from_date:to_date]) + + return query + + +class BillOfEntry: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + self.BOE = frappe.qb.DocType("Bill of Entry") + self.BOE_TAX = frappe.qb.DocType("Bill of Entry Taxes") + self.PI = frappe.qb.DocType("Purchase Invoice") + + def get_all(self, additional_fields=None, names=None, only_names=False): + query = self.get_query(additional_fields) + + if only_names and not names: + return + + elif only_names: + query = query.where(self.BOE.name.isin(names)) + + elif names: + query = query.where( + (self.BOE.posting_date[self.from_date : self.to_date]) + | (self.BOE.name.isin(names)) + ) + + else: + query = query.where(self.BOE.posting_date[self.from_date : self.to_date]) + + return query.run(as_dict=True) + + def get_unmatched(self, category): + gst_category = "SEZ" if category == "IMPGSEZ" else "Overseas" + + query = ( + self.get_query() + .where(self.PI.gst_category == gst_category) + .where(self.BOE.posting_date[self.from_date : self.to_date]) + .where( + self.BOE.name.notin( + BillOfEntry.query_matched_bill_of_entry( + self.from_date, self.to_date + ) + ) + ) + ) + + data = query.run(as_dict=True) + + for doc in data: + doc.fy = BaseUtil.get_fy(doc.bill_date or doc.posting_date) + + return BaseUtil.get_dict_for_key("supplier_gstin", data) + + def get_query(self, additional_fields=None): + fields = self.get_fields(additional_fields) + + query = ( + frappe.qb.from_(self.BOE) + .left_join(self.BOE_TAX) + .on(self.BOE_TAX.parent == self.BOE.name) + .join(self.PI) + .on(self.BOE.purchase_invoice == self.PI.name) + .where(self.BOE.docstatus == 1) + .groupby(self.BOE.name) + .select(*fields, ConstantColumn("Bill of Entry").as_("doctype")) + ) + + if self.include_ignored == 0: + query = query.where(IfNull(self.BOE.reconciliation_status, "") != "Ignored") + + return query + + def get_fields(self, additional_fields=None): + gst_accounts = get_gst_accounts_by_type(self.company, "Input") + tax_fields = [ + self.query_tax_amount(account).as_(tax[:-8]) + for tax, account in gst_accounts.items() + if account + ] + + fields = [ + self.BOE.name, + self.BOE.bill_of_entry_no.as_("bill_no"), + self.BOE.total_taxable_value.as_("taxable_value"), + self.BOE.bill_of_entry_date.as_("bill_date"), + self.BOE.posting_date, + self.PI.supplier_name, + *tax_fields, + ] + + # In IMPGSEZ supplier details are avaialble in 2A + purchase_fields = [ + "supplier_gstin", + "place_of_supply", + "is_reverse_charge", + "gst_category", + ] + + for field in purchase_fields: + fields.append( + Case() + .when(self.PI.gst_category == "SEZ", getattr(self.PI, field)) + .else_("") + .as_(field) + ) + + # Add only boe fields + if additional_fields: + boe_fields = frappe.db.get_table_columns("Bill of Entry") + for field in additional_fields: + if field in boe_fields: + fields.append(getattr(self.BOE, field)) + + return fields + + def query_tax_amount(self, account): + return Abs( + Sum( + Case() + .when( + self.BOE_TAX.account_head == account, + self.BOE_TAX.tax_amount, + ) + .else_(0) + ) + ) + + @staticmethod + def query_matched_bill_of_entry(from_date=None, to_date=None): + GSTR2 = frappe.qb.DocType("GST Inward Supply") + BOE = frappe.qb.DocType("Bill of Entry") + + query = ( + frappe.qb.from_(GSTR2) + .select("link_name") + .where(GSTR2.link_doctype == "Bill of Entry") + .join(BOE) + .on(BOE.name == GSTR2.link_name) + ) + + if from_date and to_date: + query = query.where(BOE.posting_date[from_date:to_date]) + + return query + + +class BaseReconciliation: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def get_all_inward_supply( + self, additional_fields=None, names=None, only_names=False + ): + return InwardSupply( + company_gstin=self.company_gstin, + from_date=self.inward_supply_from_date, + to_date=self.inward_supply_to_date, + gst_return=self.gst_return, + include_ignored=self.include_ignored, + ).get_all(additional_fields, names, only_names) + + def get_unmatched_inward_supply(self, category, amended_category): + return InwardSupply( + company_gstin=self.company_gstin, + from_date=self.inward_supply_from_date, + to_date=self.inward_supply_to_date, + gst_return=self.gst_return, + include_ignored=self.include_ignored, + ).get_unmatched(category, amended_category) + + def query_inward_supply(self, additional_fields=None): + query = InwardSupply( + company_gstin=self.company_gstin, + from_date=self.inward_supply_from_date, + to_date=self.inward_supply_to_date, + gst_return=self.gst_return, + include_ignored=self.include_ignored, + ) + + return query.with_period_filter(additional_fields) + + def get_all_purchase_invoice( + self, additional_fields=None, names=None, only_names=False + ): + return PurchaseInvoice( + company=self.company, + company_gstin=self.company_gstin, + from_date=self.purchase_from_date, + to_date=self.purchase_to_date, + include_ignored=self.include_ignored, + ).get_all(additional_fields, names, only_names) + + def get_unmatched_purchase(self, category): + return PurchaseInvoice( + company=self.company, + company_gstin=self.company_gstin, + from_date=self.purchase_from_date, + to_date=self.purchase_to_date, + include_ignored=self.include_ignored, + ).get_unmatched(category) + + def query_purchase_invoice(self, additional_fields=None): + return PurchaseInvoice( + company=self.company, + company_gstin=self.company_gstin, + include_ignored=self.include_ignored, + ).get_query(additional_fields) + + def get_all_bill_of_entry( + self, additional_fields=None, names=None, only_names=False + ): + return BillOfEntry( + company=self.company, + company_gstin=self.company_gstin, + from_date=self.purchase_from_date, + to_date=self.purchase_to_date, + include_ignored=self.include_ignored, + ).get_all(additional_fields, names, only_names) + + def get_unmatched_bill_of_entry(self, category): + return BillOfEntry( + company=self.company, + company_gstin=self.company_gstin, + from_date=self.purchase_from_date, + to_date=self.purchase_to_date, + include_ignored=self.include_ignored, + ).get_unmatched(category) + + def query_bill_of_entry(self, additional_fields=None): + return BillOfEntry( + company=self.company, + company_gstin=self.company_gstin, + include_ignored=self.include_ignored, + ).get_query(additional_fields) + + def get_unmatched_purchase_or_bill_of_entry(self, category): + """ + Returns dict of unmatched purchase and bill of entry data. + """ + if category in IMPORT_CATEGORY: + return self.get_unmatched_bill_of_entry(category) + + return self.get_unmatched_purchase(category) + + +class Reconciler(BaseReconciliation): + def reconcile(self, category, amended_category): + """ + Reconcile purchases and inward supplies for given category. + """ + # GSTIN Level matching + purchases = self.get_unmatched_purchase_or_bill_of_entry(category) + inward_supplies = self.get_unmatched_inward_supply(category, amended_category) + self.reconcile_for_rules(GSTIN_RULES, purchases, inward_supplies, category) + + # In case of IMPG GST in not available in 2A. So skip PAN level matching. + if category == "IMPG": + return + + # PAN Level matching + purchases = self.get_pan_level_data(purchases) + inward_supplies = self.get_pan_level_data(inward_supplies) + self.reconcile_for_rules(PAN_RULES, purchases, inward_supplies, category) + + def reconcile_for_rules(self, rules, purchases, inward_supplies, category): + if not (purchases and inward_supplies): + return + + for rule in rules: + self.reconcile_for_rule( + purchases, + inward_supplies, + rule.get("match_status").value, + rule.get("rule"), + category, + ) + + def reconcile_for_rule( + self, purchases, inward_supplies, match_status, rules, category + ): + """ + Sequentially reconcile invoices as per rules list. + - Reconciliation only done between invoices of same GSTIN. + - Where a match is found, update Inward Supply and Purchase Invoice. + """ + + for supplier_gstin in purchases: + if not inward_supplies.get(supplier_gstin) and category != "IMPG": + continue + + summary_diff = {} + if match_status == "Residual Match" and category != "CDNR": + summary_diff = self.get_summary_difference( + purchases[supplier_gstin], inward_supplies[supplier_gstin] + ) + + for purchase_invoice_name, purchase in ( + purchases[supplier_gstin].copy().items() + ): + if summary_diff and not ( + abs(summary_diff[purchase.bill_date.month]) < 2 + ): + continue + + for inward_supply_name, inward_supply in ( + inward_supplies[supplier_gstin].copy().items() + ): + if ( + summary_diff + and purchase.bill_date.month != inward_supply.bill_date.month + ): + continue + + if not self.is_doc_matching(purchase, inward_supply, rules): + continue + + self.update_matching_doc( + match_status, + purchase.name, + inward_supply.name, + purchase.doctype, + ) + + # Remove from current data to ensure matching is done only once. + purchases[supplier_gstin].pop(purchase_invoice_name) + inward_supplies[supplier_gstin].pop(inward_supply_name) + break + + def get_summary_difference(self, data1, data2): + """ + Returns dict with difference of monthly purchase for given supplier data. + Calculated only for Residual Match. + + Objective: Residual match is to match Invoices where bill no is completely different. + It should be matched for invoices of a given month only if difference in total invoice + value is negligible for purchase and inward supply. + """ + summary = {} + for doc in data1.values(): + summary.setdefault(doc.bill_date.month, 0) + summary[doc.bill_date.month] += BaseUtil.get_total_tax(doc) + + for doc in data2.values(): + summary.setdefault(doc.bill_date.month, 0) + summary[doc.bill_date.month] -= BaseUtil.get_total_tax(doc) + + return summary + + def is_doc_matching(self, purchase, inward_supply, rules): + """ + Returns true if all fields match from purchase and inward supply as per rules. + + param purchase: purchase doc + param inward_supply: inward supply doc + param rules: dict of rule against field to match + """ + + for field, rule in rules.items(): + if not self.is_field_matching(purchase, inward_supply, field.value, rule): + return False + + return True + + def is_field_matching(self, purchase, inward_supply, field, rule): + """ + Returns true if the field matches from purchase and inward supply as per the rule. + + param purchase: purchase doc + param inward_supply: inward supply doc + param field: field to match + param rule: rule applied to match + """ + + if rule == Rule.EXACT_MATCH: + return purchase[field] == inward_supply[field] + elif rule == Rule.FUZZY_MATCH: + return self.fuzzy_match(purchase, inward_supply) + elif rule == Rule.ROUNDING_DIFFERENCE: + return self.get_amount_difference(purchase, inward_supply, field) <= 1 + + def fuzzy_match(self, purchase, inward_supply): + """ + Returns true if the (cleaned) bill_no approximately match. + - For a fuzzy match, month of invoice and inward supply should be same. + - First check for partial ratio, with 100% confidence + - Next check for approximate match, with 90% confidence + """ + if abs((purchase.bill_date - inward_supply.bill_date).days) > 10: + return False + + if not purchase._bill_no: + purchase._bill_no = BaseUtil.get_cleaner_bill_no( + purchase.bill_no, purchase.fy + ) + + if not inward_supply._bill_no: + inward_supply._bill_no = BaseUtil.get_cleaner_bill_no( + inward_supply.bill_no, inward_supply.fy + ) + + partial_ratio = fuzz.partial_ratio(purchase._bill_no, inward_supply._bill_no) + if float(partial_ratio) == 100: + return True + + return ( + float(process.extractOne(purchase._bill_no, [inward_supply._bill_no])[1]) + >= 90.0 + ) + + def get_amount_difference(self, purchase, inward_supply, field): + if field == "cess": + BaseUtil.update_cess_amount(purchase) + + return abs(purchase.get(field, 0) - inward_supply.get(field, 0)) + + def update_matching_doc( + self, match_status, purchase_invoice_name, inward_supply_name, link_doctype + ): + """Update matching doc for records.""" + + if match_status == "Residual Match": + match_status = "Mismatch" + + inward_supply_fields = { + "match_status": match_status, + "link_doctype": link_doctype, + "link_name": purchase_invoice_name, + } + + frappe.db.set_value( + "GST Inward Supply", inward_supply_name, inward_supply_fields + ) + + def get_pan_level_data(self, data): + out = {} + for gstin, invoices in data.items(): + pan = gstin[2:-3] + out.setdefault(pan, {}) + out[pan].update(invoices) + + return out + + +class ReconciledData(BaseReconciliation): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.gstin_party_map = frappe._dict() + + def get_consolidated_data( + self, + purchase_names: list = None, + inward_supply_names: list = None, + prefix: str = None, + ): + data = self.get(purchase_names, inward_supply_names) + for doc in data: + purchase = doc.pop("_purchase_invoice", frappe._dict()) + inward_supply = doc.pop("_inward_supply", frappe._dict()) + + purchase.bill_date = format_date(purchase.bill_date) + inward_supply.bill_date = format_date(inward_supply.bill_date) + + doc.update(purchase) + doc.update( + {f"{prefix}_{key}": value for key, value in inward_supply.items()} + ) + doc.pan = doc.supplier_gstin[2:-3] + + return data + + def get_manually_matched_data(self, purchase_name: str, inward_supply_name: str): + """ + Get manually matched data for given purchase invoice and inward supply. + This can be used to show comparision of matched values. + """ + inward_supplies = self.get_all_inward_supply( + names=[inward_supply_name], only_names=True + ) + purchases = self.get_all_purchase_invoice_and_bill_of_entry( + "", [purchase_name], only_names=True + ) + + reconciliation_data = [ + frappe._dict( + { + "_inward_supply": inward_supplies[0] + if inward_supplies + else frappe._dict(), + "_purchase_invoice": purchases.get(purchase_name, frappe._dict()), + } + ) + ] + self.process_data(reconciliation_data, retain_doc=True) + return reconciliation_data[0] + + def get(self, purchase_names: list = None, inward_supply_names: list = None): + # TODO: update cess amount in purchase invoice + """ + Get Reconciliation data based on standard filters + Returns + - Inward Supply: for the return month as per 2A and 2B + - Purchase Invoice: All invoices matching with inward supply (irrespective of purchase period choosen) + Unmatched Purchase Invoice for the period choosen + + """ + + retain_doc = only_names = False + if inward_supply_names or purchase_names: + retain_doc = only_names = True + + inward_supplies = self.get_all_inward_supply(names=inward_supply_names) + purchases_and_bill_of_entry = self.get_all_purchase_invoice_and_bill_of_entry( + inward_supplies, purchase_names, only_names + ) + + reconciliation_data = [] + for doc in inward_supplies: + reconciliation_data.append( + frappe._dict( + { + "_inward_supply": doc, + "_purchase_invoice": purchases_and_bill_of_entry.pop( + doc.link_name, frappe._dict() + ), + } + ) + ) + + for doc in purchases_and_bill_of_entry.values(): + reconciliation_data.append(frappe._dict({"_purchase_invoice": doc})) + + self.process_data(reconciliation_data, retain_doc=retain_doc) + return reconciliation_data + + def get_all_inward_supply( + self, additional_fields=None, names=None, only_names=False + ): + inward_supply_fields = [ + "supplier_name", + "classification", + "match_status", + "action", + "link_doctype", + "link_name", + ] + + return super().get_all_inward_supply(inward_supply_fields, names, only_names) + + def get_all_purchase_invoice_and_bill_of_entry( + self, inward_supplies, purchase_names, only_names=False + ): + purchase_fields = [ + "supplier", + "supplier_name", + "is_return", + "gst_category", + "reconciliation_status", + ] + + boe_names = purchase_names + + if not only_names: + purchase_names = set() + boe_names = set() + for doc in inward_supplies: + if doc.link_doctype == "Purchase Invoice": + purchase_names.add(doc.link_name) + + elif doc.link_doctype == "Bill of Entry": + boe_names.add(doc.link_name) + + purchases = ( + super().get_all_purchase_invoice( + purchase_fields, purchase_names, only_names + ) + or [] + ) + + bill_of_entries = ( + super().get_all_bill_of_entry(purchase_fields, boe_names, only_names) or [] + ) + + if not purchases and not bill_of_entries: + return {} + + purchases.extend(bill_of_entries) + + return {doc.name: doc for doc in purchases} + + def process_data(self, reconciliation_data: list, retain_doc: bool = False): + """ + Process reconciliation data to add additional fields or update differences. + Cases: + - Missing in Purchase Invoice + - Missing in Inward Supply + - Update differences + + params: + - reconciliation_data: list of reconciliation data + Format: + [ + { + "_purchase_invoice": purchase invoice doc, + "_inward_supply": inward supply doc, + }, + ... + ] + + - retain_doc: retain vs pop doc from reconciliation data + + """ + default_dict = { + "supplier_name": "", + "supplier_gstin": "", + "bill_no": "", + "bill_date": "", + "match_status": "", + "purchase_invoice_name": "", + "inward_supply_name": "", + "taxable_value_difference": "", + "tax_difference": "", + "differences": "", + "action": "", + "classification": "", + } + + for data in reconciliation_data: + data.update(default_dict) + method = data.get if retain_doc else data.pop + + purchase = method("_purchase_invoice", frappe._dict()) + inward_supply = method("_inward_supply", frappe._dict()) + + self.update_fields(data, purchase, inward_supply) + self.update_amount_difference(data, purchase, inward_supply) + self.update_differences(data, purchase, inward_supply) + + if retain_doc and purchase: + BaseUtil.update_cess_amount(purchase) + + def update_fields(self, data, purchase, inward_supply): + for field in ("supplier_name", "supplier_gstin", "bill_no", "bill_date"): + data[field] = purchase.get(field) or inward_supply.get(field) + + data.update( + { + "supplier_name": data.supplier_name + or self.guess_supplier_name(data.supplier_gstin), + "purchase_doctype": purchase.get("doctype"), + "purchase_invoice_name": purchase.get("name"), + "inward_supply_name": inward_supply.get("name"), + "match_status": inward_supply.get("match_status"), + "action": inward_supply.get("action"), + "classification": inward_supply.get("classification") + or self.guess_classification(purchase), + } + ) + + # missing in purchase invoice + if not purchase: + data.match_status = MatchStatus.MISSING_IN_PI.value + + # missing in inward supply + elif not inward_supply: + data.match_status = MatchStatus.MISSING_IN_2A_2B.value + data.action = ( + "Ignore" + if purchase.get("reconciliation_status") == "Ignored" + else "No Action" + ) + + def update_amount_difference(self, data, purchase, inward_supply): + data.taxable_value_difference = rounded( + purchase.get("taxable_value", 0) - inward_supply.get("taxable_value", 0), + 2, + ) + + data.tax_difference = rounded( + BaseUtil.get_total_tax(purchase) - BaseUtil.get_total_tax(inward_supply), + 2, + ) + + def update_differences(self, data, purchase, inward_supply): + differences = [] + if self.is_exact_or_suggested_match(data): + if self.has_rounding_difference(data): + differences.append("Rounding Difference") + + elif not self.is_mismatch_or_manual_match(data): + return + + for field in Fields: + if field == Fields.BILL_NO: + continue + + if purchase.get(field.value) != inward_supply.get(field.value): + differences.append(field.name) + + data.differences = ", ".join(differences) + + def guess_supplier_name(self, gstin): + if party := self.gstin_party_map.get(gstin): + return party + + return self.gstin_party_map.setdefault( + gstin, get_party_for_gstin(gstin) or "Unknown" + ) + + @staticmethod + def guess_classification(doc): + GST_CATEGORIES = { + "Registered Regular": "B2B", + "SEZ": "IMPGSEZ", + "Overseas": "IMPG", + "UIN Holders": "B2B", + "Tax Deductor": "B2B", + } + + classification = GST_CATEGORIES.get(doc.gst_category) + if doc.is_return and classification == "B2B": + classification = "CDNR" + + if not classification and doc.get("doctype") == "Bill of Entry": + classification = "IMPG" + + return classification + + @staticmethod + def is_exact_or_suggested_match(data): + return data.match_status in ( + MatchStatus.EXACT_MATCH.value, + MatchStatus.SUGGESTED_MATCH.value, + ) + + @staticmethod + def is_mismatch_or_manual_match(data): + return data.match_status in ( + MatchStatus.MISMATCH.value, + MatchStatus.MANUAL_MATCH.value, + ) + + @staticmethod + def has_rounding_difference(data): + return ( + abs(data.taxable_value_difference) > 0.01 or abs(data.tax_difference) > 0.01 + ) + + +class BaseUtil: + @staticmethod + def get_fy(date): + if not date: + return + + # Standard for India as per GST + if date.month < 4: + return f"{date.year - 1}-{date.year}" + + return f"{date.year}-{date.year + 1}" + + @staticmethod + def get_cleaner_bill_no(bill_no, fy): + """ + - Attempts to return bill number without financial year. + - Removes trailing zeros from bill number. + """ + + fy = fy.split("-") + replace_list = [ + f"{fy[0]}-{fy[1]}", + f"{fy[0]}/{fy[1]}", + f"{fy[0]}-{fy[1][2:]}", + f"{fy[0]}/{fy[1][2:]}", + f"{fy[0][2:]}-{fy[1][2:]}", + f"{fy[0][2:]}/{fy[1][2:]}", + "/", # these are only special characters allowed in invoice + "-", + ] + + inv = bill_no + for replace in replace_list: + inv = inv.replace(replace, " ") + inv = " ".join(inv.split()).lstrip("0") + return inv + + @staticmethod + def get_dict_for_key(key, list): + new_dict = frappe._dict() + for data in list: + new_dict.setdefault(data[key], {})[data.name] = data + + return new_dict + + @staticmethod + def get_total_tax(doc): + total_tax = 0 + + for tax in GST_TAX_TYPES: + total_tax += doc.get(tax, 0) + + return total_tax + + @staticmethod + def update_cess_amount(doc): + doc.cess = doc.get("cess", 0) + doc.get("cess_non_advol", 0) + + @staticmethod + def get_periods(date_range, return_type: ReturnType, reversed=False): + """Returns a list of month (formatted as `MMYYYY`) in a fiscal year""" + if not date_range: + return [] + + date_range = (getdate(date_range[0]), getdate(date_range[1])) + end_date = min(date_range[1], BaseUtil._getdate(return_type)) + + # latest to oldest + return tuple( + BaseUtil._reversed(BaseUtil._get_periods(date_range[0], end_date), reversed) + ) + + @staticmethod + def _get_periods(start_date, end_date): + """Returns a list of month (formatted as `MMYYYY`) in given date range""" + + if isinstance(start_date, str): + start_date = getdate(start_date) + + if isinstance(end_date, str): + end_date = getdate(end_date) + + return [ + dt.strftime("%m%Y") + for dt in rrule(MONTHLY, dtstart=start_date, until=end_date) + ] + + @staticmethod + def _reversed(lst, reverse): + if reverse: + return reversed(lst) + return lst + + @staticmethod + def _getdate(return_type): + GSTR2B_GEN_DATE = 14 + if return_type == ReturnType.GSTR2B: + if getdate().day >= GSTR2B_GEN_DATE: + return add_months(getdate(), -1) + else: + return add_months(getdate(), -2) + + return getdate() diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/gstr_download_history.html b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/gstr_download_history.html new file mode 100644 index 000000000..9de95081c --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/gstr_download_history.html @@ -0,0 +1,37 @@ +
+ + + + {% for(let i=0; i{{ columns[i] }} + + {% } %} + + + + {% let previous_period = ''; %} + {% for(let period in data) { %} + + + {% if(period != previous_period) { %} + + {% previous_period = period; %} + {% } %} + {% for(let value of Object.values(data[period].shift())) { %} + + {% } %} + + + + {% if(data[period].length > 0) { %} + {% for(let category of data[period]) { %} + + {% for(let value of Object.values(category)) { %} + + {% } %} + + {% } %} + {% } %} + {% } %} + +
{{period}}{{ value }}
{{ value }}
+
\ No newline at end of file diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_detail_comparision.html b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_detail_comparision.html new file mode 100644 index 000000000..38e24f78d --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_detail_comparision.html @@ -0,0 +1,86 @@ +
+ + + + + + + + + + + + {% else %} + + {% endif %} + + {% if purchase.name %} + + {% else %} + + {% endif %} + + + + + + + + + + + + + + + + + + + + {% if purchase.cgst || inward_supply.cgst %} + + + + + {% endif %} + {% if purchase.sgst || inward_supply.sgst %} + + + + + {% endif %} + {% if purchase.igst || inward_supply.igst %} + + + + + {% endif %} + {% if purchase.cess || inward_supply.cess %} + + + + + {% endif %} + + + + + +
2A / 2BPurchase
Document Links + {% if inward_supply.name %} + {{ frappe.utils.get_form_link("GST Inward Supply", + inward_supply.name, true) }}-{{ frappe.utils.get_form_link(purchase.doctype, + purchase.name, true)}}-
Bill No + {{ inward_supply.bill_no || '-' }}{{ purchase.bill_no || '-' }}
Bill Date + + {{ frappe.format(inward_supply.bill_date, {'fieldtype': 'Date'}) || '-' }} + + {{ frappe.format(purchase.bill_date, {'fieldtype': 'Date'}) || '-' }} +
Place of Supply{{ inward_supply.place_of_supply || '-' }}{{ purchase.place_of_supply || '-' }}
Reverse Charge{{ inward_supply.is_reverse_charge || '-' }}{{ purchase.is_reverse_charge || '-' }}
CGST + {{ inward_supply.cgst || '-' }}{{ purchase.cgst || '-' }}
SGST + {{ inward_supply.sgst || '-' }}{{ purchase.sgst || '-' }}
IGST + {{ inward_supply.igst || '-' }}{{ purchase.igst || '-' }}
CESS + {{ inward_supply.cess || '-' }}{{ purchase.cess || '-' }}
Taxable Amount + {{ inward_supply.taxable_value || '-' }}{{ purchase.taxable_value || '-' }}
+
\ No newline at end of file diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.css b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.css new file mode 100644 index 000000000..609a3af1c --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.css @@ -0,0 +1,102 @@ +div[data-page-route="Purchase Reconciliation Tool"] { + --dt-row-height: 34px; +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="reconciliation_html"] + .section-body { + margin-top: 0; +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="reconciliation_html"] + .form-tabs-list { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 0 solid black; + padding-right: var(--padding-lg); + position: inherit; +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="reconciliation_html"] + .form-tabs-list + .custom-button-group { + display: flex; +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="reconciliation_html"] + .form-tabs-list + .inner-group-button, .filter-selector { + margin-bottom: 8px; + margin-left: 8px; +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="reconciliation_html"] + .form-tabs-list + .custom-button-group + .btn { + padding: 5px 10px; +} + +div[data-page-route="Purchase Reconciliation Tool"] .title-area .indicator-pill { + display: none; +} + +div[data-page-route="Purchase Reconciliation Tool"] .datatable .dt-scrollable { + overflow-y: auto !important; + margin-bottom: 2em; + min-height: calc(100vh - 450px); +} + +div[data-page-route="Purchase Reconciliation Tool"] .datatable .dt-row { + height: unset; +} + +div[data-page-route="Purchase Reconciliation Tool"] .datatable .dt-row-filter { + height: var(--dt-row-height); +} + +div[data-page-route="Purchase Reconciliation Tool"] .datatable .dt-row-filter .dt-cell { + max-height: var(--dt-row-height); +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="no_reconciliation_data"], +div[data-page-route="Purchase Reconciliation Tool"] [data-fieldname="not_reconciled"] { + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="no_reconciliation_data"] + > img, +div[data-page-route="Purchase Reconciliation Tool"] + [data-fieldname="not_reconciled"] + > img { + margin-bottom: var(--margin-md); + max-height: 100px; +} + +div[data-page-route="Purchase Reconciliation Tool"] .dropdown-divider { + height: 0; + margin: 5px 0; + border-top: 1px solid var(--border-color); + padding: 0; +} + +.modal-dialog div[data-fieldname="detail_table"] .table > tbody > tr > td { + text-align: center; + width: 30%; +} + +.not-matched { + color: red; + text-align: center; +} diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.js b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.js new file mode 100644 index 000000000..6829c15a3 --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.js @@ -0,0 +1,1652 @@ +// Copyright (c) 2022, Resilient Tech and contributors +// For license information, please see license.txt + +frappe.provide("purchase_reconciliation_tool"); + +const tooltip_info = { + purchase_period: "Returns purchases during this period where no match is found.", + inward_supply_period: + "Returns all documents from GSTR 2A/2B during this return period.", +}; + +const api_enabled = india_compliance.is_api_enabled(); + +const ReturnType = { + GSTR2A: "GSTR2a", + GSTR2B: "GSTR2b", +}; + +frappe.ui.form.on("Purchase Reconciliation Tool", { + async setup(frm) { + patch_set_active_tab(frm); + new india_compliance.quick_info_popover(frm, tooltip_info); + + await frappe.require("purchase_reconciliation_tool.bundle.js"); + frm.purchase_reconciliation_tool = new PurchaseReconciliationTool(frm); + }, + + onload(frm) { + if (frm.doc.is_modified) frm.doc.reconciliation_data = null; + frm.trigger("company"); + }, + + async company(frm) { + if (frm.doc.company && !frm.doc.company_gstin) { + const options = await set_gstin_options(frm); + frm.set_value("company_gstin", options[0]); + } + }, + + refresh(frm) { + // Primary Action + frm.disable_save(); + frm.page.set_primary_action(__("Reconcile"), () => frm.save()); + + // add custom buttons + api_enabled + ? frm.add_custom_button(__("Download"), () => new ImportDialog(frm)) + : frm.add_custom_button(__("Upload"), () => new ImportDialog(frm, false)); + + if (!frm.purchase_reconciliation_tool?.data?.length) return; + if (frm.get_active_tab()?.df.fieldname == "invoice_tab") { + frm.add_custom_button( + __("Unlink"), + () => unlink_documents(frm), + __("Actions") + ); + frm.add_custom_button(__("dropdown-divider"), () => {}, __("Actions")); + } + ["Accept My Values", "Accept Supplier Values", "Pending", "Ignore"].forEach( + action => + frm.add_custom_button( + __(action), + () => apply_action(frm, action), + __("Actions") + ) + ); + frm.$wrapper + .find("[data-label='dropdown-divider']") + .addClass("dropdown-divider"); + + // Export button + frm.add_custom_button(__("Export"), () => + frm.purchase_reconciliation_tool.export_data() + ); + + // move actions button next to filters + for (let button of $(".custom-actions .inner-group-button")) { + if (button.innerText?.trim() != "Actions") continue; + $(".custom-button-group .inner-group-button").remove(); + $(button).appendTo($(".custom-button-group")); + } + }, + + before_save(frm) { + frm.doc.__unsaved = true; + frm.doc.reconciliation_data = null; + }, + + purchase_period(frm) { + fetch_date_range(frm, "purchase"); + }, + + inward_supply_period(frm) { + fetch_date_range(frm, "inward_supply"); + }, + + after_save(frm) { + frm.purchase_reconciliation_tool.refresh( + frm.doc.reconciliation_data ? JSON.parse(frm.doc.reconciliation_data) : [] + ); + }, + + show_progress(frm, type) { + if (type == "download") { + frappe.run_serially([ + () => frm.events.update_progress(frm, "update_api_progress"), + () => frm.events.update_progress(frm, "update_transactions_progress"), + ]); + } else if (type == "upload") { + frm.events.update_progress(frm, "update_transactions_progress"); + } + }, + + update_progress(frm, method) { + frappe.realtime.on(method, data => { + const { current_progress } = data; + const message = + method == "update_api_progress" + ? __("Fetching data from GSTN") + : __("Updating Inward Supply for Return Period {0}", [ + data.return_period, + ]); + + frm.dashboard.show_progress( + "Import GSTR Progress", + current_progress, + message + ); + if (data.is_last_period) { + frm.flag_last_return_period = data.return_period; + } + if ( + current_progress === 100 && + method != "update_api_progress" && + frm.flag_last_return_period == data.return_period + ) { + setTimeout(() => { + frm.dashboard.hide(); + frm.refresh(); + frm.dashboard.set_headline("Successfully Imported"); + setTimeout(() => { + frm.dashboard.clear_headline(); + }, 2000); + frm.save(); + }, 1000); + } + }); + }, +}); + +class PurchaseReconciliationTool { + constructor(frm) { + this.init(frm); + this.render_tab_group(); + this.setup_filter_button(); + this.render_data_tables(); + } + + init(frm) { + this.frm = frm; + this.data = frm.doc.reconciliation_data + ? JSON.parse(frm.doc.reconciliation_data) + : []; + this.filtered_data = this.data; + this.$wrapper = this.frm.get_field("reconciliation_html").$wrapper; + this._tabs = ["invoice", "supplier", "summary"]; + } + + refresh(data) { + if (data) { + this.data = data; + this.refresh_filter_fields(); + } + + this.apply_filters(!!data); + + // data unchanged! + if (this.rendered_data == this.filtered_data) return; + + this._tabs.forEach(tab => { + this.tabs[`${tab}_tab`].refresh(this[`get_${tab}_data`]()); + }); + + this.rendered_data = this.filtered_data; + } + + render_tab_group() { + this.tab_group = new frappe.ui.FieldGroup({ + fields: [ + { + //hack: for the FieldGroup(Layout) to avoid rendering default "details" tab + fieldtype: "Section Break", + }, + { + label: "Match Summary", + fieldtype: "Tab Break", + fieldname: "summary_tab", + active: 1, + }, + { + fieldtype: "HTML", + fieldname: "summary_data", + }, + { + label: "Supplier View", + fieldtype: "Tab Break", + fieldname: "supplier_tab", + }, + { + fieldtype: "HTML", + fieldname: "supplier_data", + }, + { + label: "Document View", + fieldtype: "Tab Break", + fieldname: "invoice_tab", + }, + { + fieldtype: "HTML", + fieldname: "invoice_data", + }, + ], + body: this.$wrapper, + frm: this.frm, + }); + + this.tab_group.make(); + + // make tabs_dict for easy access + this.tabs = Object.fromEntries( + this.tab_group.tabs.map(tab => [tab.df.fieldname, tab]) + ); + } + + setup_filter_button() { + const filter_button_group = $( + ` +
+
+
+ + +
+
+
+ ` + ).appendTo(this.$wrapper.find(".form-tabs-list")); + + this.filter_group = new india_compliance.FilterGroup({ + doctype: "Purchase Reconciliation Tool", + filter_button: filter_button_group.find(".filter-button"), + filter_x_button: filter_button_group.find(".filter-x-button"), + filter_options: { + fieldname: "supplier_name", + filter_fields: this.get_filter_fields(), + }, + on_change: () => { + this.refresh(); + }, + }); + } + + get_filter_fields() { + const fields = [ + { + label: "Supplier Name", + fieldname: "supplier_name", + fieldtype: "Autocomplete", + options: this.get_autocomplete_options("supplier_name"), + }, + { + label: "Supplier GSTIN", + fieldname: "supplier_gstin", + fieldtype: "Autocomplete", + options: this.get_autocomplete_options("supplier_gstin"), + }, + { + label: "Match Status", + fieldname: "match_status", + fieldtype: "Select", + options: [ + "Exact Match", + "Suggested Match", + "Mismatch", + "Manual Match", + "Missing in 2A/2B", + "Missing in PI", + ], + }, + { + label: "Action", + fieldname: "action", + fieldtype: "Select", + options: [ + "No Action", + "Accept My Values", + "Accept Supplier Values", + "Ignore", + "Pending", + ], + }, + { + label: "Classification", + fieldname: "classification", + fieldtype: "Select", + options: [ + "B2B", + "B2BA", + "CDNR", + "CDNRA", + "ISD", + "ISDA", + "IMPG", + "IMPGSEZ", + ], + }, + { + label: "Is Reverse Charge", + fieldname: "is_reverse_charge", + fieldtype: "Check", + }, + ]; + + fields.forEach(field => (field.parent = "Purchase Reconciliation Tool")); + return fields; + } + + refresh_filter_fields() { + this.filter_group.filter_options.filter_fields = this.get_filter_fields(); + } + + get_autocomplete_options(field) { + const options = []; + this.data.forEach(row => { + if (row[field] && !options.includes(row[field])) options.push(row[field]); + }); + return options; + } + + apply_filters(force, supplier_filter) { + const has_filters = this.filter_group.filters.length > 0 || supplier_filter; + if (!has_filters) { + this.filters = null; + this.filtered_data = this.data; + return; + } + + let filters = this.filter_group.get_filters(); + if (supplier_filter) filters.push(supplier_filter); + if (!force && this.filters === filters) return; + + this.filters = filters; + this.filtered_data = this.data.filter(row => { + return filters.every(filter => + india_compliance.FILTER_OPERATORS[filter[2]]( + filter[3] || "", + row[filter[1]] || "" + ) + ); + }); + } + + render_data_tables() { + this._tabs.forEach(tab => { + this.tabs[`${tab}_tab`] = new india_compliance.DataTableManager({ + $wrapper: this.tab_group.get_field(`${tab}_data`).$wrapper, + columns: this[`get_${tab}_columns`](), + data: this[`get_${tab}_data`](), + options: { + cellHeight: 55, + }, + }); + }); + this.set_listeners(); + } + + set_listeners() { + const me = this; + this.tabs.invoice_tab.$datatable.on("click", ".btn.eye", function (e) { + const row = me.mapped_invoice_data[$(this).attr("data-name")]; + me.dm = new DetailViewDialog(me.frm, row); + }); + + this.tabs.supplier_tab.$datatable.on("click", ".btn.download", function (e) { + const row = me.tabs.supplier_tab.data.find( + r => r.supplier_gstin === $(this).attr("data-name") + ); + me.export_data(row); + }); + + this.tabs.supplier_tab.$datatable.on("click", ".btn.envelope", function (e) { + const row = me.tabs.supplier_tab.data.find( + r => r.supplier_gstin === $(this).attr("data-name") + ); + me.dm = new EmailDialog(me.frm, row); + }); + + this.tabs.summary_tab.$datatable.on( + "click", + ".match-status", + async function (e) { + e.preventDefault(); + + const match_status = $(this).text(); + await me.filter_group.push_new_filter([ + "Purchase Reconciliation Tool", + "match_status", + "=", + match_status, + ]); + me.filter_group.apply(); + } + ); + + this.tabs.supplier_tab.$datatable.on( + "click", + ".supplier-gstin", + add_supplier_gstin_filter + ) + + this.tabs.invoice_tab.$datatable.on( + "click", + ".supplier-gstin", + add_supplier_gstin_filter + ) + + + async function add_supplier_gstin_filter(e) { + e.preventDefault(); + + const supplier_gstin = $(this).text().trim(); + await me.filter_group.push_new_filter([ + "Purchase Reconciliation Tool", + "supplier_gstin", + "=", + supplier_gstin + ]); + me.filter_group.apply(); + } + } + + export_data(selected_row) { + this.data_to_export = this.get_filtered_data(selected_row); + if (selected_row) delete this.data_to_export.supplier_summary; + + const url = + "india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_tool.download_excel_report"; + + open_url_post(`/api/method/${url}`, { + data: JSON.stringify(this.data_to_export), + doc: JSON.stringify(this.frm.doc), + is_supplier_specific: !!selected_row, + }); + } + + get_filtered_data(selected_row = null) { + let supplier_filter = null; + + if (selected_row) { + supplier_filter = [ + this.frm.doctype, + "supplier_gstin", + "=", + selected_row.supplier_gstin, + false, + ]; + } + + this.apply_filters(true, supplier_filter); + + const purchases = []; + const inward_supplies = []; + + this.filtered_data.forEach(row => { + if (row.inward_supply_name) inward_supplies.push(row.inward_supply_name); + if (row.purchase_invoice_name) purchases.push(row.purchase_invoice_name); + }); + + return { + match_summary: this.get_summary_data(), + supplier_summary: this.get_supplier_data(), + purchases: purchases, + inward_supplies: inward_supplies, + }; + } + + get_summary_data() { + const data = {}; + this.filtered_data.forEach(row => { + let new_row = data[row.match_status]; + if (!new_row) { + new_row = data[row.match_status] = { + match_status: row.match_status, + inward_supply_count: 0, + purchase_count: 0, + action_taken_count: 0, + total_docs: 0, + tax_difference: 0, + taxable_value_difference: 0, + }; + } + if (row.inward_supply_name) new_row.inward_supply_count += 1; + if (row.purchase_invoice_name) new_row.purchase_count += 1; + if (row.action != "No Action") new_row.action_taken_count += 1; + new_row.total_docs += 1; + new_row.tax_difference += row.tax_difference || 0; + new_row.taxable_value_difference += row.taxable_value_difference || 0; + }); + return Object.values(data); + } + + get_summary_columns() { + return [ + { + label: "Match Status", + fieldname: "match_status", + width: 200, + _value: (...args) => `${args[0]}`, + }, + { + label: "Count
2A/2B Docs", + fieldname: "inward_supply_count", + width: 120, + align: "center", + }, + { + label: "Count
Purchase Docs", + fieldname: "purchase_count", + width: 120, + align: "center", + }, + { + label: "Taxable Amount Diff
2A/2B - Purchase", + fieldname: "taxable_value_difference", + width: 180, + align: "center", + _value: (...args) => format_number(args[0]), + }, + { + label: "Tax Difference
2A/2B - Purchase", + fieldname: "tax_difference", + width: 180, + align: "center", + _value: (...args) => format_number(args[0]), + }, + { + label: "% Action Taken", + fieldname: "action_taken", + width: 120, + align: "center", + _value: (...args) => { + return ( + roundNumber( + (args[2].action_taken_count / args[2].total_docs) * 100, + 2 + ) + " %" + ); + }, + }, + ]; + } + + get_supplier_data() { + const data = {}; + this.filtered_data.forEach(row => { + let new_row = data[row.supplier_gstin]; + if (!new_row) { + new_row = data[row.supplier_gstin] = { + supplier_name_gstin: this.get_supplier_name_gstin(row), + supplier_name: row.supplier_name, + supplier_gstin: row.supplier_gstin, + inward_supply_count: 0, + purchase_count: 0, + action_taken_count: 0, + total_docs: 0, + tax_difference: 0, + taxable_value_difference: 0, + }; + } + if (row.inward_supply_name) new_row.inward_supply_count += 1; + if (row.purchase_invoice_name) new_row.purchase_count += 1; + if (row.action != "No Action") new_row.action_taken_count += 1; + new_row.total_docs += 1; + new_row.tax_difference += row.tax_difference || 0; + new_row.taxable_value_difference += row.taxable_value_difference || 0; + }); + return Object.values(data); + } + + get_supplier_columns() { + return [ + { + label: "Supplier Name", + fieldname: "supplier_name_gstin", + fieldtype: "Link", + width: 200, + }, + { + label: "Count
2A/2B Docs", + fieldname: "inward_supply_count", + align: "center", + width: 120, + }, + { + label: "Count
Purchase Docs", + fieldname: "purchase_count", + align: "center", + width: 120, + }, + { + label: "Taxable Amount Diff
2A/2B - Purchase", + fieldname: "taxable_value_difference", + align: "center", + width: 150, + _value: (...args) => format_number(args[0]), + }, + { + label: "Tax Difference
2A/2B - Purchase", + fieldname: "tax_difference", + align: "center", + width: 150, + _value: (...args) => format_number(args[0]), + }, + { + label: "% Action
Taken", + fieldname: "action_taken", + align: "center", + width: 120, + _value: (...args) => { + return ( + roundNumber( + (args[2].action_taken_count / args[2].total_docs) * 100, + 2 + ) + " %" + ); + }, + }, + { + fieldname: "download", + fieldtype: "html", + width: 60, + _value: (...args) => get_icon(...args, "download"), + }, + { + fieldname: "email", + fieldtype: "html", + width: 60, + _value: (...args) => get_icon(...args, "envelope"), + }, + ]; + } + + get_invoice_data() { + this.mapped_invoice_data = {}; + this.filtered_data.forEach(row => { + this.mapped_invoice_data[get_hash(row)] = row; + row.supplier_name_gstin = this.get_supplier_name_gstin(row); + }); + return this.filtered_data; + } + + get_invoice_columns() { + return [ + { + fieldname: "view", + fieldtype: "html", + width: 60, + align: "center", + _value: (...args) => get_icon(...args, "eye"), + }, + { + label: "Supplier Name", + fieldname: "supplier_name_gstin", + width: 150, + }, + { + label: "Bill No.", + fieldname: "bill_no", + }, + { + label: "Date", + fieldname: "bill_date", + _value: (...args) => frappe.datetime.str_to_user(args[0]), + }, + { + label: "Match Status", + fieldname: "match_status", + width: 120, + }, + { + label: "GST Inward
Supply", + fieldname: "inward_supply_name", + fieldtype: "Link", + doctype: "GST Inward Supply", + align: "center", + width: 120, + }, + { + label: "Purchase
Invoice", + fieldname: "purchase_invoice_name", + fieldtype: "Link", + doctype: "Purchase Invoice", + align: "center", + width: 120, + }, + { + fieldname: "taxable_value_difference", + label: "Taxable Amount Diff
2A/2B - Purchase", + width: 150, + align: "center", + _value: (...args) => { + return format_number(args[0]); + }, + }, + { + label: "Tax Difference
2A/2B - Purchase", + fieldname: "tax_difference", + width: 120, + align: "center", + _value: (...args) => { + return format_number(args[0]); + }, + }, + { + fieldname: "differences", + label: "Differences", + width: 150, + align: "Left", + }, + { + label: "Action", + fieldname: "action", + }, + ]; + } + + get_supplier_name_gstin(row) { + return ` + ${row.supplier_name} +
+ + ${row.supplier_gstin || ""} + + `; + } +} + +class DetailViewDialog { + table_fields = [ + "name", + "bill_no", + "bill_date", + "taxable_value", + "cgst", + "sgst", + "igst", + "cess", + "is_reverse_charge", + "place_of_supply", + ]; + + constructor(frm, row) { + this.frm = frm; + this.row = row; + this.render_dialog(); + } + + async render_dialog() { + await this.get_invoice_details(); + this.process_data(); + this.init_dialog(); + this.setup_actions(); + this.render_html(); + this.dialog.show(); + } + + async get_invoice_details() { + const { message } = await this.frm.call("get_invoice_details", { + purchase_name: this.row.purchase_invoice_name, + inward_supply_name: this.row.inward_supply_name, + }); + + this.data = message; + } + + process_data() { + for (let key of ["_purchase_invoice", "_inward_supply"]) { + const doc = this.data[key]; + if (!doc) continue; + + this.table_fields.forEach(field => { + if (field == "is_reverse_charge" && doc[field] != undefined) + doc[field] = doc[field] ? "Yes" : "No"; + }); + } + } + + init_dialog() { + const supplier_details = ` +
${this.row.supplier_name} + ${this.row.supplier_gstin ? ` (${this.row.supplier_gstin})` : ""} +
+ `; + + this.dialog = new frappe.ui.Dialog({ + title: `Detail View (${this.row.classification})`, + fields: [ + ...this._get_document_link_fields(), + { + fieldtype: "HTML", + fieldname: "supplier_details", + options: supplier_details, + }, + { + fieldtype: "HTML", + fieldname: "diff_cards", + }, + { + fieldtype: "HTML", + fieldname: "detail_table", + }, + ], + }); + this.set_link_options(); + } + + _get_document_link_fields() { + if (this.row.match_status == "Missing in 2A/2B") + this.missing_doctype = "GST Inward Supply"; + else if (this.row.match_status == "Missing in PI") + if (["IMPG", "IMPGSEZ"].includes(this.row.classification)) + this.missing_doctype = "Bill of Entry"; + else this.missing_doctype = "Purchase Invoice"; + else return []; + + return [ + { + label: "GSTIN", + fieldtype: "Data", + fieldname: "supplier_gstin", + default: this.row.supplier_gstin, + onchange: () => this.set_link_options(), + }, + { + label: "Date Range", + fieldtype: "DateRange", + fieldname: "date_range", + default: [ + this.frm.doc.purchase_from_date, + this.frm.doc.purchase_to_date, + ], + onchange: () => this.set_link_options(), + }, + { + fieldtype: "Column Break", + }, + { + label: "Document Type", + fieldtype: "Autocomplete", + fieldname: "doctype", + default: this.missing_doctype, + options: + this.missing_doctype == "GST Inward Supply" + ? ["GST Inward Supply"] + : ["Purchase Invoice", "Bill of Entry"], + + read_only_depends_on: `eval: ${ + this.missing_doctype == "GST Inward Supply" + }`, + + onchange: () => { + const doctype = this.dialog.get_value("doctype"); + this.dialog + .get_field("show_matched") + .set_label(`Show matched options for linking ${doctype}`); + }, + }, + { + label: `Document Name`, + fieldtype: "Autocomplete", + fieldname: "link_with", + onchange: () => this.refresh_data(), + }, + { + label: `Show matched options for linking ${this.missing_doctype}`, + fieldtype: "Check", + fieldname: "show_matched", + onchange: () => this.set_link_options(), + }, + { + fieldtype: "Section Break", + }, + ]; + } + + async set_link_options() { + if (!this.dialog.get_value("doctype")) return; + + this.filters = { + supplier_gstin: this.dialog.get_value("supplier_gstin"), + bill_from_date: this.dialog.get_value("date_range")[0], + bill_to_date: this.dialog.get_value("date_range")[1], + show_matched: this.dialog.get_value("show_matched"), + purchase_doctype: this.data.purchase_doctype, + }; + + const { message } = await this.frm.call("get_link_options", { + doctype: this.dialog.get_value("doctype"), + filters: this.filters, + }); + + this.dialog.get_field("link_with").set_data(message); + } + + setup_actions() { + // determine actions + let actions = []; + const doctype = this.dialog.get_value("doctype"); + if (this.row.match_status == "Missing in 2A/2B") actions.push("Link", "Ignore"); + else if (this.row.match_status == "Missing in PI") + if (doctype == "Purchase Invoice") + actions.push("Create", "Link", "Pending", "Ignore"); + else actions.push("Link", "Pending", "Ignore"); + else + actions.push( + "Unlink", + "Accept My Values", + "Accept Supplier Values", + "Pending" + ); + + // setup actions + actions.forEach(action => { + this.dialog.add_custom_action( + action, + () => { + this._apply_custom_action(action); + this.dialog.hide(); + }, + `mr-2 ${this._get_button_css(action)}` + ); + }); + + this.dialog.$wrapper + .find(".btn.btn-secondary.not-grey") + .removeClass("btn-secondary"); + this.dialog.$wrapper.find(".modal-footer").css("flex-direction", "inherit"); + } + + _apply_custom_action(action) { + if (action == "Unlink") { + unlink_documents(this.frm, [this.row]); + } else if (action == "Link") { + purchase_reconciliation_tool.link_documents( + this.frm, + this.data.purchase_invoice_name, + this.data.inward_supply_name, + this.dialog.get_value("doctype"), + true + ); + } else if (action == "Create") { + create_new_purchase_invoice( + this.data, + this.frm.doc.company, + this.frm.doc.company_gstin + ); + } else { + apply_action(this.frm, action, [this.row]); + } + } + + _get_button_css(action) { + if (action == "Unlink") return "btn-danger not-grey"; + if (action == "Pending") return "btn-secondary"; + if (action == "Ignore") return "btn-secondary"; + if (action == "Create") return "btn-primary not-grey"; + if (action == "Link") return "btn-primary not-grey btn-link disabled"; + if (action == "Accept My Values") return "btn-primary not-grey"; + if (action == "Accept Supplier Values") return "btn-primary not-grey"; + } + + toggle_link_btn(disabled) { + const btn = this.dialog.$wrapper.find(".modal-footer .btn-link"); + if (disabled) btn.addClass("disabled"); + else btn.removeClass("disabled"); + } + + async refresh_data() { + this.toggle_link_btn(true); + const field = this.dialog.get_field("link_with"); + if (field.value) this.toggle_link_btn(false); + + if (this.missing_doctype == "GST Inward Supply") + this.row.inward_supply_name = field.value; + else this.row.purchase_invoice_name = field.value; + + await this.get_invoice_details(); + this.process_data(); + + this.row = this.data; + this.render_html(); + } + + render_html() { + this.render_cards(); + this.render_table(); + } + + render_cards() { + let cards = [ + { + value: this.row.tax_difference, + label: "Tax Difference", + datatype: "Currency", + currency: frappe.boot.sysdefaults.currency, + indicator: + this.row.tax_difference === 0 ? "text-success" : "text-danger", + }, + { + value: this.row.taxable_value_difference, + label: "Taxable Amount Difference", + datatype: "Currency", + currency: frappe.boot.sysdefaults.currency, + indicator: + this.row.taxable_value_difference === 0 + ? "text-success" + : "text-danger", + }, + ]; + + if (!this.row.purchase_invoice_name || !this.row.inward_supply_name) cards = []; + + new india_compliance.NumberCardManager({ + $wrapper: this.dialog.fields_dict.diff_cards.$wrapper, + cards: cards, + }); + } + + render_table() { + const detail_table = this.dialog.fields_dict.detail_table; + + detail_table.html( + frappe.render_template("purchase_detail_comparision", { + purchase: this.data._purchase_invoice, + inward_supply: this.data._inward_supply, + }) + ); + detail_table.$wrapper.removeClass("not-matched"); + this._set_value_color(detail_table.$wrapper); + } + + _set_value_color(wrapper) { + if (!this.row.purchase_invoice_name || !this.row.inward_supply_name) return; + + ["place_of_supply", "is_reverse_charge"].forEach(field => { + if (this.data._purchase_invoice[field] == this.data._inward_supply[field]) + return; + + wrapper + .find(`[data-label='${field}'], [data-label='${field}']`) + .addClass("not-matched"); + }); + } +} + +class ImportDialog { + constructor(frm, for_download = true) { + this.frm = frm; + this.for_download = for_download; + this.init_dialog(); + this.dialog.show(); + } + + init_dialog() { + if (this.for_download) this._init_download_dialog(); + else this._init_upload_dialog(); + + this.return_type = this.dialog.get_value("return_type"); + this.date_range = this.dialog.get_value("date_range"); + this.setup_dialog_actions(); + this.fetch_import_history(); + } + + _init_download_dialog() { + this.dialog = new frappe.ui.Dialog({ + title: __("Download Data from GSTN"), + fields: [...this.get_gstr_fields(), ...this.get_history_fields()], + }); + } + + _init_upload_dialog() { + this.dialog = new frappe.ui.Dialog({ + title: __("Upload Data"), + fields: [ + ...this.get_gstr_fields(), + { + label: "Upload Period", + fieldname: "upload_period", + fieldtype: "Data", + read_only: 1, + }, + { + fieldtype: "Section Break", + }, + { + label: "Attach File", + fieldname: "attach_file", + fieldtype: "Attach", + description: "Attach .json file here", + options: { restrictions: { allowed_file_types: [".json"] } }, + onchange: () => { + const attached_file = this.dialog.get_value("attach_file"); + if (!attached_file) return; + this.update_return_period(); + }, + }, + ...this.get_history_fields(), + ], + }); + + this.dialog.get_field("period").toggle(false); + } + + setup_dialog_actions() { + if (this.for_download) { + if (this.return_type === ReturnType.GSTR2A) { + this.dialog.$wrapper.find(".btn-secondary").removeClass("hidden"); + this.dialog.set_primary_action(__("Download All"), () => { + this.download_gstr(false); + this.dialog.hide(); + }); + this.dialog.set_secondary_action_label(__("Download Missing")); + this.dialog.set_secondary_action(() => { + this.download_gstr(true); + this.dialog.hide(); + }); + } else if (this.return_type === ReturnType.GSTR2B) { + this.dialog.$wrapper.find(".btn-secondary").addClass("hidden"); + this.dialog.set_primary_action(__("Download"), () => { + this.download_gstr(true); + this.dialog.hide(); + }); + } + } else { + this.dialog.set_primary_action(__("Upload"), () => { + const file_path = this.dialog.get_value("attach_file"); + const period = this.dialog.get_value("period"); + if (!file_path) frappe.throw(__("Please select a file first!")); + if (!period) + frappe.throw( + __( + "Could not fetch period from file, make sure you have selected the correct file!" + ) + ); + this.upload_gstr(period, file_path); + this.dialog.hide(); + }); + } + } + + async fetch_import_history() { + const { message } = await this.frm.call("get_import_history", { + return_type: this.return_type, + date_range: this.date_range, + for_download: this.for_download, + }); + + if (!message) return; + this.dialog.fields_dict.history.html( + frappe.render_template("gstr_download_history", message) + ); + } + + async update_return_period() { + const file_path = this.dialog.get_value("attach_file"); + const { message } = await this.frm.call("get_return_period_from_file", { + return_type: this.return_type, + file_path, + }); + + if (!message) { + this.dialog.get_field("attach_file").clear_attachment(); + frappe.throw( + __( + "Please make sure you have uploaded the correct file. File Uploaded is not for {0}", + [return_type] + ) + ); + } + + await this.dialog.set_value("upload_period", message); + this.dialog.refresh(); + } + + async download_gstr(only_missing = true, otp = null) { + let method; + const args = { date_range: this.date_range, otp }; + if (this.return_type === ReturnType.GSTR2A) { + method = "download_gstr_2a"; + args.force = !only_missing; + } else { + method = "download_gstr_2b"; + } + + this.frm.events.show_progress(this.frm, "download"); + const { message } = await this.frm.call(method, args); + if (message && ["otp_requested", "invalid_otp"].includes(message.error_type)) { + const otp = await india_compliance.get_gstin_otp(message.error_type); + if (otp) this.download_gstr(only_missing, otp); + return; + } + } + + upload_gstr(period, file_path) { + this.frm.events.show_progress(this.frm, "upload"); + this.frm.call("upload_gstr", { + return_type: this.return_type, + period, + file_path, + }); + } + + get_gstr_fields() { + return [ + { + label: "GST Return Type", + fieldname: "return_type", + fieldtype: "Select", + default: ReturnType.GSTR2B, + options: [ + { label: "GSTR 2A", value: ReturnType.GSTR2A }, + { label: "GSTR 2B", value: ReturnType.GSTR2B }, + ], + onchange: () => { + this.fetch_import_history(); + this.setup_dialog_actions(); + this.return_type = this.dialog.get_value("return_type"); + }, + }, + { + fieldtype: "Column Break", + }, + { + label: "Period", + fieldname: "period", + fieldtype: "Select", + options: this.frm.get_field("inward_supply_period").df.options, + default: this.frm.doc.inward_supply_period, + onchange: () => { + const period = this.dialog.get_value("period"); + this.frm.call("get_date_range", { period }).then(({ message }) => { + this.date_range = + message || this.dialog.get_value("date_range"); + this.fetch_import_history(); + }); + }, + }, + { + label: "Date Range", + fieldname: "date_range", + fieldtype: "DateRange", + default: [ + this.frm.doc.inward_supply_from_date, + this.frm.doc.inward_supply_to_date, + ], + depends_on: "eval:doc.period == 'Custom'", + onchange: () => { + this.date_range = this.dialog.get_value("date_range"); + this.fetch_import_history(); + }, + }, + ]; + } + + get_history_fields() { + const label = this.for_download ? "Download History" : "Upload History"; + + return [ + { label, fieldtype: "Section Break" }, + { label, fieldname: "history", fieldtype: "HTML" }, + ]; + } +} + +class EmailDialog { + constructor(frm, data) { + this.frm = frm; + this.data = data; + this.get_attachment(); + } + + get_attachment() { + const export_data = this.frm.purchase_reconciliation_tool.get_filtered_data( + this.data + ); + + frappe.call({ + method: "india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_tool.generate_excel_attachment", + args: { + data: JSON.stringify(export_data), + doc: JSON.stringify(this.frm.doc), + }, + callback: r => { + this.prepare_email_args(r.message); + }, + }); + } + + async prepare_email_args(attachment) { + this.attachment = attachment; + Object.assign(this, await this.get_template()); + this.recipients = await this.get_recipients(); + this.show_email_dialog(); + } + + show_email_dialog() { + const args = { + subject: this.subject, + recipients: this.recipients, + attach_document_print: false, + message: this.message, + attachments: this.attachment, + }; + new frappe.views.CommunicationComposer(args); + } + async get_template() { + if (!this.frm.meta.default_email_template) return {}; + let doc = { + ...this.frm.doc, + ...this.data, + }; + + const { message } = await frappe.call({ + method: "frappe.email.doctype.email_template.email_template.get_email_template", + args: { + template_name: this.frm.meta.default_email_template, + doc: doc, + }, + }); + + return message; + } + + async get_recipients() { + const { message } = await frappe.call({ + method: "india_compliance.gst_india.utils.get_party_contact_details", + args: { + party: this.data.supplier_name, + }, + }); + + return message?.contact_email || []; + } +} + +async function fetch_date_range(frm, field_prefix) { + const from_date_field = field_prefix + "_from_date"; + const to_date_field = field_prefix + "_to_date"; + const period = frm.doc[field_prefix + "_period"]; + if (period == "Custom") return; + + const { message } = await frm.call("get_date_range", { period }); + if (!message) return; + + frm.set_value(from_date_field, message[0]); + frm.set_value(to_date_field, message[1]); +} + +function get_icon(value, column, data, icon) { + /** + * Returns custom ormated value for the row. + * @param {string} value Current value of the row. + * @param {object} column All properties of current column + * @param {object} data All values in its core form for current row + * @param {string} icon Return icon (font-awesome) as the content + */ + + const hash = get_hash(data); + return ``; +} + +function get_hash(data) { + if (data.purchase_invoice_name || data.inward_supply_name) + return data.purchase_invoice_name + "~" + data.inward_supply_name; + if (data.supplier_gstin) return data.supplier_gstin; +} + +function patch_set_active_tab(frm) { + const set_active_tab = frm.set_active_tab; + frm.set_active_tab = function (...args) { + set_active_tab.apply(this, args); + frm.refresh(); + }; +} + +purchase_reconciliation_tool.link_documents = async function ( + frm, + purchase_invoice_name, + inward_supply_name, + link_doctype, + alert = true +) { + if (frm.get_active_tab()?.df.fieldname != "invoice_tab") return; + + // link documents & update data. + const { message: r } = await frm.call("link_documents", { + purchase_invoice_name, + inward_supply_name, + link_doctype, + }); + const reco_tool = frm.purchase_reconciliation_tool; + const new_data = reco_tool.data.filter( + row => + !( + row.purchase_invoice_name == purchase_invoice_name || + row.inward_supply_name == inward_supply_name + ) + ); + new_data.push(...r); + + reco_tool.refresh(new_data); + if (alert) + after_successful_action(frm.purchase_reconciliation_tool.tabs.invoice_tab); +}; + +async function unlink_documents(frm, selected_rows) { + if (frm.get_active_tab()?.df.fieldname != "invoice_tab") return; + const { invoice_tab } = frm.purchase_reconciliation_tool.tabs; + if (!selected_rows) selected_rows = invoice_tab.get_checked_items(); + + if (!selected_rows.length) + return frappe.show_alert({ + message: __("Please select rows to unlink"), + indicator: "red", + }); + + // validate selected rows + selected_rows.forEach(row => { + if (row.match_status.includes("Missing")) + frappe.throw( + __( + "You have selected rows where no match is available. Please remove them before unlinking." + ) + ); + }); + + // unlink documents & update table + const { message: r } = await frm.call("unlink_documents", selected_rows); + const unlinked_docs = get_unlinked_docs(selected_rows); + + const reco_tool = frm.purchase_reconciliation_tool; + const new_data = reco_tool.data.filter( + row => + !( + unlinked_docs.has(row.purchase_invoice_name) || + unlinked_docs.has(row.inward_supply_name) + ) + ); + new_data.push(...r); + reco_tool.refresh(new_data); + after_successful_action(invoice_tab); +} + +function get_unlinked_docs(selected_rows) { + const unlinked_docs = new Set(); + selected_rows.forEach(row => { + unlinked_docs.add(row.purchase_invoice_name); + unlinked_docs.add(row.inward_supply_name); + }); + + return unlinked_docs; +} + +function deepcopy(array) { + return JSON.parse(JSON.stringify(array)); +} + +function apply_action(frm, action, selected_rows) { + const active_tab = frm.get_active_tab()?.df.fieldname; + if (!active_tab) return; + + const tab = frm.purchase_reconciliation_tool.tabs[active_tab]; + if (!selected_rows) selected_rows = tab.get_checked_items(); + + // get affected rows + const { filtered_data, data } = frm.purchase_reconciliation_tool; + let affected_rows = get_affected_rows(active_tab, selected_rows, filtered_data); + + if (!affected_rows.length) + return frappe.show_alert({ + message: __("Please select rows to apply action"), + indicator: "red", + }); + + // validate affected rows + if (action.includes("Accept")) { + let warn = false; + affected_rows = affected_rows.filter(row => { + if (row.match_status.includes("Missing")) { + warn = true; + return false; + } + return true; + }); + + if (warn) + frappe.msgprint( + __( + "You can only Accept values where a match is available. Rows where match is missing will be ignored." + ) + ); + } else if (action == "Ignore") { + let warn = false; + affected_rows = affected_rows.filter(row => { + if (!row.match_status.includes("Missing")) { + warn = true; + return false; + } + return true; + }); + + if (warn) + frappe.msgprint( + __( + "You can only apply Ignore action on rows where data is Missing in 2A/2B or Missing in PI. These rows will be ignored." + ) + ); + } + + // update affected rows to backend and frontend + frm.call("apply_action", { data: affected_rows, action }); + const new_data = data.filter(row => { + if (has_matching_row(row, affected_rows)) row.action = action; + return true; + }); + + frm.purchase_reconciliation_tool.refresh(new_data); + after_successful_action(tab); +} + +function after_successful_action(tab) { + if (tab) tab.clear_checked_items(); + frappe.show_alert({ + message: "Action applied successfully", + indicator: "green", + }); +} + +function has_matching_row(row, array) { + return array.filter(item => JSON.stringify(item) === JSON.stringify(row)).length; +} + +function get_affected_rows(tab, selection, data) { + if (tab == "invoice_tab") return selection; + + if (tab == "supplier_tab") + return data.filter( + inv => + selection.filter(row => row.supplier_gstin == inv.supplier_gstin).length + ); + + if (tab == "summary_tab") + return data.filter( + inv => selection.filter(row => row.match_status == inv.match_status).length + ); +} + +async function create_new_purchase_invoice(row, company, company_gstin) { + if (row.match_status != "Missing in PI") return; + const doc = row._inward_supply; + + const { message: supplier } = await frappe.call({ + method: "india_compliance.gst_india.utils.get_party_for_gstin", + args: { + gstin: row.supplier_gstin, + }, + }); + + let company_address; + await frappe.model.get_value( + "Address", + { gstin: company_gstin, is_your_company_address: 1 }, + "name", + r => (company_address = r.name) + ); + + frappe.route_hooks.after_load = frm => { + function _set_value(values) { + for (const key in values) { + if (values[key] == frm.doc[key]) continue; + frm.set_value(key, values[key]); + } + } + + const values = { + company: company, + bill_no: doc.bill_no, + bill_date: doc.bill_date, + is_reverse_charge: ["Yes", 1].includes(doc.is_reverse_charge) ? 1 : 0, + }; + + _set_value({ + ...values, + supplier: supplier, + shipping_address: company_address, + billing_address: company_address, + }); + + // validated this on save + frm._inward_supply = { + ...values, + name: row.inward_supply_name, + company_gstin: company_gstin, + inward_supply: row.inward_supply, + supplier_gstin: row.supplier_gstin, + place_of_supply: doc.place_of_supply, + cgst: doc.cgst, + sgst: doc.sgst, + igst: doc.igst, + cess: doc.cess, + taxable_value: doc.taxable_value, + }; + }; + + frappe.new_doc("Purchase Invoice"); +} + +async function set_gstin_options(frm) { + const { query, params } = india_compliance.get_gstin_query(frm.doc.company); + const { message } = await frappe.call({ + method: query, + args: params, + }); + + if (!message) return []; + const gstin_field = frm.get_field("company_gstin"); + gstin_field.set_data(message); + return message; +} diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.json b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.json new file mode 100644 index 000000000..f6770defb --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.json @@ -0,0 +1,194 @@ +{ + "actions": [], + "creation": "2022-04-22 15:27:38.166558", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "include_ignored", + "column_break_2", + "company_gstin", + "column_break_3", + "purchase_period", + "purchase_from_date", + "purchase_to_date", + "column_break_6", + "inward_supply_period", + "inward_supply_from_date", + "inward_supply_to_date", + "column_break_12", + "gst_return", + "section_break_11", + "reconciliation_html", + "not_reconciled", + "no_reconciliation_data", + "is_modified", + "section_break_cmfa", + "reconciliation_data" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "company_gstin", + "fieldtype": "Autocomplete", + "label": "Company GSTIN" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "default": "This Fiscal Year", + "fieldname": "purchase_period", + "fieldtype": "Select", + "label": "Purchase Period", + "options": "\nThis Month\nThis Quarter\nThis Fiscal Year\nLast Month\nLast Quarter\nLast Fiscal Year\nCustom" + }, + { + "depends_on": "eval: doc.purchase_period == 'Custom'", + "fieldname": "purchase_from_date", + "fieldtype": "Date", + "label": "From Date", + "read_only_depends_on": "eval: doc.purchase_period != 'Custom'" + }, + { + "depends_on": "eval: doc.purchase_period == 'Custom'", + "fieldname": "purchase_to_date", + "fieldtype": "Date", + "label": "To Date", + "read_only_depends_on": "eval: doc.purchase_period != 'Custom'" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "default": "This Fiscal Year", + "fieldname": "inward_supply_period", + "fieldtype": "Select", + "label": "Inward Supply Period", + "options": "\nThis Month\nThis Quarter\nThis Fiscal Year\nLast Month\nLast Quarter\nLast Fiscal Year\nCustom" + }, + { + "depends_on": "eval: doc.inward_supply_period == 'Custom'", + "fieldname": "inward_supply_from_date", + "fieldtype": "Date", + "label": "From Date", + "read_only_depends_on": "eval: doc.inward_supply_period != 'Custom'" + }, + { + "depends_on": "eval: doc.inward_supply_period == 'Custom'", + "fieldname": "inward_supply_to_date", + "fieldtype": "Date", + "label": "To Date", + "read_only_depends_on": "eval: doc.inward_supply_period != 'Custom'" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.reconciliation_data?.length", + "fieldname": "reconciliation_html", + "fieldtype": "HTML" + }, + { + "depends_on": "eval: doc.reconciliation_data && !doc.reconciliation_data.length", + "fieldname": "no_reconciliation_data", + "fieldtype": "HTML", + "options": "\"No\n\t

{{ __(\"No data available for selected filters\") }}

" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "gst_return", + "fieldtype": "Select", + "label": "GST Return", + "options": "GSTR 2B\nBoth GSTR 2A & 2B" + }, + { + "depends_on": "eval: !doc.reconciliation_data", + "fieldname": "not_reconciled", + "fieldtype": "HTML", + "options": "\"No\n\t

{{ __(\"Reconcile to view the data\") }}

" + }, + { + "fieldname": "section_break_cmfa", + "fieldtype": "Section Break", + "hidden": 1 + }, + { + "fieldname": "reconciliation_data", + "fieldtype": "JSON", + "label": "Reconciliation Data" + }, + { + "default": "0", + "fieldname": "is_modified", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Modified" + }, + { + "default": "0", + "fieldname": "include_ignored", + "fieldtype": "Check", + "label": "Include Ignored" + } + ], + "hide_toolbar": 1, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2023-09-14 12:57:00.756535", + "modified_by": "Administrator", + "module": "GST India", + "name": "Purchase Reconciliation Tool", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "role": "Accounts Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "role": "Accounts User", + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.py b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.py new file mode 100644 index 000000000..d9562cd48 --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_tool.py @@ -0,0 +1,1136 @@ +# Copyright (c) 2022, Resilient Tech and contributors +# For license information, please see license.txt + +import json +from typing import List + +import frappe +from frappe.model.document import Document +from frappe.query_builder.functions import IfNull +from frappe.utils.response import json_handler + +from india_compliance.gst_india.constants import ORIGINAL_VS_AMENDED +from india_compliance.gst_india.doctype.purchase_reconciliation_tool import ( + BaseUtil, + BillOfEntry, + PurchaseInvoice, + ReconciledData, + Reconciler, +) +from india_compliance.gst_india.utils import get_json_from_file, get_timespan_date_range +from india_compliance.gst_india.utils.exporter import ExcelExporter +from india_compliance.gst_india.utils.gstr import ( + IMPORT_CATEGORY, + GSTRCategory, + ReturnType, + download_gstr_2a, + download_gstr_2b, + save_gstr_2a, + save_gstr_2b, +) + + +class PurchaseReconciliationTool(Document): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ReconciledData = ReconciledData(**self.get_reco_doc()) + + def get_reco_doc(self): + fields = ( + "company", + "company_gstin", + "gst_return", + "purchase_from_date", + "purchase_to_date", + "inward_supply_from_date", + "inward_supply_to_date", + "include_ignored", + ) + return {field: self.get(field) for field in fields} + + def validate(self): + # reconcile purchases and inward supplies + if frappe.flags.in_install or frappe.flags.in_migrate: + return + + _Reconciler = Reconciler(**self.get_reco_doc()) + for row in ORIGINAL_VS_AMENDED: + _Reconciler.reconcile(row["original"], row["amended"]) + + self.ReconciledData = ReconciledData(**self.get_reco_doc()) + self.reconciliation_data = json.dumps( + self.ReconciledData.get(), default=json_handler + ) + + self.db_set("is_modified", 0) + + @frappe.whitelist() + def upload_gstr(self, return_type, period, file_path): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + return_type = ReturnType(return_type) + json_data = get_json_from_file(file_path) + if return_type == ReturnType.GSTR2A: + return save_gstr_2a(self.company_gstin, period, json_data) + + if return_type == ReturnType.GSTR2B: + return save_gstr_2b(self.company_gstin, period, json_data) + + @frappe.whitelist() + def download_gstr_2a(self, date_range, force=False, otp=None): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + return_type = ReturnType.GSTR2A + periods = BaseUtil.get_periods(date_range, return_type) + if not force: + periods = self.get_periods_to_download(return_type, periods) + + return download_gstr_2a(self.company_gstin, periods, otp) + + @frappe.whitelist() + def download_gstr_2b(self, date_range, otp=None): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + return_type = ReturnType.GSTR2B + periods = self.get_periods_to_download( + return_type, BaseUtil.get_periods(date_range, return_type) + ) + return download_gstr_2b(self.company_gstin, periods, otp) + + def get_periods_to_download(self, return_type, periods): + existing_periods = get_import_history( + self.company_gstin, + return_type, + periods, + pluck="return_period", + ) + + return [period for period in periods if period not in existing_periods] + + @frappe.whitelist() + def get_import_history(self, return_type, date_range, for_download=True): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + if not return_type: + return + + return_type = ReturnType(return_type) + periods = BaseUtil.get_periods(date_range, return_type, True) + history = get_import_history(self.company_gstin, return_type, periods) + + columns = [ + "Period", + "Classification", + "Status", + f"{'Downloaded' if for_download else 'Uploaded'} On", + ] + + settings = frappe.get_cached_doc("GST Settings") + + data = {} + for period in periods: + # TODO: skip if today is not greater than 14th return period's next months + data[period] = [] + status = "🟢   Downloaded" + for category in GSTRCategory: + if category.value == "ISDA" and return_type == ReturnType.GSTR2A: + continue + + if ( + not settings.enable_overseas_transactions + and category.value in IMPORT_CATEGORY + ): + continue + + download = next( + ( + log + for log in history + if log.return_period == period + and log.classification in (category.value, "") + ), + None, + ) + + status = "🟠   Not Downloaded" + if download: + status = "🟢   Downloaded" + if download.data_not_found: + status = "🔵   Data Not Found" + if download.request_id: + status = "🔵   Queued" + + if not for_download: + status = status.replace("Downloaded", "Uploaded") + + _dict = { + "Classification": category.value + if return_type is ReturnType.GSTR2A + else "ALL", + "Status": status, + columns[-1]: "✅  " + + download.last_updated_on.strftime("%d-%m-%Y %H:%M:%S") + if download + else "", + } + if _dict not in data[period]: + data[period].append(_dict) + + return {"columns": columns, "data": data} + + @frappe.whitelist() + def get_return_period_from_file(self, return_type, file_path): + """ + Permissions check not necessary as response is not sensitive + """ + if not file_path: + return + + return_type = ReturnType(return_type) + try: + json_data = get_json_from_file(file_path) + if return_type == ReturnType.GSTR2A: + return json_data.get("fp") + + if return_type == ReturnType.GSTR2B: + return json_data.get("data").get("rtnprd") + + except Exception: + pass + + @frappe.whitelist() + def get_date_range(self, period): + """ + Permissions check not necessary as response is not sensitive + """ + if not period or period == "Custom": + return + + return get_timespan_date_range(period.lower(), self.company) + + @frappe.whitelist() + def get_invoice_details(self, purchase_name, inward_supply_name): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + return self.ReconciledData.get_manually_matched_data( + purchase_name, inward_supply_name + ) + + @frappe.whitelist() + def link_documents(self, purchase_invoice_name, inward_supply_name, link_doctype): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + if not purchase_invoice_name or not inward_supply_name: + return + + purchases = [] + inward_supplies = [] + + # silently handle existing links + if isup_linked_with := frappe.db.get_value( + "GST Inward Supply", inward_supply_name, "link_name" + ): + self._unlink_documents((inward_supply_name,)) + purchases.append(isup_linked_with) + + link_doc = { + "link_doctype": link_doctype, + "link_name": purchase_invoice_name, + } + if pur_linked_with := frappe.db.get_all( + "GST Inward Supply", link_doc, pluck="name" + ): + self._unlink_documents((pur_linked_with)) + inward_supplies.append(pur_linked_with) + + link_doc["match_status"] = "Manual Match" + + # link documents + frappe.db.set_value( + "GST Inward Supply", + inward_supply_name, + link_doc, + ) + purchases.append(purchase_invoice_name) + inward_supplies.append(inward_supply_name) + + self.db_set("is_modified", 1) + + return self.ReconciledData.get(purchases, inward_supplies) + + @frappe.whitelist() + def unlink_documents(self, data): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + if isinstance(data, str): + data = frappe.parse_json(data) + + inward_supplies = set() + purchases = set() + boe = set() + + for doc in data: + inward_supplies.add(doc.get("inward_supply_name")) + + purchase_doctype = doc.get("purchase_doctype") + if purchase_doctype == "Purchase Invoice": + purchases.add(doc.get("purchase_invoice_name")) + + elif purchase_doctype == "Bill of Entry": + boe.add(doc.get("purchase_invoice_name")) + + self.set_reconciliation_status("Purchase Invoice", purchases, "Unreconciled") + self.set_reconciliation_status("Bill of Entry", boe, "Unreconciled") + self._unlink_documents(inward_supplies) + + self.db_set("is_modified", 1) + + return self.ReconciledData.get(purchases.union(boe), inward_supplies) + + def set_reconciliation_status(self, doctype, names, status): + if not names: + return + + frappe.db.set_value( + doctype, {"name": ("in", names)}, "reconciliation_status", status + ) + + def _unlink_documents(self, inward_supplies): + if not inward_supplies: + return + + GSTR2 = frappe.qb.DocType("GST Inward Supply") + ( + frappe.qb.update(GSTR2) + .set("link_doctype", "") + .set("link_name", "") + .set("match_status", "Unlinked") + .where(GSTR2.name.isin(inward_supplies)) + .run() + ) + + # Revert action performed + ( + frappe.qb.update(GSTR2) + .set("action", "No Action") + .where(GSTR2.name.isin(inward_supplies)) + .where(GSTR2.action.notin(("Ignore", "Pending"))) + .run() + ) + + @frappe.whitelist() + def apply_action(self, data, action): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + if isinstance(data, str): + data = frappe.parse_json(data) + + STATUS_MAP = { + "Accept My Values": "Reconciled", + "Accept Supplier Values": "Reconciled", + "Pending": "Unreconciled", + "Ignore": "Ignored", + } + + status = STATUS_MAP.get(action) + + inward_supplies = [] + purchases = [] + boe = [] + + for doc in data: + if action == "Ignore" and "Missing" not in doc.get("match_status"): + continue + + elif "Accept" in action and "Missing" in doc.get("match_status"): + continue + + if inward_supply_name := doc.get("inward_supply_name"): + inward_supplies.append(inward_supply_name) + + purchase_doctype = doc.get("purchase_doctype") + if purchase_doctype == "Purchase Invoice": + purchases.append(doc.get("purchase_invoice_name")) + + elif purchase_doctype == "Bill of Entry": + boe.append(doc.get("purchase_invoice_name")) + + if inward_supplies: + frappe.db.set_value( + "GST Inward Supply", {"name": ("in", inward_supplies)}, "action", action + ) + + self.set_reconciliation_status("Purchase Invoice", purchases, status) + self.set_reconciliation_status("Bill of Entry", boe, status) + + self.db_set("is_modified", 1) + + @frappe.whitelist() + def get_link_options(self, doctype, filters): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + + if isinstance(filters, dict): + filters = frappe._dict(filters) + + if doctype == "Purchase Invoice": + return self.get_purchase_invoice_options(filters) + + elif doctype == "GST Inward Supply": + return self.get_inward_supply_options(filters) + + elif doctype == "Bill of Entry": + return self.get_bill_of_entry_options(filters) + + def get_purchase_invoice_options(self, filters): + PI = frappe.qb.DocType("Purchase Invoice") + query = ( + self.ReconciledData.query_purchase_invoice(["gst_category", "is_return"]) + .where(PI.supplier_gstin.like(f"%{filters.supplier_gstin}%")) + .where(PI.bill_date[filters.bill_from_date : filters.bill_to_date]) + ) + + if not filters.show_matched: + query = query.where( + PI.name.notin(PurchaseInvoice.query_matched_purchase_invoice()) + ) + + return self._get_link_options(query.run(as_dict=True)) + + def get_inward_supply_options(self, filters): + GSTR2 = frappe.qb.DocType("GST Inward Supply") + query = ( + self.ReconciledData.query_inward_supply(["classification"]) + .where(GSTR2.supplier_gstin.like(f"%{filters.supplier_gstin}%")) + .where(GSTR2.bill_date[filters.bill_from_date : filters.bill_to_date]) + ) + + if filters.get("purchase_doctype") == "Purchase Invoice": + query = query.where(GSTR2.classification.notin(IMPORT_CATEGORY)) + elif filters.get("purchase_doctype") == "Bill of Entry": + query = query.where(GSTR2.classification.isin(IMPORT_CATEGORY)) + + if not filters.show_matched: + query = query.where(IfNull(GSTR2.link_name, "") == "") + + return self._get_link_options(query.run(as_dict=True)) + + def get_bill_of_entry_options(self, filters): + BOE = frappe.qb.DocType("Bill of Entry") + query = self.ReconciledData.query_bill_of_entry().where( + BOE.bill_of_entry_date[filters.bill_from_date : filters.bill_to_date] + ) + + if not filters.show_matched: + query = query.where( + BOE.name.notin(BillOfEntry.query_matched_bill_of_entry()) + ) + + return self._get_link_options(query.run(as_dict=True)) + + def _get_link_options(self, data): + for row in data: + row.value = row.label = row.name + if not row.get("classification"): + row.classification = self.ReconciledData.guess_classification(row) + + row.description = ( + f"{row.bill_no}, {row.bill_date}, Taxable Amount: {row.taxable_value}" + ) + row.description += ( + f", Tax Amount: {BaseUtil.get_total_tax(row)}, {row.classification}" + ) + + return data + + +def get_import_history( + company_gstin, return_type: ReturnType, periods: List[str], fields=None, pluck=None +): + if not (fields or pluck): + fields = ( + "return_period", + "classification", + "data_not_found", + "last_updated_on", + "request_id", + ) + + return frappe.db.get_all( + "GSTR Import Log", + filters={ + "gstin": company_gstin, + "return_type": return_type.value, + "return_period": ("in", periods), + }, + fields=fields, + pluck=pluck, + ) + + +@frappe.whitelist() +def generate_excel_attachment(data, doc): + frappe.has_permission("Purchase Reconciliation Tool", "email", throw=True) + + build_data = BuildExcel(doc, data, is_supplier_specific=True, email=True) + + xlsx_file, filename = build_data.export_data() + xlsx_data = xlsx_file.getvalue() + + # Upload attachment for email xlsx data using communication make() method + folder = frappe.form_dict.folder or "Home" + file_url = frappe.form_dict.file_url or "" + + file = frappe.get_doc( + { + "doctype": "File", + "attached_to_doctype": "Purchase Reconciliation Tool", + "attached_to_name": "Purchase Reconciliation Tool", + "folder": folder, + "file_name": f"{filename}.xlsx", + "file_url": file_url, + "is_private": 0, + "content": xlsx_data, + } + ) + file.save(ignore_permissions=True) + return [file] + + +@frappe.whitelist() +def download_excel_report(data, doc, is_supplier_specific=False): + frappe.has_permission("Purchase Reconciliation Tool", "export", throw=True) + + build_data = BuildExcel(doc, data, is_supplier_specific) + build_data.export_data() + + +def parse_params(fun): + def wrapper(*args, **kwargs): + args = [frappe.parse_json(arg) for arg in args] + kwargs = {k: frappe.parse_json(v) for k, v in kwargs.items()} + return fun(*args, **kwargs) + + return wrapper + + +class BuildExcel: + COLOR_PALLATE = frappe._dict( + { + "dark_gray": "d9d9d9", + "light_gray": "f2f2f2", + "dark_pink": "e6b9b8", + "light_pink": "f2dcdb", + "sky_blue": "c6d9f1", + "light_blue": "dce6f2", + "green": "d7e4bd", + "light_green": "ebf1de", + } + ) + + @parse_params + def __init__(self, doc, data, is_supplier_specific=False, email=False): + """ + :param doc: purchase reconciliation tool doc + :param data: data to be exported + :param is_supplier_specific: if true, data will be downloded for specific supplier + :param email: send the file as email + """ + self.doc = doc + self.data = data + self.is_supplier_specific = is_supplier_specific + self.email = email + self.set_headers() + self.set_filters() + + def export_data(self): + """Exports data to an excel file""" + excel = ExcelExporter() + excel.create_sheet( + sheet_name="Match Summary Data", + filters=self.filters, + headers=self.match_summary_header, + data=self.get_match_summary_data(), + ) + + if not self.is_supplier_specific: + excel.create_sheet( + sheet_name="Supplier Data", + filters=self.filters, + headers=self.supplier_header, + data=self.get_supplier_data(), + ) + + excel.create_sheet( + sheet_name="Invoice Data", + filters=self.filters, + merged_headers=self.get_merge_headers(), + headers=self.invoice_header, + data=self.get_invoice_data(), + ) + + excel.remove_sheet("Sheet") + + file_name = self.get_file_name() + if self.email: + xlsx_data = excel.save_workbook() + return [xlsx_data, file_name] + + excel.export(file_name) + + def set_headers(self): + """Sets headers for the excel file""" + + self.match_summary_header = self.get_match_summary_columns() + self.supplier_header = self.get_supplier_columns() + self.invoice_header = self.get_invoice_columns() + + def set_filters(self): + """Add filters to the sheet""" + + label = "2B" if self.doc.gst_return == "GSTR 2B" else "2A/2B" + self.period = ( + f"{self.doc.inward_supply_from_date} to {self.doc.inward_supply_to_date}" + ) + + self.filters = frappe._dict( + { + "Company Name": self.doc.company, + "GSTIN": self.doc.company_gstin, + f"Return Period ({label})": self.period, + } + ) + + def get_merge_headers(self): + """Returns merged_headers for the excel file""" + return frappe._dict( + { + "2A / 2B": ["inward_supply_bill_no", "inward_supply_cess"], + "Purchase Data": ["bill_no", "cess"], + } + ) + + def get_match_summary_data(self): + return self.process_data( + self.data.get("match_summary"), + self.match_summary_header, + ) + + def get_supplier_data(self): + return self.process_data( + self.data.get("supplier_summary"), self.supplier_header + ) + + def get_invoice_data(self): + data = ReconciledData(**self.doc).get_consolidated_data( + self.data.get("purchases"), + self.data.get("inward_supplies"), + prefix="inward_supply", + ) + + # TODO: Sanitize supplier name and gstin + self.supplier_name = data[0].get("supplier_name") + self.supplier_gstin = data[0].get("supplier_gstin") + + return self.process_data(data, self.invoice_header) + + def process_data(self, data, column_list): + """return required list of dict for the excel file""" + if not data: + return + + out = [] + fields = [d.get("fieldname") for d in column_list] + purchase_fields = [field.get("fieldname") for field in self.pr_columns] + for row in data: + new_row = {} + for field in fields: + if field not in row: + row[field] = None + + # pur data in row (for invoice_summary) is polluted for Missing in PI + if field in purchase_fields and not row.get("name"): + row[field] = None + + self.assign_value(field, row, new_row) + + out.append(new_row) + + return out + + def assign_value(self, field, source_data, target_data): + if source_data.get(field) is None: + target_data[field] = None + return + + if "is_reverse_charge" in field: + target_data[field] = "Yes" if source_data.get(field) else "No" + return + + target_data[field] = source_data.get(field) + + def get_file_name(self): + """Returns file name for the excel file""" + if not self.is_supplier_specific: + return f"{self.doc.company_gstin}_{self.period}_report" + + file_name = f"{self.supplier_name}_{self.supplier_gstin}" + return file_name.replace(" ", "_") + + def get_match_summary_columns(self): + """ + Defaults: + - bg_color: self.COLOR_PALLATE.dark_gray + - bg_color_data": self.COLOR_PALLATE.light_gray + - bold: 1 + - align_header: "center" + - align_data: "general" + - width: 20 + """ + return [ + { + "label": "Match Status", + "fieldname": "match_status", + "data_format": {"horizontal": "left"}, + "header_format": {"horizontal": "center"}, + }, + { + "label": "Count \n 2A/2B Docs", + "fieldname": "inward_supply_count", + "fieldtype": "Int", + "data_format": {"number_format": "#,##0"}, + }, + { + "label": "Count \n Purchase Docs", + "fieldname": "purchase_count", + "fieldtype": "Int", + "data_format": {"number_format": "#,##0"}, + }, + { + "label": "Taxable Amount Diff \n 2A/2B - Purchase", + "fieldname": "taxable_value_difference", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_pink, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.dark_pink, + }, + }, + { + "label": "Tax Difference \n 2A/2B - Purchase", + "fieldname": "tax_difference", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_pink, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.dark_pink, + }, + }, + { + "label": "%Action Taken", + "fieldname": "action_taken_count", + "data_format": {"number_format": "0.00%"}, + "width": 12, + }, + ] + + def get_supplier_columns(self): + return [ + { + "label": "Supplier Name", + "fieldname": "supplier_name", + "data_format": {"horizontal": "left"}, + }, + { + "label": "Supplier GSTIN", + "fieldname": "supplier_gstin", + "data_format": {"horizontal": "left"}, + }, + { + "label": "Count \n 2A/2B Docs", + "fieldname": "inward_supply_count", + "fieldtype": "Int", + "data_format": {"number_format": "#,##0"}, + }, + { + "label": "Count \n Purchase Docs", + "fieldname": "purchase_count", + "fieldtype": "Int", + "data_format": { + "number_format": "#,##0", + }, + }, + { + "label": "Taxable Amount Diff \n 2A/2B - Purchase", + "fieldname": "taxable_value_difference", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_pink, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.dark_pink, + }, + }, + { + "label": "Tax Difference \n 2A/2B - Purchase", + "fieldname": "tax_difference", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_pink, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.dark_pink, + }, + }, + { + "label": "%Action Taken", + "fieldname": "action_taken_count", + "data_format": {"number_format": "0.00%"}, + "header_format": { + "width": 12, + }, + }, + ] + + def get_invoice_columns(self): + self.pr_columns = [ + { + "label": "Bill No", + "fieldname": "bill_no", + "compare_with": "inward_supply_bill_no", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_green, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "Bill Date", + "fieldname": "bill_date", + "compare_with": "inward_supply_bill_date", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_green, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "GSTIN", + "fieldname": "supplier_gstin", + "compare_with": "inward_supply_supplier_gstin", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_green, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 15, + }, + }, + { + "label": "Place of Supply", + "fieldname": "place_of_supply", + "compare_with": "inward_supply_place_of_supply", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_green, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "Reverse Charge", + "fieldname": "is_reverse_charge", + "compare_with": "inward_supply_is_reverse_charge", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_green, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "Taxable Value", + "fieldname": "taxable_value", + "compare_with": "inward_supply_taxable_value", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_green, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "CGST", + "fieldname": "cgst", + "compare_with": "inward_supply_cgst", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_green, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "SGST", + "fieldname": "sgst", + "compare_with": "inward_supply_sgst", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_green, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "IGST", + "fieldname": "igst", + "compare_with": "inward_supply_igst", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_green, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + { + "label": "CESS", + "fieldname": "cess", + "compare_with": "inward_supply_cess", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_green, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.green, + "width": 12, + }, + }, + ] + self.inward_supply_columns = [ + { + "label": "Bill No", + "fieldname": "inward_supply_bill_no", + "compare_with": "bill_no", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "Bill Date", + "fieldname": "inward_supply_bill_date", + "compare_with": "bill_date", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "GSTIN", + "fieldname": "inward_supply_supplier_gstin", + "compare_with": "supplier_gstin", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 15, + }, + }, + { + "label": "Place of Supply", + "fieldname": "inward_supply_place_of_supply", + "compare_with": "place_of_supply", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "Reverse Charge", + "fieldname": "inward_supply_is_reverse_charge", + "compare_with": "is_reverse_charge", + "data_format": { + "horizontal": "left", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "Taxable Value", + "fieldname": "inward_supply_taxable_value", + "compare_with": "taxable_value", + "fieldtype": "Float", + "data_format": { + "number_format": "0.00", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "CGST", + "fieldname": "inward_supply_cgst", + "compare_with": "cgst", + "fieldtype": "Float", + "data_format": { + "number_format": "0.00", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "SGST", + "fieldname": "inward_supply_sgst", + "compare_with": "sgst", + "fieldtype": "Float", + "data_format": { + "number_format": "0.00", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "IGST", + "fieldname": "inward_supply_igst", + "compare_with": "igst", + "fieldtype": "Float", + "data_format": { + "number_format": "0.00", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + { + "label": "CESS", + "fieldname": "inward_supply_cess", + "compare_with": "cess", + "fieldtype": "Float", + "data_format": { + "number_format": "0.00", + "bg_color": self.COLOR_PALLATE.light_blue, + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.sky_blue, + "width": 12, + }, + }, + ] + inv_columns = [ + { + "label": "Action Status", + "fieldname": "action", + "data_format": {"horizontal": "left"}, + }, + { + "label": "Match Status", + "fieldname": "match_status", + "data_format": {"horizontal": "left"}, + }, + { + "label": "Supplier Name", + "fieldname": "supplier_name", + "data_format": {"horizontal": "left"}, + }, + { + "label": "PAN", + "fieldname": "pan", + "data_format": {"horizontal": "center"}, + "header_format": { + "width": 15, + }, + }, + { + "label": "Classification", + "fieldname": "classification", + "data_format": {"horizontal": "left"}, + "header_format": { + "width": 11, + }, + }, + { + "label": "Taxable Value Difference", + "fieldname": "taxable_value_difference", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_pink, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.dark_pink, + "width": 12, + }, + }, + { + "label": "Tax Difference", + "fieldname": "tax_difference", + "fieldtype": "Float", + "data_format": { + "bg_color": self.COLOR_PALLATE.light_pink, + "number_format": "0.00", + }, + "header_format": { + "bg_color": self.COLOR_PALLATE.dark_pink, + "width": 12, + }, + }, + ] + inv_columns.extend(self.inward_supply_columns) + inv_columns.extend(self.pr_columns) + return inv_columns diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/test_purchase_reconciliation_tool.py b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/test_purchase_reconciliation_tool.py new file mode 100644 index 000000000..a9a7a7d98 --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/test_purchase_reconciliation_tool.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022, Resilient Tech and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestPurchaseReconciliationTool(FrappeTestCase): + pass diff --git a/india_compliance/gst_india/number_card/number_card.py b/india_compliance/gst_india/number_card/number_card.py index 40d9c4520..70e848b6f 100644 --- a/india_compliance/gst_india/number_card/number_card.py +++ b/india_compliance/gst_india/number_card/number_card.py @@ -23,7 +23,7 @@ def get_active_e_invoice_count_for_cancelled_invoices(filters=None): return 0 default_filters = get_default_filters(filters) - default_filters["exceptions"] = "Invoice Cancelled but e-Invoice Active" + default_filters["exceptions"] = "Invoice Cancelled but not e-Invoice" return get_e_invoice_summary_count(default_filters) diff --git a/india_compliance/gst_india/overrides/gl_entry.py b/india_compliance/gst_india/overrides/gl_entry.py index bfcf3650b..c6a0d5a03 100644 --- a/india_compliance/gst_india/overrides/gl_entry.py +++ b/india_compliance/gst_india/overrides/gl_entry.py @@ -16,7 +16,10 @@ def validate(doc, method=None): return frappe.throw( - _("Company GSTIN is a mandatory field for accounting of GST Accounts.") + _( + "Company GSTIN is a mandatory field for accounting of GST Accounts." + " Run `Update GSTIN` patch from GST Balance Report to update GSTIN in all transactions." + ) ) diff --git a/india_compliance/gst_india/overrides/payment_entry.py b/india_compliance/gst_india/overrides/payment_entry.py index 7f50cb8c1..220924167 100644 --- a/india_compliance/gst_india/overrides/payment_entry.py +++ b/india_compliance/gst_india/overrides/payment_entry.py @@ -1,8 +1,63 @@ import frappe -from frappe.utils import cstr +from frappe import _ +from frappe.contacts.doctype.address.address import get_default_address +from frappe.query_builder.functions import Sum +from frappe.utils import cstr, flt, getdate +from erpnext.accounts.general_ledger import make_gl_entries +from erpnext.accounts.utils import create_payment_ledger_entry +from erpnext.controllers.accounts_controller import get_advance_payment_entries +from india_compliance.gst_india.overrides.transaction import get_gst_details +from india_compliance.gst_india.overrides.transaction import ( + validate_transaction as validate_transaction_for_advance_payment, +) +from india_compliance.gst_india.utils import get_all_gst_accounts -def update_place_of_supply(doc, method): + +def validate(doc, method=None): + if not doc.taxes: + return + + if doc.party_type == "Customer": + # Presume is export with GST if GST accounts are present + doc.is_export_with_gst = 1 + validate_transaction_for_advance_payment(doc, method) + + else: + gst_accounts = get_all_gst_accounts(doc.company) + for row in doc.taxes: + if row.account_head in gst_accounts and row.tax_amount != 0: + frappe.throw( + _("GST Taxes are not allowed for Supplier Advance Payment Entry") + ) + + +def on_submit(doc, method=None): + make_gst_revesal_entry_from_advance_payment(doc) + + +def on_update_after_submit(doc, method=None): + make_gst_revesal_entry_from_advance_payment(doc) + + +@frappe.whitelist() +def update_party_details(party_details, doctype, company): + if isinstance(party_details, str): + party_details = frappe.parse_json(party_details) + + address = get_default_address("Customer", party_details.get("customer")) + party_details.update(customer_address=address) + + # Update address for update + response = { + "customer_address": address, # should be set first as gst_category and gstin is fetched from address + **get_gst_details(party_details, doctype, company, update_place_of_supply=True), + } + + return response + + +def update_place_of_supply(doc): country = frappe.get_cached_value("Company", doc.company, "country") if country != "India": return @@ -17,3 +72,246 @@ def update_place_of_supply(doc, method): doc.place_of_supply = ( cstr(address.gst_state_number) + "-" + cstr(address.gst_state) ) + + +def make_gst_revesal_entry_from_advance_payment(doc): + """ + This functionality aims to create a GST reversal entry where GST was paid in advance + + On Submit: Creates GLEs and PLEs for all references. + On Update after Submit: Creates GLEs for new references. Creates PLEs for all references. + """ + gl_dict = [] + + if not doc.taxes: + return + + for row in doc.get("references"): + gl_dict.extend(get_gl_for_advance_gst_reversal(doc, row)) + + if not gl_dict: + return + + # Creates GLEs and PLEs + make_gl_entries(gl_dict) + + +def get_gl_for_advance_gst_reversal(payment_entry, reference_row): + gl_dicts = [] + voucher_date = frappe.db.get_value( + reference_row.reference_doctype, reference_row.reference_name, "posting_date" + ) + posting_date = ( + payment_entry.posting_date + if getdate(payment_entry.posting_date) > getdate(voucher_date) + else voucher_date + ) + + taxes = get_proportionate_taxes_for_reversal(payment_entry, reference_row) + + if not taxes: + return gl_dicts + + total_amount = sum(taxes.values()) + + args = { + "posting_date": posting_date, + "voucher_detail_no": reference_row.name, + "remarks": f"Reversal for GST on Advance Payment Entry" + f" {payment_entry.name} against {reference_row.reference_doctype} {reference_row.reference_name}", + } + + # Reduce receivables + gl_entry = payment_entry.get_gl_dict( + { + "account": reference_row.account, + "credit": total_amount, + "credit_in_account_currency": total_amount, + "party_type": payment_entry.party_type, + "party": payment_entry.party, + "against_voucher_type": reference_row.reference_doctype, + "against_voucher": reference_row.reference_name, + **args, + }, + item=reference_row, + ) + + if frappe.db.exists("GL Entry", args): + # All existing PLE are delinked and new ones are created everytime on update + # refer: reconcile_against_document in utils.py + create_payment_ledger_entry( + [gl_entry], update_outstanding="No", cancel=0, adv_adj=1 + ) + + return gl_dicts + + gl_dicts.append(gl_entry) + + # Reverse taxes + for account, amount in taxes.items(): + gl_dicts.append( + payment_entry.get_gl_dict( + { + "account": account, + "debit": amount, + "debit_in_account_currency": amount, + "against_voucher_type": payment_entry.doctype, + "against_voucher": payment_entry.name, + **args, + }, + item=reference_row, + ) + ) + + return gl_dicts + + +def get_proportionate_taxes_for_reversal(payment_entry, reference_row): + """ + This function calculates proportionate taxes for reversal of GST paid in advance + """ + # Compile taxes + gst_accounts = get_all_gst_accounts(payment_entry.company) + taxes = {} + for row in payment_entry.taxes: + if row.account_head not in gst_accounts: + continue + + taxes.setdefault(row.account_head, 0) + taxes[row.account_head] += row.base_tax_amount + + if not taxes: + return + + # Ensure there is no rounding error + if ( + not payment_entry.unallocated_amount + and payment_entry.references[-1].idx == reference_row.idx + ): + return balance_taxes(payment_entry, reference_row, taxes) + + return get_proportionate_taxes_for_row(payment_entry, reference_row, taxes) + + +def get_proportionate_taxes_for_row(payment_entry, reference_row, taxes): + base_allocated_amount = payment_entry.calculate_base_allocated_amount_for_reference( + reference_row + ) + for account, amount in taxes.items(): + taxes[account] = flt( + amount * base_allocated_amount / payment_entry.base_paid_amount, 2 + ) + + return taxes + + +def balance_taxes(payment_entry, reference_row, taxes): + for account, amount in taxes.items(): + for allocation_row in payment_entry.references: + if allocation_row.reference_name == reference_row.reference_name: + continue + + taxes[account] = taxes[account] - flt( + amount + * payment_entry.calculate_base_allocated_amount_for_reference( + allocation_row + ) + / payment_entry.base_paid_amount, + 2, + ) + + return taxes + + +def get_advance_payment_entries_for_regional( + party_type, + party, + party_account, + order_doctype, + order_list=None, + include_unallocated=True, + against_all_orders=False, + limit=None, + condition=None, +): + """ + Get Advance Payment Entries with GST Taxes + """ + + payment_entries = get_advance_payment_entries( + party_type=party_type, + party=party, + party_account=party_account, + order_doctype=order_doctype, + order_list=order_list, + include_unallocated=include_unallocated, + against_all_orders=against_all_orders, + limit=limit, + condition=condition, + ) + + # if not Sales Invoice and is Payment Reconciliation + if not condition or not payment_entries: + return payment_entries + + company = frappe.db.get_value("Account", party_account, "company") + taxes = get_taxes_summary(company, payment_entries) + + for pe in payment_entries: + tax_row = taxes.get( + pe.reference_name, + frappe._dict(paid_amount=1, tax_amount=0, tax_amount_reversed=0), + ) + pe.amount += tax_row.tax_amount - tax_row.tax_amount_reversed + + return payment_entries + + +def adjust_allocations_for_taxes_in_payment_reconciliation(doc): + if not doc.allocation: + return + + taxes = get_taxes_summary(doc.company, doc.allocation) + taxes = { + tax.payment_entry: tax.paid_amount / (tax.paid_amount + tax.tax_amount) + for tax in taxes.values() + } + + for row in doc.allocation: + paid_proportion = taxes.get(row.reference_name, 1) + for field in ("amount", "allocated_amount", "unreconciled_amount"): + row.set(field, flt(row.get(field, 0) * paid_proportion, 2)) + + +def get_taxes_summary(company, payment_entries): + gst_accounts = get_all_gst_accounts(company) + references = [ + advance.reference_name + for advance in payment_entries + if advance.reference_type == "Payment Entry" + ] + + gl_entry = frappe.qb.DocType("GL Entry") + pe = frappe.qb.DocType("Payment Entry") + taxes = ( + frappe.qb.from_(gl_entry) + .join(pe) + .on(pe.name == gl_entry.voucher_no) + .select( + Sum(gl_entry.credit_in_account_currency).as_("tax_amount"), + Sum(gl_entry.debit_in_account_currency).as_("tax_amount_reversed"), + pe.name.as_("payment_entry"), + pe.paid_amount, + ) + .where(gl_entry.is_cancelled == 0) + .where(gl_entry.voucher_type == "Payment Entry") + .where(gl_entry.voucher_no.isin(references)) + .where(gl_entry.account.isin(gst_accounts)) + .where(gl_entry.company == company) + .groupby(gl_entry.voucher_no) + .run(as_dict=True) + ) + + taxes = {tax.payment_entry: tax for tax in taxes} + + return taxes diff --git a/india_compliance/gst_india/overrides/purchase_invoice.py b/india_compliance/gst_india/overrides/purchase_invoice.py index 069679fc4..a2aa5f3c6 100644 --- a/india_compliance/gst_india/overrides/purchase_invoice.py +++ b/india_compliance/gst_india/overrides/purchase_invoice.py @@ -45,9 +45,34 @@ def validate(doc, method=None): update_itc_totals(doc) validate_supplier_invoice_number(doc) + validate_with_inward_supply(doc) + set_reconciliation_status(doc) + + +def set_reconciliation_status(doc): + reconciliation_status = "Not Applicable" + + if is_b2b_invoice(doc): + reconciliation_status = "Unreconciled" + + doc.reconciliation_status = reconciliation_status + + +def is_b2b_invoice(doc): + return not ( + doc.supplier_gstin in ["", None] + or doc.gst_category in ["Registered Composition", "Unregistered", "Overseas"] + or doc.supplier_gstin == doc.company_gstin + or doc.is_opening == "Yes" + or any(row for row in doc.items if row.is_non_gst == 1) + ) def update_itc_totals(doc, method=None): + # Set default value + if not doc.eligibility_for_itc: + doc.eligibility_for_itc = "All Other ITC" + # Initialize values doc.itc_integrated_tax = 0 doc.itc_state_tax = 0 @@ -99,7 +124,79 @@ def get_dashboard_data(data): reference_section["items"].append("Bill of Entry") update_dashboard_with_gst_logs( - "Purchase Invoice", data, "e-Waybill Log", "Integration Request" + "Purchase Invoice", + data, + "e-Waybill Log", + "Integration Request", + "GST Inward Supply", ) return data + + +def validate_with_inward_supply(doc): + if not doc.get("_inward_supply"): + return + + mismatch_fields = {} + for field in [ + "company", + "company_gstin", + "supplier_gstin", + "bill_no", + "bill_date", + "is_reverse_charge", + "place_of_supply", + ]: + if doc.get(field) != doc._inward_supply.get(field): + mismatch_fields[field] = doc._inward_supply.get(field) + + # mismatch for taxable_value + taxable_value = sum([item.taxable_value for item in doc.items]) + if taxable_value != doc._inward_supply.get("taxable_value"): + mismatch_fields["Taxable Value"] = doc._inward_supply.get("taxable_value") + + # mismatch for taxes + gst_accounts = get_gst_accounts_by_type(doc.company, "Input") + for tax in ["cgst", "sgst", "igst", "cess"]: + tax_amount = get_tax_amount(doc.taxes, gst_accounts[tax + "_account"]) + if tax == "cess": + tax_amount += get_tax_amount(doc.taxes, gst_accounts.cess_non_advol_account) + + if tax_amount == doc._inward_supply.get(tax): + continue + + mismatch_fields[tax.upper()] = doc._inward_supply.get(tax) + + if mismatch_fields: + message = ( + "Purchase Invoice does not match with releted GST Inward Supply.
" + "Following values are not matching from 2A/2B:
" + ) + for field, value in mismatch_fields.items(): + message += f"
{field}: {value}" + + frappe.msgprint( + _(message), + title=_("Mismatch with GST Inward Supply"), + ) + + elif doc._action == "submit": + frappe.msgprint( + _("Invoice matched with GST Inward Supply"), + alert=True, + indicator="green", + ) + + +def get_tax_amount(taxes, account_head): + if not (taxes or account_head): + return 0 + + return sum( + [ + tax.base_tax_amount_after_discount_amount + for tax in taxes + if tax.account_head == account_head + ] + ) diff --git a/india_compliance/gst_india/overrides/sales_invoice.py b/india_compliance/gst_india/overrides/sales_invoice.py index 59cd74538..9d3071608 100644 --- a/india_compliance/gst_india/overrides/sales_invoice.py +++ b/india_compliance/gst_india/overrides/sales_invoice.py @@ -1,8 +1,11 @@ import frappe -from frappe import _ +from frappe import _, bold +from frappe.utils import flt, fmt_money from india_compliance.gst_india.constants import GST_INVOICE_NUMBER_FORMAT +from india_compliance.gst_india.overrides.payment_entry import get_taxes_summary from india_compliance.gst_india.overrides.transaction import ( + _validate_hsn_codes, ignore_gst_validations, validate_mandatory_fields, validate_transaction, @@ -56,6 +59,7 @@ def validate(doc, method=None): validate_fields_and_set_status_for_e_invoice(doc, gst_settings) validate_unique_hsn_and_uom(doc) validate_port_address(doc) + set_and_validate_advances_with_gst(doc) set_e_waybill_status(doc, gst_settings) @@ -101,6 +105,8 @@ def validate_fields_and_set_status_for_e_invoice(doc, gst_settings): _("{0} is a mandatory field for generating e-Invoices"), ) + validate_hsn_codes_for_e_invoice(doc) + if is_foreign_doc(doc): country = frappe.db.get_value("Address", doc.customer_address, "country") get_validated_country_code(country) @@ -109,6 +115,14 @@ def validate_fields_and_set_status_for_e_invoice(doc, gst_settings): doc.einvoice_status = "Pending" +def validate_hsn_codes_for_e_invoice(doc): + _validate_hsn_codes( + doc, + valid_hsn_length=[6, 8], + message=_("Since HSN/SAC Code is mandatory for generating e-Invoices.
"), + ) + + def validate_port_address(doc): if ( doc.gst_category != "Overseas" @@ -214,6 +228,7 @@ def update_dashboard_with_gst_logs(doctype, data, *log_doctypes): { "e-Waybill Log": "reference_name", "Integration Request": "reference_docname", + "GST Inward Supply": "link_name", } ) @@ -244,3 +259,46 @@ def set_e_waybill_status(doc, gst_settings=None): e_waybill_status = "Manually Generated" doc.update({"e_waybill_status": e_waybill_status}) + + +def set_and_validate_advances_with_gst(doc): + if not doc.advances: + return + + taxes = get_taxes_summary(doc.company, doc.advances) + + allocated_amount_with_taxes = 0 + tax_amount = 0 + + for advance in doc.get("advances"): + if not advance.allocated_amount: + continue + + tax_row = taxes.get( + advance.reference_name, frappe._dict(paid_amount=1, tax_amount=0) + ) + + _tax_amount = flt( + advance.allocated_amount / tax_row.paid_amount * tax_row.tax_amount, 2 + ) + tax_amount += _tax_amount + allocated_amount_with_taxes += _tax_amount + allocated_amount_with_taxes += advance.allocated_amount + + excess_allocation = flt( + flt(allocated_amount_with_taxes, 2) - (doc.rounded_total or doc.grand_total), 2 + ) + if excess_allocation > 0: + message = _( + "Allocated amount with taxes (GST) in advances table cannot be greater than" + " outstanding amount of the document. Allocated amount with taxes is greater by {0}." + ).format(bold(fmt_money(excess_allocation, currency=doc.currency))) + + if excess_allocation < 1: + message += "

Is it becasue of Rounding Adjustment? Try disabling Rounded Total in the document." + + frappe.throw(message, title=_("Invalid Allocated Amount")) + + doc.total_advance = allocated_amount_with_taxes + doc.set_payment_schedule() + doc.outstanding_amount -= tax_amount diff --git a/india_compliance/gst_india/overrides/transaction.py b/india_compliance/gst_india/overrides/transaction.py index 0bc36de9d..15b313d86 100644 --- a/india_compliance/gst_india/overrides/transaction.py +++ b/india_compliance/gst_india/overrides/transaction.py @@ -364,7 +364,7 @@ def validate_tax_accounts_for_non_gst(doc): def validate_items(doc): """Validate Items for a GST Compliant Invoice""" - if not doc.items: + if not doc.get("items"): return item_tax_templates = frappe._dict() @@ -460,7 +460,7 @@ def get_source_state_code(doc): Logic opposite to that of utils.get_place_of_supply """ - if doc.doctype in SALES_DOCTYPES: + if doc.doctype in SALES_DOCTYPES or doc.doctype == "Payment Entry": return doc.company_gstin[:2] if doc.gst_category == "Overseas": @@ -482,6 +482,10 @@ def validate_hsn_codes(doc, method=None): if not validate_hsn_code: return + return _validate_hsn_codes(doc, valid_hsn_length, message=None) + + +def _validate_hsn_codes(doc, valid_hsn_length, message=None): rows_with_missing_hsn = [] rows_with_invalid_hsn = [] @@ -501,26 +505,29 @@ def validate_hsn_codes(doc, method=None): frappe.throw( _( + "{0}" "Please enter a valid HSN/SAC code for the following row numbers:" - "
{0}" - ).format(frappe.bold(", ".join(rows_with_invalid_hsn))), + "
{1}" + ).format(message or "", frappe.bold(", ".join(rows_with_invalid_hsn))), title=_("Invalid HSN/SAC"), ) if rows_with_missing_hsn: frappe.msgprint( _( - "Please enter HSN/SAC code for the following row numbers:
{0}" - ).format(frappe.bold(", ".join(rows_with_missing_hsn))), + "{0}" "Please enter HSN/SAC code for the following row numbers:
{1}" + ).format(message or "", frappe.bold(", ".join(rows_with_missing_hsn))), title=_("Invalid HSN/SAC"), ) if rows_with_invalid_hsn: frappe.msgprint( _( - "HSN/SAC code should be {0} digits long for the following" - " row numbers:
{1}" + "{0}" + "HSN/SAC code should be {1} digits long for the following" + " row numbers:
{2}" ).format( + message or "", join_list_with_custom_separators(valid_hsn_length), frappe.bold(", ".join(rows_with_invalid_hsn)), ), @@ -650,7 +657,7 @@ def get_gst_details(party_details, doctype, company, *, update_place_of_supply=F - taxes in the tax template """ - is_sales_transaction = doctype in SALES_DOCTYPES + is_sales_transaction = doctype in SALES_DOCTYPES or doctype == "Payment Entry" if isinstance(party_details, str): party_details = frappe.parse_json(party_details) @@ -694,6 +701,9 @@ def get_gst_details(party_details, doctype, company, *, update_place_of_supply=F party_details.update(is_reverse_charge) gst_details.update(is_reverse_charge) + if doctype == "Payment Entry": + return gst_details + if ( (destination_gstin and destination_gstin == source_gstin) # Internal transfer or ( @@ -952,6 +962,9 @@ def validate_transaction(doc, method=None): if is_sales_transaction := doc.doctype in SALES_DOCTYPES: validate_hsn_codes(doc) gstin = doc.billing_address_gstin + elif doc.doctype == "Payment Entry": + is_sales_transaction = True + gstin = doc.billing_address_gstin else: validate_reverse_charge_transaction(doc) gstin = doc.supplier_gstin diff --git a/india_compliance/gst_india/page/india_compliance_account/india_compliance_account.js b/india_compliance/gst_india/page/india_compliance_account/india_compliance_account.js index 58b037be5..20d537968 100644 --- a/india_compliance/gst_india/page/india_compliance_account/india_compliance_account.js +++ b/india_compliance/gst_india/page/india_compliance_account/india_compliance_account.js @@ -4,5 +4,5 @@ frappe.pages["india-compliance-account"].on_page_load = async function (wrapper) "india_compliance_account.bundle.css", ]); - new india_compliance.pages.IndiaComplianceAccountPage(wrapper); + icAccountPage = new india_compliance.pages.IndiaComplianceAccountPage(wrapper, PAGE_NAME); }; diff --git a/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.js b/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.js index 373dc6c28..46753907b 100644 --- a/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.js +++ b/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.js @@ -53,16 +53,16 @@ frappe.query_reports["e-Invoice Summary"] = { formatter: function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (column.fieldname == "einvoice_status" && value) { - if (value == "Pending") - value = `${value}`; - else if (value == "Generated") - value = `${value}`; - else if (value == "Cancelled") - value = `${value}`; - else if (value == "Failed") - value = `${value}`; - } + if (value == "Pending") + value = `${value}`; + else if (["Generated", "Submitted"].includes(value)) + value = `${value}`; + else if (value == "Cancelled") + value = `${value}`; + else if (value == "Failed") + value = `${value}`; + else if (value == "Not Applicable") + value = `${value}`; return value; }, diff --git a/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py b/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py index 7482c0d54..1d260843b 100644 --- a/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py +++ b/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py @@ -6,7 +6,7 @@ import frappe from frappe import _ from frappe.query_builder import Case -from frappe.query_builder.functions import Coalesce +from frappe.query_builder.functions import Coalesce, IfNull from frappe.utils.data import get_datetime from india_compliance.gst_india.utils.e_invoice import get_e_invoice_applicability_date @@ -24,8 +24,11 @@ def execute(filters=None): def get_data_for_all_companies(filters): data = [] - indian_companies = [filters.get("company")] - if not indian_companies: + indian_companies = [] + + if filters.get("company"): + indian_companies.append(filters.get("company")) + else: indian_companies = frappe.get_all( "Company", filters={"country": "India"}, pluck="name" ) @@ -47,7 +50,7 @@ def validate_filters(filters=None): if not settings.enable_e_invoice: frappe.throw( - _("e-Invoice is not enabled for {}").format(filters.company), + _("e-Invoice is not enabled for your company."), title=_("Invalid Filter"), ) @@ -93,7 +96,7 @@ def get_data(filters=None): if not settings.enable_e_invoice or not e_invoice_applicability_date: return [] - conditions = e_invoice_conditions(filters, e_invoice_applicability_date) + conditions = e_invoice_conditions(e_invoice_applicability_date) query = ( frappe.qb.from_(sales_invoice) @@ -110,6 +113,10 @@ def get_data(filters=None): sales_invoice.company, e_invoice_log.acknowledgement_number, e_invoice_log.acknowledged_on, + Case() + .when(sales_invoice.docstatus == 1, "Submitted") + .else_("Cancelled") + .as_("docstatus"), ) .where( sales_invoice.posting_date[ @@ -126,37 +133,49 @@ def get_data(filters=None): if filters.get("customer"): query = query.where(sales_invoice.customer == filters.get("customer")) - if filters.get("exceptions") == "e-Invoice Not Generated": - query = query.where(((sales_invoice.irn == "") | (sales_invoice.irn.isnull()))) - - if filters.get("exceptions") != "Invoice Cancelled but not e-Invoice": - query = query.where(sales_invoice.docstatus == 1) + if not filters.get("exceptions"): + data = query.where(sales_invoice.docstatus == 1).run(as_dict=True) + cancelled_active_e_invoices = get_cancelled_active_e_invoice_query( + filters, sales_invoice, query + ).run(as_dict=True) - else: - # invoice is cancelled but irn is not cancelled - query = query.where(sales_invoice.docstatus == 2).where( - (sales_invoice.irn != "") & (sales_invoice.irn.notnull()) - ) + return sorted(data + cancelled_active_e_invoices, key=lambda x: x.posting_date) - valid_irns = frappe.get_all( - "Sales Invoice", - pluck="irn", - filters={ - "docstatus": 1, - "company": filters.get("company"), - # logical optimization - "posting_date": [">=", filters.get("from_date")], - "irn": ["is", "set"], - }, + if filters.get("exceptions") == "e-Invoice Not Generated": + query = query.where( + ((IfNull(sales_invoice.irn, "") == "") & (sales_invoice.docstatus == 1)) ) - if valid_irns: - query = query.where(sales_invoice.irn.notin(valid_irns)) + if filters.get("exceptions") == "Invoice Cancelled but not e-Invoice": + # invoice is cancelled but irn is not cancelled + query = get_cancelled_active_e_invoice_query(filters, sales_invoice, query) return query.run(as_dict=True) -def e_invoice_conditions(filters, e_invoice_applicability_date): +def get_cancelled_active_e_invoice_query(filters, sales_invoice, query): + query = query.where( + (sales_invoice.docstatus == 2) & (IfNull(sales_invoice.irn, "") != "") + ) + + valid_irns = frappe.get_all( + "Sales Invoice", + pluck="irn", + filters={ + "docstatus": 1, + "company": filters.get("company"), + # logical optimization + "posting_date": [">=", filters.get("from_date")], + "irn": ["is", "set"], + }, + ) + + if valid_irns: + query = query.where(sales_invoice.irn.notin(valid_irns)) + return query + + +def e_invoice_conditions(e_invoice_applicability_date): sales_invoice = frappe.qb.DocType("Sales Invoice") sub_query = validate_sales_invoice_item() conditions = [] @@ -202,13 +221,13 @@ def get_columns(filters=None): "fieldname": "sales_invoice", "label": _("Sales Invoice"), "options": "Sales Invoice", - "width": 140, + "width": 130, }, { "fieldtype": "Data", "fieldname": "einvoice_status", "label": _("e-Invoice Status"), - "width": 100, + "width": 90, }, { "fieldtype": "Link", @@ -225,14 +244,14 @@ def get_columns(filters=None): { "fieldtype": "Data", "fieldname": "acknowledgement_number", - "label": "Acknowledgement Number", - "width": 145, + "label": _("Acknowledgement Number"), + "width": 110, }, { - "fieldtype": "Data", + "fieldtype": "Datetime", "fieldname": "acknowledged_on", - "label": "Acknowledged On (IST)", - "width": 165, + "label": _("Acknowledged On (IST)"), + "width": 110, }, {"fieldtype": "Data", "fieldname": "irn", "label": _("IRN No."), "width": 250}, { @@ -242,6 +261,12 @@ def get_columns(filters=None): "label": _("Grand Total"), "width": 120, }, + { + "fieldtype": "Data", + "fieldname": "docstatus", + "label": _("Document Status"), + "width": 100, + }, ] if not filters.get("company"): diff --git a/india_compliance/gst_india/report/gst_advance_detail/__init__.py b/india_compliance/gst_india/report/gst_advance_detail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.js b/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.js new file mode 100644 index 000000000..235f499a0 --- /dev/null +++ b/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.js @@ -0,0 +1,70 @@ +// Copyright (c) 2023, Resilient Tech and contributors +// For license information, please see license.txt + +frappe.query_reports["GST Advance Detail"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + get_query: function () { + return { + filters: { + country: "India", + }, + }; + }, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + depends_on: "eval:doc.show_for_period", + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.get_today(), + }, + { + fieldname: "customer", + label: __("Customer"), + fieldtype: "Link", + options: "Customer", + }, + { + fieldname: "account", + label: __("Account"), + fieldtype: "Link", + options: "Account", + get_query: function () { + var company = frappe.query_report.get_filter_value("company"); + return { + filters: { + company: company, + account_type: "Receivable", + is_group: 0, + }, + }; + }, + }, + { + fieldname: "show_for_period", + label: __("Show For Period"), + fieldtype: "Check", + default: 0, + }, + { + fieldname: "show_summary", + label: __("Show Summary"), + fieldtype: "Check", + default: 0, + } + ], +}; diff --git a/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.json b/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.json new file mode 100644 index 000000000..4e9495b78 --- /dev/null +++ b/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.json @@ -0,0 +1,30 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2023-09-17 16:44:05.262440", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "letterhead": null, + "modified": "2023-09-17 16:44:32.575646", + "modified_by": "Administrator", + "module": "GST India", + "name": "GST Advance Detail", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Payment Entry", + "report_name": "GST Advance Detail", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + } + ] +} \ No newline at end of file diff --git a/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.py b/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.py new file mode 100644 index 000000000..2bccc0eb1 --- /dev/null +++ b/india_compliance/gst_india/report/gst_advance_detail/gst_advance_detail.py @@ -0,0 +1,238 @@ +# Copyright (c) 2023, Resilient Tech and contributors +# For license information, please see license.txt + +from pypika.terms import Case + +import frappe +from frappe import _ +from frappe.query_builder import Criterion +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.functions import IfNull, Sum +from frappe.utils import flt, getdate + +from india_compliance.gst_india.utils import get_gst_accounts_by_type + + +def execute(filters=None): + report = GSTAdvanceDetail(filters) + return report.get_columns(), report.get_data() + + +class GSTAdvanceDetail: + def __init__(self, filters): + self.filters = filters + + self.pe = frappe.qb.DocType("Payment Entry") + self.pe_ref = frappe.qb.DocType("Payment Entry Reference") + self.gl_entry = frappe.qb.DocType("GL Entry") + self.gst_accounts = get_gst_accounts(filters) + + def get_columns(self): + columns = [ + { + "fieldname": "posting_date", + "label": _("Posting Date"), + "fieldtype": "Date", + "width": 120, + }, + { + "fieldname": "payment_entry", + "label": _("Payment Entry"), + "fieldtype": "Link", + "options": "Payment Entry", + "width": 180, + }, + { + "fieldname": "customer", + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 150, + }, + { + "fieldname": "customer_name", + "label": _("Customer Name"), + "fieldtype": "Data", + "width": 150, + }, + { + "fieldname": "paid_amount", + "label": _("Paid Amount"), + "fieldtype": "Currency", + "width": 120, + }, + { + "fieldname": "allocated_amount", + "label": _("Allocated Amount"), + "fieldtype": "Currency", + "width": 120, + }, + { + "fieldname": "gst_paid", + "label": _("GST Paid"), + "fieldtype": "Currency", + "width": 120, + }, + { + "fieldname": "gst_allocated", + "label": _("GST Allocated"), + "fieldtype": "Currency", + "width": 120, + }, + { + "fieldname": "against_voucher_type", + "label": _("Against Voucher Type"), + "fieldtype": "Link", + "options": "DocType", + "width": 150, + "hidden": 1, + }, + { + "fieldname": "against_voucher", + "label": _("Against Voucher"), + "fieldtype": "Dynamic Link", + "options": "against_voucher_type", + "width": 150, + }, + { + "fieldname": "place_of_supply", + "label": _("Place of Supply"), + "fieldtype": "Data", + "width": 150, + }, + ] + + if not self.filters.get("show_summary"): + return columns + + for col in columns.copy(): + if col.get("fieldname") in ["posting_date", "against_voucher"]: + columns.remove(col) + + return columns + + def get_data(self): + paid_entries = self.get_paid_entries() + allocated_entries = self.get_allocated_entries() + + data = paid_entries + allocated_entries + + # sort by payment_entry + data = sorted(data, key=lambda k: (k["payment_entry"]), reverse=True) + + if not self.filters.get("show_summary"): + return data + + return self.get_summary_data(data) + + def get_summary_data(self, data): + amount_fields = { + "paid_amount": 0, + "gst_paid": 0, + "allocated_amount": 0, + "gst_allocated": 0, + } + + summary_data = {} + for row in data: + new_row = summary_data.setdefault( + row["payment_entry"], {**row, **amount_fields} + ) + + for key in amount_fields: + new_row[key] += flt(row[key]) + + return list(summary_data.values()) + + def get_paid_entries(self): + return ( + self.get_query() + .select( + ConstantColumn(0).as_("allocated_amount"), + ConstantColumn("").as_("against_voucher_type"), + ConstantColumn("").as_("against_voucher"), + ) + .where(self.gl_entry.credit_in_account_currency > 0) + .groupby(self.gl_entry.voucher_no) + .run(as_dict=True) + ) + + def get_allocated_entries(self): + query = ( + self.get_query() + .join(self.pe_ref) + .on(self.pe_ref.name == self.gl_entry.voucher_detail_no) + .select( + self.pe_ref.allocated_amount, + self.pe_ref.reference_doctype.as_("against_voucher_type"), + self.pe_ref.reference_name.as_("against_voucher"), + ) + .where(self.gl_entry.debit_in_account_currency > 0) + ) + + if self.filters.get("show_summary"): + query = query.groupby(self.gl_entry.voucher_no) + + else: + query = query.groupby(self.gl_entry.voucher_detail_no) + + return query.run(as_dict=True) + + def get_query(self): + return ( + frappe.qb.from_(self.gl_entry) + .join(self.pe) + .on(self.pe.name == self.gl_entry.voucher_no) + .select( + self.gl_entry.voucher_no, + self.gl_entry.posting_date, + self.pe.name.as_("payment_entry"), + self.pe.party.as_("customer"), + self.pe.party_name.as_("customer_name"), + Case() + .when(self.gl_entry.credit_in_account_currency > 0, self.pe.paid_amount) + .else_(0) + .as_("paid_amount"), + Sum(self.gl_entry.credit_in_account_currency).as_("gst_paid"), + Sum(self.gl_entry.debit_in_account_currency).as_("gst_allocated"), + self.pe.place_of_supply, + ) + .where(Criterion.all(self.get_conditions())) + ) + + def get_conditions(self): + conditions = [] + + conditions.append(self.gl_entry.is_cancelled == 0) + conditions.append(self.gl_entry.voucher_type == "Payment Entry") + conditions.append(self.gl_entry.company == self.filters.get("company")) + conditions.append(self.gl_entry.account.isin(self.gst_accounts)) + + if self.filters.get("customer"): + conditions.append(self.gl_entry.party == self.filters.get("customer")) + + if self.filters.get("account"): + conditions.append(self.pe.paid_from == self.filters.get("account")) + + if self.filters.get("show_for_period") and self.filters.get("from_date"): + conditions.append( + self.gl_entry.posting_date >= getdate(self.filters.get("from_date")) + ) + else: + conditions.append(IfNull(self.pe.unallocated_amount, 0) > 0) + + if self.filters.get("to_date"): + conditions.append( + self.gl_entry.posting_date <= getdate(self.filters.get("to_date")) + ) + + return conditions + + +def get_gst_accounts(filters): + gst_accounts = get_gst_accounts_by_type(filters.get("company"), "Output") + + if not gst_accounts: + return [] + + return [account_head for account_head in gst_accounts.values() if account_head] diff --git a/india_compliance/gst_india/report/gst_balance/gst_balance.py b/india_compliance/gst_india/report/gst_balance/gst_balance.py index 15ff3f024..5d839d8b3 100644 --- a/india_compliance/gst_india/report/gst_balance/gst_balance.py +++ b/india_compliance/gst_india/report/gst_balance/gst_balance.py @@ -179,15 +179,19 @@ def _process_opening_balance(account, is_debit=True): "opening_credit": _process_opening_balance(account, is_debit=False), "debit": transaction.get("debit", 0), "credit": transaction.get("credit", 0), + "closing_debit": 0, + "closing_credit": 0, } ) - data[account]["closing_debit"] = ( + closing_balance = ( data[account]["opening_debit"] + data[account]["debit"] - ) - data[account]["closing_credit"] = ( - data[account]["opening_credit"] + data[account]["credit"] - ) + ) - (data[account]["opening_credit"] + data[account]["credit"]) + + if closing_balance > 0: + data[account]["closing_debit"] = closing_balance + else: + data[account]["closing_credit"] = abs(closing_balance) return list(data.values()) diff --git a/india_compliance/gst_india/report/gstr_1/gstr_1.js b/india_compliance/gst_india/report/gstr_1/gstr_1.js index 95365b654..fd5bbde07 100644 --- a/india_compliance/gst_india/report/gstr_1/gstr_1.js +++ b/india_compliance/gst_india/report/gstr_1/gstr_1.js @@ -63,6 +63,7 @@ frappe.query_reports["GSTR-1"] = { { "value": "CDNR-UNREG", "label": __("Credit/Debit Notes (Unregistered) - 9B") }, { "value": "EXPORT", "label": __("Export Invoice - 6A") }, { "value": "Advances", "label": __("Tax Liability (Advances Received) - 11A(1), 11A(2)") }, + { "value": "Adjustment", "label": __("Adjustment of Advances - 11B(1), 11B(2)") }, { "value": "NIL Rated", "label": __("NIL RATED/EXEMPTED Invoices") } ], "default": "B2B" diff --git a/india_compliance/gst_india/report/gstr_1/gstr_1.py b/india_compliance/gst_india/report/gstr_1/gstr_1.py index 5d8733ae6..95b7e6727 100644 --- a/india_compliance/gst_india/report/gstr_1/gstr_1.py +++ b/india_compliance/gst_india/report/gstr_1/gstr_1.py @@ -1,12 +1,14 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - - import json from datetime import date +from pypika.terms import Case + import frappe from frappe import _ +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Sum from frappe.utils import flt, formatdate, getdate from india_compliance.gst_india.utils import ( @@ -73,8 +75,8 @@ def run(self): def get_data(self): if self.filters.get("type_of_business") in ("B2C Small", "B2C Large"): self.get_b2c_data() - elif self.filters.get("type_of_business") == "Advances": - self.get_advance_data() + elif self.filters.get("type_of_business") in ("Advances", "Adjustment"): + self.get_11A_11B_data() elif self.filters.get("type_of_business") == "NIL Rated": self.get_nil_rated_invoices() elif self.invoices: @@ -105,28 +107,6 @@ def get_data(self): if taxable_value: self.data.append(row) - def get_advance_data(self): - advances_data = {} - advances = self.get_advance_entries() - for entry in advances: - # only consider IGST and SGST so as to avoid duplication of taxable amount - if ( - entry.account_head == self.gst_accounts.igst_account - or entry.account_head == self.gst_accounts.sgst_account - ): - advances_data.setdefault( - (entry.place_of_supply, entry.rate), [0.0, 0.0] - ) - advances_data[(entry.place_of_supply, entry.rate)][0] += ( - entry.amount * 100 / entry.rate - ) - elif entry.account_head == self.gst_accounts.cess_account: - advances_data[(entry.place_of_supply, entry.rate)][1] += entry.amount - - for key, value in advances_data.items(): - row = [key[0], key[1], value[0], value[1]] - self.data.append(row) - def get_nil_rated_invoices(self): nil_exempt_output = [ { @@ -327,19 +307,13 @@ def get_invoice_data(self): d.is_reverse_charge = "Y" if d.is_reverse_charge else "N" self.invoices.setdefault(d.invoice_number, d) - def get_advance_entries(self): - return frappe.db.sql( - """ - SELECT SUM(a.base_tax_amount) as amount, a.account_head, a.rate, p.place_of_supply - FROM `tabPayment Entry` p, `tabAdvance Taxes and Charges` a - WHERE p.docstatus = 1 - AND p.name = a.parent - AND posting_date between %s and %s - GROUP BY a.account_head, p.place_of_supply, a.rate - """, - (self.filters.get("from_date"), self.filters.get("to_date")), - as_dict=1, - ) + def get_11A_11B_data(self): + report = GSTR11A11BData(self.filters, self.gst_accounts) + data = report.get_data() + + for key, value in data.items(): + row = [key[0], key[1], value[0], value[1]] + self.data.append(row) def get_conditions(self): conditions = "" @@ -526,12 +500,17 @@ def get_columns(self): if self.filters.get("type_of_business") != "NIL Rated": self.tax_columns = [ - {"fieldname": "rate", "label": "Rate", "fieldtype": "Int", "width": 60}, + { + "fieldname": "rate", + "label": _("Rate"), + "fieldtype": "Int", + "width": 60, + }, { "fieldname": "taxable_value", - "label": "Taxable Value", + "label": _("Taxable Value"), "fieldtype": "Currency", - "width": 100, + "width": 150, }, ] @@ -539,54 +518,54 @@ def get_columns(self): self.invoice_columns = [ { "fieldname": "billing_address_gstin", - "label": "GSTIN/UIN of Recipient", + "label": _("GSTIN/UIN of Recipient"), "fieldtype": "Data", "width": 150, }, { "fieldname": "customer_name", - "label": "Receiver Name", + "label": _("Receiver Name"), "fieldtype": "Data", "width": 100, }, { "fieldname": "invoice_number", - "label": "Invoice Number", + "label": _("Invoice Number"), "fieldtype": "Link", "options": "Sales Invoice", "width": 100, }, { "fieldname": "posting_date", - "label": "Invoice date", + "label": _("Invoice date"), "fieldtype": "Data", "width": 80, }, { "fieldname": "invoice_value", - "label": "Invoice Value", + "label": _("Invoice Value"), "fieldtype": "Currency", "width": 100, }, { "fieldname": "place_of_supply", - "label": "Place Of Supply", + "label": _("Place Of Supply"), "fieldtype": "Data", "width": 100, }, { "fieldname": "is_reverse_charge", - "label": "Reverse Charge", + "label": _("Reverse Charge"), "fieldtype": "Data", }, { "fieldname": "gst_category", - "label": "Invoice Type", + "label": _("Invoice Type"), "fieldtype": "Data", }, { "fieldname": "ecommerce_gstin", - "label": "E-Commerce GSTIN", + "label": _("E-Commerce GSTIN"), "fieldtype": "Data", "width": 120, }, @@ -594,7 +573,7 @@ def get_columns(self): self.other_columns = [ { "fieldname": "cess_amount", - "label": "Cess Amount", + "label": _("Cess Amount"), "fieldtype": "Currency", "width": 100, } @@ -604,32 +583,32 @@ def get_columns(self): self.invoice_columns = [ { "fieldname": "invoice_number", - "label": "Invoice Number", + "label": _("Invoice Number"), "fieldtype": "Link", "options": "Sales Invoice", "width": 120, }, { "fieldname": "posting_date", - "label": "Invoice date", + "label": _("Invoice date"), "fieldtype": "Data", "width": 100, }, { "fieldname": "invoice_value", - "label": "Invoice Value", + "label": _("Invoice Value"), "fieldtype": "Currency", "width": 100, }, { "fieldname": "place_of_supply", - "label": "Place Of Supply", + "label": _("Place Of Supply"), "fieldtype": "Data", "width": 120, }, { "fieldname": "ecommerce_gstin", - "label": "E-Commerce GSTIN", + "label": _("E-Commerce GSTIN"), "fieldtype": "Data", "width": 130, }, @@ -637,7 +616,7 @@ def get_columns(self): self.other_columns = [ { "fieldname": "cess_amount", - "label": "Cess Amount", + "label": _("Cess Amount"), "fieldtype": "Currency", "width": 100, } @@ -646,67 +625,67 @@ def get_columns(self): self.invoice_columns = [ { "fieldname": "billing_address_gstin", - "label": "GSTIN/UIN of Recipient", + "label": _("GSTIN/UIN of Recipient"), "fieldtype": "Data", "width": 150, }, { "fieldname": "customer_name", - "label": "Receiver Name", + "label": _("Receiver Name"), "fieldtype": "Data", "width": 120, }, { "fieldname": "return_against", - "label": "Invoice/Advance Receipt Number", + "label": _("Invoice/Advance Receipt Number"), "fieldtype": "Link", "options": "Sales Invoice", "width": 120, }, { "fieldname": "posting_date", - "label": "Invoice/Advance Receipt date", + "label": _("Invoice/Advance Receipt date"), "fieldtype": "Data", "width": 120, }, { "fieldname": "invoice_number", - "label": "Invoice/Advance Receipt Number", + "label": _("Invoice/Advance Receipt Number"), "fieldtype": "Link", "options": "Sales Invoice", "width": 120, }, { "fieldname": "is_reverse_charge", - "label": "Reverse Charge", + "label": _("Reverse Charge"), "fieldtype": "Data", }, { "fieldname": "export_type", - "label": "Export Type", + "label": _("Export Type"), "fieldtype": "Data", "hidden": 1, }, { "fieldname": "reason_for_issuing_document", - "label": "Reason For Issuing document", + "label": _("Reason For Issuing document"), "fieldtype": "Data", "width": 140, }, { "fieldname": "place_of_supply", - "label": "Place Of Supply", + "label": _("Place Of Supply"), "fieldtype": "Data", "width": 120, }, { "fieldname": "gst_category", - "label": "GST Category", + "label": _("GST Category"), "fieldtype": "Data", }, { "fieldname": "invoice_value", - "label": "Invoice Value", + "label": _("Invoice Value"), "fieldtype": "Currency", "width": 120, }, @@ -714,19 +693,19 @@ def get_columns(self): self.other_columns = [ { "fieldname": "cess_amount", - "label": "Cess Amount", + "label": _("Cess Amount"), "fieldtype": "Currency", "width": 100, }, { "fieldname": "pre_gst", - "label": "PRE GST", + "label": _("PRE GST"), "fieldtype": "Data", "width": 80, }, { "fieldname": "document_type", - "label": "Document Type", + "label": _("Document Type"), "fieldtype": "Data", "width": 80, }, @@ -735,56 +714,56 @@ def get_columns(self): self.invoice_columns = [ { "fieldname": "customer_name", - "label": "Receiver Name", + "label": _("Receiver Name"), "fieldtype": "Data", "width": 120, }, { "fieldname": "return_against", - "label": "Issued Against", + "label": _("Issued Against"), "fieldtype": "Link", "options": "Sales Invoice", "width": 120, }, { "fieldname": "posting_date", - "label": "Note Date", + "label": _("Note Date"), "fieldtype": "Date", "width": 120, }, { "fieldname": "invoice_number", - "label": "Note Number", + "label": _("Note Number"), "fieldtype": "Link", "options": "Sales Invoice", "width": 120, }, { "fieldname": "export_type", - "label": "Export Type", + "label": _("Export Type"), "fieldtype": "Data", "hidden": 1, }, { "fieldname": "reason_for_issuing_document", - "label": "Reason For Issuing document", + "label": _("Reason For Issuing document"), "fieldtype": "Data", "width": 140, }, { "fieldname": "place_of_supply", - "label": "Place Of Supply", + "label": _("Place Of Supply"), "fieldtype": "Data", "width": 120, }, { "fieldname": "gst_category", - "label": "GST Category", + "label": _("GST Category"), "fieldtype": "Data", }, { "fieldname": "invoice_value", - "label": "Invoice Value", + "label": _("Invoice Value"), "fieldtype": "Currency", "width": 120, }, @@ -792,19 +771,19 @@ def get_columns(self): self.other_columns = [ { "fieldname": "cess_amount", - "label": "Cess Amount", + "label": _("Cess Amount"), "fieldtype": "Currency", "width": 100, }, { "fieldname": "pre_gst", - "label": "PRE GST", + "label": _("PRE GST"), "fieldtype": "Data", "width": 80, }, { "fieldname": "document_type", - "label": "Document Type", + "label": _("Document Type"), "fieldtype": "Data", "width": 80, }, @@ -813,13 +792,13 @@ def get_columns(self): self.invoice_columns = [ { "fieldname": "place_of_supply", - "label": "Place Of Supply", + "label": _("Place Of Supply"), "fieldtype": "Data", "width": 120, }, { "fieldname": "ecommerce_gstin", - "label": "E-Commerce GSTIN", + "label": _("E-Commerce GSTIN"), "fieldtype": "Data", "width": 130, }, @@ -827,13 +806,13 @@ def get_columns(self): self.other_columns = [ { "fieldname": "cess_amount", - "label": "Cess Amount", + "label": _("Cess Amount"), "fieldtype": "Currency", "width": 100, }, { "fieldname": "type", - "label": "Type", + "label": _("Type"), "fieldtype": "Data", "width": 50, }, @@ -842,89 +821,89 @@ def get_columns(self): self.invoice_columns = [ { "fieldname": "export_type", - "label": "Export Type", + "label": _("Export Type"), "fieldtype": "Data", "width": 120, }, { "fieldname": "invoice_number", - "label": "Invoice Number", + "label": _("Invoice Number"), "fieldtype": "Link", "options": "Sales Invoice", "width": 120, }, { "fieldname": "posting_date", - "label": "Invoice date", + "label": _("Invoice date"), "fieldtype": "Data", "width": 120, }, { "fieldname": "invoice_value", - "label": "Invoice Value", + "label": _("Invoice Value"), "fieldtype": "Currency", "width": 120, }, { "fieldname": "port_code", - "label": "Port Code", + "label": _("Port Code"), "fieldtype": "Data", "width": 120, }, { "fieldname": "shipping_bill_number", - "label": "Shipping Bill Number", + "label": _("Shipping Bill Number"), "fieldtype": "Data", "width": 120, }, { "fieldname": "shipping_bill_date", - "label": "Shipping Bill Date", + "label": _("Shipping Bill Date"), "fieldtype": "Data", "width": 120, }, ] - elif self.filters.get("type_of_business") == "Advances": + elif self.filters.get("type_of_business") in ("Advances", "Adjustment"): self.invoice_columns = [ { "fieldname": "place_of_supply", - "label": "Place Of Supply", + "label": _("Place Of Supply"), "fieldtype": "Data", - "width": 120, + "width": 180, } ] self.other_columns = [ { "fieldname": "cess_amount", - "label": "Cess Amount", + "label": _("Cess Amount"), "fieldtype": "Currency", - "width": 100, + "width": 130, } ] elif self.filters.get("type_of_business") == "NIL Rated": self.invoice_columns = [ { "fieldname": "description", - "label": "Description", + "label": _("Description"), "fieldtype": "Data", "width": 420, }, { "fieldname": "nil_rated", - "label": "Nil Rated", + "label": _("Nil Rated"), "fieldtype": "Currency", "width": 200, }, { "fieldname": "exempted", - "label": "Exempted", + "label": _("Exempted"), "fieldtype": "Currency", "width": 200, }, { "fieldname": "non_gst", - "label": "Non GST", + "label": _("Non GST"), "fieldtype": "Currency", "width": 200, }, @@ -933,6 +912,115 @@ def get_columns(self): self.columns = self.invoice_columns + self.tax_columns + self.other_columns +class GSTR11A11BData: + def __init__(self, filters, gst_accounts): + self.filters = filters + + self.pe = frappe.qb.DocType("Payment Entry") + self.pe_ref = frappe.qb.DocType("Payment Entry Reference") + self.gl_entry = frappe.qb.DocType("GL Entry") + self.gst_accounts = gst_accounts + + def get_data(self): + if self.filters.get("type_of_business") == "Advances": + records = self.get_11A_data() + + elif self.filters.get("type_of_business") == "Adjustment": + records = self.get_11B_data() + + return self.process_data(records) + + def get_11A_data(self): + return ( + self.get_query() + .select(self.pe.paid_amount.as_("taxable_value")) + .groupby(self.pe.name) + .run(as_dict=True) + ) + + def get_11B_data(self): + query = ( + self.get_query() + .join(self.pe_ref) + .on(self.pe_ref.name == self.gl_entry.voucher_detail_no) + .select(self.pe_ref.allocated_amount.as_("taxable_value")) + .groupby(self.gl_entry.voucher_detail_no) + ) + + return query.run(as_dict=True) + + def get_query(self): + cr_or_dr = ( + "credit" if self.filters.get("type_of_business") == "Advances" else "debit" + ) + cr_or_dr_amount_field = getattr( + self.gl_entry, f"{cr_or_dr}_in_account_currency" + ) + + return ( + frappe.qb.from_(self.gl_entry) + .join(self.pe) + .on(self.pe.name == self.gl_entry.voucher_no) + .select( + self.pe.place_of_supply, + Sum( + Case() + .when( + self.gl_entry.account != self.gst_accounts.cess_account, + cr_or_dr_amount_field, + ) + .else_(0) + ).as_("tax_amount"), + Sum( + Case() + .when( + self.gl_entry.account == self.gst_accounts.cess_account, + cr_or_dr_amount_field, + ) + .else_(0) + ).as_("cess_amount"), + ) + .where(Criterion.all(self.get_conditions())) + .where(cr_or_dr_amount_field > 0) + ) + + def get_conditions(self): + gst_accounts_list = [ + account_head for account_head in self.gst_accounts.values() if account_head + ] + + conditions = [] + + conditions.append(self.gl_entry.is_cancelled == 0) + conditions.append(self.gl_entry.voucher_type == "Payment Entry") + conditions.append(self.gl_entry.company == self.filters.get("company")) + conditions.append(self.gl_entry.account.isin(gst_accounts_list)) + conditions.append( + self.gl_entry.posting_date[ + self.filters.get("from_date") : self.filters.get("to_date") + ] + ) + + if self.filters.get("company_gstin"): + conditions.append( + self.gl_entry.company_gstin == self.filters.get("company_gstin") + ) + + return conditions + + def process_data(self, records): + data = {} + for entry in records: + tax_rate = round(((entry.tax_amount / entry.taxable_value) * 100)) + + data.setdefault((entry.place_of_supply, tax_rate), [0.0, 0.0]) + + data[(entry.place_of_supply, tax_rate)][0] += entry.taxable_value + data[(entry.place_of_supply, tax_rate)][1] += entry.cess_amount + + return data + + @frappe.whitelist() def get_json(filters, report_name, data): """ @@ -996,20 +1084,25 @@ def get_json(filters, report_name, data): out = get_cdnr_unreg_json(res, gstin) gst_json["cdnur"] = out - elif filters["type_of_business"] == "Advances": + elif filters["type_of_business"] in ("Advances", "Adjustment"): + business_type_key = { + "Advances": "at", + "Adjustment": "txpd", + } + for item in report_data[:-1]: if not item.get("place_of_supply"): frappe.throw( _( """{0} not entered in some entries. - Please update and try again""" + Please update and try again""" ).format(frappe.bold("Place Of Supply")) ) res.setdefault(item["place_of_supply"], []).append(item) out = get_advances_json(res, gstin) - gst_json["at"] = out + gst_json[business_type_key[filters.get("type_of_business")]] = out elif filters["type_of_business"] == "NIL Rated": res = report_data[:-1] @@ -1112,12 +1205,10 @@ def get_advances_json(data, gstin): company_state_number = gstin[0:2] out = [] for place_of_supply, items in data.items(): - supply_type = ( - "INTRA" - if company_state_number == place_of_supply.split("-")[0] - else "INTER" - ) - row = {"pos": place_of_supply.split("-")[0], "itms": [], "sply_ty": supply_type} + pos = place_of_supply.split("-")[0] + supply_type = "INTRA" if company_state_number == pos else "INTER" + + row = {"pos": pos, "itms": [], "sply_ty": supply_type} for item in items: itms = { @@ -1126,16 +1217,16 @@ def get_advances_json(data, gstin): "csamt": flt(item.get("cess_amount"), 2), } + tax_amount = (itms["ad_amount"] * itms["rt"]) / 100 if supply_type == "INTRA": itms.update( { - "samt": flt((itms["ad_amount"] * itms["rt"]) / 100, 2), - "camt": flt((itms["ad_amount"] * itms["rt"]) / 100, 2), - "rt": itms["rt"] * 2, + "samt": flt(tax_amount / 2, 2), + "camt": flt(tax_amount / 2, 2), } ) else: - itms["iamt"] = flt((itms["ad_amount"] * itms["rt"]) / 100, 2) + itms["iamt"] = flt(tax_amount, 2) row["itms"].append(itms) out.append(row) diff --git a/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py b/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py index dfa37083c..a26be30a1 100644 --- a/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py +++ b/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py @@ -5,7 +5,7 @@ from frappe import _ from frappe.query_builder import Case from frappe.query_builder.custom import ConstantColumn -from frappe.query_builder.functions import LiteralValue, Sum +from frappe.query_builder.functions import Ifnull, LiteralValue, Sum from frappe.utils import cint, get_first_day, get_last_day from india_compliance.gst_india.utils import get_gst_accounts_by_type @@ -142,6 +142,7 @@ def get_itc_from_purchase(self): & (purchase_invoice.posting_date[self.from_date : self.to_date]) & (purchase_invoice.company == self.company) & (purchase_invoice.company_gstin == self.company_gstin) + & (Ifnull(purchase_invoice.eligibility_for_itc, "") != "") ) .groupby(purchase_invoice.name) ) diff --git a/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py b/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py index eb123e478..34dd33b59 100644 --- a/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py +++ b/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py @@ -317,6 +317,8 @@ def get_hsn_wise_json_data(filters, report_data): count = 1 for hsn in report_data: + if hsn.get("gst_hsn_code") == "Total": + continue row = { "num": count, "hsn_sc": hsn.get("gst_hsn_code"), diff --git a/india_compliance/gst_india/setup/__init__.py b/india_compliance/gst_india/setup/__init__.py index 7626e9228..b6e9e46b8 100644 --- a/india_compliance/gst_india/setup/__init__.py +++ b/india_compliance/gst_india/setup/__init__.py @@ -29,6 +29,7 @@ def after_install(): create_accounting_dimension_fields() create_property_setters() create_address_template() + create_email_template() set_default_gst_settings() set_default_accounts_settings() create_hsn_codes() @@ -83,6 +84,26 @@ def create_address_template(): ).insert(ignore_permissions=True) +def create_email_template(): + if frappe.db.exists("Email Template", "Purchase Reconciliation"): + return + + frappe.get_doc( + { + "doctype": "Email Template", + "name": "Purchase Reconciliation", + "subject": "2A/2B Reconciliation for {{ supplier_name }}-{{ supplier_gstin }}", + "response": ( + "Hello,

We have made a purchase reconciliation" + "for the period {{ inward_supply_from_date }} to {{ inward_supply_to_date }}" + " for purchases made by {{ company }} from you.
You are requested to kindly" + "make necessary corrections to the GST Portal on your end if required." + "Attached is the sheet for your reference." + ), + } + ).insert(ignore_permissions=True) + + def create_hsn_codes(): if frappe.db.count("GST HSN Code") > 0: return diff --git a/india_compliance/gst_india/utils/__init__.py b/india_compliance/gst_india/utils/__init__.py index 95d4e194a..c54a4022e 100644 --- a/india_compliance/gst_india/utils/__init__.py +++ b/india_compliance/gst_india/utils/__init__.py @@ -1,11 +1,28 @@ +import copy +import io +import tarfile + from dateutil import parser from pytz import timezone from titlecase import titlecase as _titlecase import frappe from frappe import _ +from frappe.contacts.doctype.contact.contact import get_contact_details from frappe.desk.form.load import get_docinfo, run_onload -from frappe.utils import cint, cstr, get_datetime, get_link_to_form, get_system_timezone +from frappe.utils import ( + add_to_date, + cint, + cstr, + get_datetime, + get_link_to_form, + get_system_timezone, + getdate, +) +from frappe.utils.data import get_timespan_date_range as _get_timespan_date_range +from frappe.utils.file_manager import get_file_path +from erpnext.accounts.party import get_default_contact +from erpnext.accounts.utils import get_fiscal_year from india_compliance.gst_india.constants import ( ABBREVIATIONS, @@ -96,6 +113,38 @@ def get_gstin_list(party, party_type="Company"): return gstin_list +@frappe.whitelist() +def get_party_for_gstin(gstin, party_type="Supplier"): + if not gstin: + return + + if party := frappe.db.get_value( + party_type, filters={"gstin": gstin}, fieldname="name" + ): + return party + + address = frappe.qb.DocType("Address") + links = frappe.qb.DocType("Dynamic Link") + party = ( + frappe.qb.from_(address) + .join(links) + .on(links.parent == address.name) + .select(links.link_name) + .where(links.link_doctype == party_type) + .where(address.gstin == gstin) + .limit(1) + .run() + ) + if party: + return party[0][0] + + +@frappe.whitelist() +def get_party_contact_details(party, party_type="Supplier"): + if party and (contact := get_default_contact(party_type, party)): + return get_contact_details(contact) + + def validate_gstin( gstin, label="GSTIN", @@ -157,7 +206,9 @@ def validate_gst_category(gst_category, gstin): if gst_category == "Unregistered": frappe.throw( - "GST Category cannot be Unregistered for party with GSTIN", + _( + "GST Category cannot be Unregistered for party with GSTIN", + ) ) valid_gstin_format = GSTIN_FORMATS.get(gst_category) @@ -282,7 +333,7 @@ def is_overseas_transaction(doctype, gst_category, place_of_supply): if gst_category == "SEZ": return True - if doctype in SALES_DOCTYPES: + if doctype in SALES_DOCTYPES or doctype == "Payment Entry": return is_foreign_transaction(gst_category, place_of_supply) return gst_category == "Overseas" @@ -316,7 +367,7 @@ def get_place_of_supply(party_details, doctype): # fallback to company GSTIN for sales or supplier GSTIN for purchases # (in retail scenarios, customer / company GSTIN may not be set) - if doctype in SALES_DOCTYPES: + if doctype in SALES_DOCTYPES or doctype == "Payment Entry": # for exports, Place of Supply is set using GST category in absence of GSTIN if party_details.gst_category == "Overseas": return "96-Other Countries" @@ -447,6 +498,10 @@ def as_ist(value=None): ) +def get_json_from_file(path): + return frappe._dict(frappe.get_file_json(get_file_path(path))) + + def join_list_with_custom_separators(input, separator=", ", last_separator=" or "): if type(input) not in (list, tuple): return @@ -568,3 +623,88 @@ def get_validated_country_code(country): ) return code + + +def get_timespan_date_range(timespan: str, company: str | None = None) -> tuple | None: + date_range = _get_timespan_date_range(timespan) + + if date_range: + return date_range + + company = company or frappe.defaults.get_user_default("Company") + + if timespan == "this fiscal year": + date = getdate() + fiscal_year = get_fiscal_year(date, company=company) + return (fiscal_year[1], fiscal_year[2]) + + if timespan == "last fiscal year": + date = add_to_date(getdate(), years=-1) + fiscal_year = get_fiscal_year(date, company=company) + return (fiscal_year[1], fiscal_year[2]) + + return + + +def merge_dicts(d1: dict, d2: dict) -> dict: + """ + Sample Input: + ------------- + d1 = { + 'key1': 'value1', + 'key2': {'nested': 'value'}, + 'key3': ['value1'], + 'key4': 'value4' + } + d2 = { + 'key1': 'value2', + 'key2': {'key': 'value3'}, + 'key3': ['value2'], + 'key5': 'value5' + } + + Sample Output: + -------------- + { + 'key1': 'value2', + 'key2': {'nested': 'value', 'key': 'value3'}, + 'key3': ['value1', 'value2'], + 'key4': 'value4', + 'key5': 'value5' + } + """ + for key in set(d1.keys()) | set(d2.keys()): + if key in d2 and key in d1: + if isinstance(d1[key], dict) and isinstance(d2[key], dict): + merge_dicts(d1[key], d2[key]) + + elif isinstance(d1[key], list) and isinstance(d2[key], list): + d1[key] = d1[key] + d2[key] + + else: + d1[key] = copy.deepcopy(d2[key]) + + elif key in d2: + d1[key] = copy.deepcopy(d2[key]) + + return d1 + + +def tar_gz_bytes_to_data(tar_gz_bytes: bytes) -> str | None: + """ + Return first file in tar.gz ending with .json + """ + with tarfile.open(fileobj=io.BytesIO(tar_gz_bytes), mode="r:gz") as tar_gz_file: + for filename in tar_gz_file.getnames(): + if not filename.endswith(".json"): + continue + + file_in_tar = tar_gz_file.extractfile(filename) + + if not file_in_tar: + continue + + data = file_in_tar.read().decode("utf-8") + break + + return data diff --git a/india_compliance/gst_india/utils/api.py b/india_compliance/gst_india/utils/api.py index 8ea61b165..efcb63df3 100644 --- a/india_compliance/gst_india/utils/api.py +++ b/india_compliance/gst_india/utils/api.py @@ -18,7 +18,6 @@ def create_integration_request( reference_doctype=None, reference_name=None, ): - return frappe.get_doc( { "doctype": "Integration Request", diff --git a/india_compliance/gst_india/utils/cryptography.py b/india_compliance/gst_india/utils/cryptography.py new file mode 100644 index 000000000..2aecf3c97 --- /dev/null +++ b/india_compliance/gst_india/utils/cryptography.py @@ -0,0 +1,75 @@ +import hmac +from base64 import b64decode, b64encode +from hashlib import sha256 + +from Crypto.Cipher import AES, PKCS1_v1_5 +from Crypto.PublicKey import RSA +from Crypto.Util.Padding import pad, unpad +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + +import frappe +from frappe import _ +from frappe.utils import now_datetime + +BS = 16 + + +def aes_encrypt_data(data: str, key: bytes | str) -> str: + raw = pad(data.encode(), BS) + + if isinstance(key, str): + key = key.encode() + + cipher = AES.new(key, AES.MODE_ECB) + enc = cipher.encrypt(raw) + + return b64encode(enc).decode() + + +def aes_decrypt_data(encrypted: str, key: bytes | str) -> bytes: + if isinstance(key, str): + key = key.encode() + + encrypted = b64decode(encrypted) + cipher = AES.new(key, AES.MODE_ECB) + decrypted = unpad(cipher.decrypt(encrypted), BS) + + return decrypted + + +def hmac_sha256(data: str, key: bytes) -> str: + hmac_value = hmac.new(key, data, sha256) + return b64encode(hmac_value.digest()).decode() + + +def hash_sha256(data: bytes) -> str: + return sha256(data).hexdigest() + + +def encrypt_using_public_key(data: str, certificate: bytes) -> str: + if not data: + return + + cert = x509.load_pem_x509_certificate(certificate, default_backend()) + + valid_up_to = cert.not_valid_after + if valid_up_to < now_datetime(): + frappe.throw(_("Public Certificate has expired")) + + public_key = cert.public_key() + pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + key = RSA.importKey(pem) + + cipher = PKCS1_v1_5.new(key) + if isinstance(data, str): + data = data.encode() + + ciphertext = cipher.encrypt(data) + + return b64encode(ciphertext).decode() diff --git a/india_compliance/gst_india/utils/e_invoice.py b/india_compliance/gst_india/utils/e_invoice.py index b14095953..ed0e72014 100644 --- a/india_compliance/gst_india/utils/e_invoice.py +++ b/india_compliance/gst_india/utils/e_invoice.py @@ -128,10 +128,39 @@ def generate_e_invoice(docname, throw=True, force=False): if result.InfCd == "DUPIRN": response = api.get_e_invoice_by_irn(result.Desc.Irn) + if signed_data := response.SignedInvoice: + invoice_data = json.loads( + jwt.decode(signed_data, options={"verify_signature": False})["data"] + ) + + previous_invoice_amount = invoice_data.get("ValDtls").get("TotInvVal") + current_invoice_amount = data.get("ValDtls").get("TotInvVal") + + if previous_invoice_amount != current_invoice_amount: + frappe.throw( + _( + "e-Invoice is already available against Invoice {0} with a Grand Total of Rs.{1}" + " Duplicate IRN requests are not considered by e-Invoice Portal." + ).format( + frappe.bold(invoice_data.get("DocDtls").get("No")), + frappe.bold(previous_invoice_amount), + ) + ) + # Handle error 2283: # IRN details cannot be provided as it is generated more than 2 days ago result = result.Desc if response.error_code == "2283" else response + # Handle Invalid GSTIN Error + if result.error_code in ("3028", "3029"): + gstin = data.get("BuyerDtls").get("Gstin") + response = api.sync_gstin_info(gstin) + + if response.Status != "ACT": + frappe.throw(_("GSTIN {0} status is not Active").format(gstin)) + + result = api.generate_irn(data) + except GatewayTimeoutError as e: einvoice_status = "Failed" @@ -236,13 +265,22 @@ def cancel_e_invoice(docname, values): } result = EInvoiceAPI(doc).cancel_irn(data) + + log_and_process_e_invoice_cancellation( + doc, values, result, "e-Invoice cancelled successfully" + ) + + return send_updated_doc(doc) + + +def log_and_process_e_invoice_cancellation(doc, values, result, message): log_e_invoice( doc, { "name": doc.irn, "is_cancelled": 1, "cancel_reason_code": values.reason, - "cancel_remark": values.remark, + "cancel_remark": values.remark or values.reason, "cancelled_on": ( get_datetime() # Fallback to handle already cancelled IRN if result.error_code == "9999" @@ -251,12 +289,33 @@ def cancel_e_invoice(docname, values): }, ) - doc.db_set({"einvoice_status": "Cancelled", "irn": ""}) + doc.db_set( + { + "einvoice_status": result.get("einvoice_status") or "Cancelled", + "irn": "", + } + ) - frappe.msgprint( - _("e-Invoice cancelled successfully"), - indicator="green", - alert=True, + frappe.msgprint(_(message), indicator="green", alert=True) + + +@frappe.whitelist() +def mark_e_invoice_as_cancelled(doctype, docname, values): + doc = load_doc(doctype, docname, "cancel") + + if doc.docstatus != 2: + return + + values = frappe.parse_json(values) + result = frappe._dict( + { + "CancelDate": values.cancelled_on, + "einvoice_status": "Manually Cancelled", + } + ) + + log_and_process_e_invoice_cancellation( + doc, values, result, "e-Invoice marked as cancelled successfully" ) return send_updated_doc(doc) diff --git a/india_compliance/gst_india/utils/e_waybill.py b/india_compliance/gst_india/utils/e_waybill.py index c94044601..d4d43e2cd 100644 --- a/india_compliance/gst_india/utils/e_waybill.py +++ b/india_compliance/gst_india/utils/e_waybill.py @@ -139,7 +139,10 @@ def _generate_e_waybill(doc, throw=True): # Via e-Invoice API if not Return or Debit Note # Handles following error when generating e-Waybill using IRN: # 4010: E-way Bill cannot generated for Debit Note, Credit Note and Services - with_irn = doc.get("irn") and not (doc.is_return or doc.get("is_debit_note")) + with_irn = doc.get("irn") and not ( + doc.is_return or doc.get("is_debit_note") or is_foreign_doc(doc) + ) + data = EWaybillData(doc).get_data(with_irn=with_irn) except frappe.ValidationError as e: diff --git a/india_compliance/gst_india/utils/exporter.py b/india_compliance/gst_india/utils/exporter.py new file mode 100644 index 000000000..f3695abb4 --- /dev/null +++ b/india_compliance/gst_india/utils/exporter.py @@ -0,0 +1,316 @@ +from io import BytesIO + +import openpyxl +from openpyxl.formatting.rule import FormulaRule +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +import frappe + + +class ExcelExporter: + def __init__(self): + self.wb = openpyxl.Workbook() + + def create_sheet(self, **kwargs): + """ + create worksheet + :param sheet_name - name for the worksheet + :param filters - A data dictionary to added in sheet + :param merged_headers - A dict of List + @example: { + 'label': [column1, colum2] + } + :param headers: A List of dictionary (cell properties will be optional) + :param data: A list of dictionary to append data to sheet + """ + + Worksheet().create(workbook=self.wb, **kwargs) + + def save_workbook(self, file_name=None): + """Save workbook""" + if file_name: + self.wb.save(file_name) + return self.wb + + xlsx_file = BytesIO() + self.wb.save(xlsx_file) + return xlsx_file + + def remove_sheet(self, sheet_name): + """Remove worksheet""" + if sheet_name in self.wb.sheetnames: + self.wb.remove(self.wb[sheet_name]) + + def export(self, file_name): + # write out response as a xlsx type + if file_name[-4:] != ".xlsx": + file_name = f"{file_name}.xlsx" + + xlsx_file = self.save_workbook() + + frappe.local.response["filename"] = file_name + frappe.local.response["filecontent"] = xlsx_file.getvalue() + frappe.local.response["type"] = "binary" + + +class Worksheet: + data_format = frappe._dict( + { + "font_family": "Calibri", + "font_size": 9, + "bold": False, + "horizontal": "general", + "number_format": "General", + "width": 20, + "height": 20, + "vertical": "center", + "wrap_text": False, + "bg_color": "f2f2f2", + } + ) + filter_format = data_format.copy().update({"bg_color": None, "bold": True}) + header_format = frappe._dict( + { + "font_family": "Calibri", + "font_size": 9, + "bold": True, + "horizontal": "center", + "number_format": "General", + "width": 20, + "height": 30, + "vertical": "center", + "wrap_text": True, + "bg_color": "d9d9d9", + } + ) + default_styles = frappe._dict( + { + "is_header": "header_format", + "is_total": "header_format", + "is_data": "data_format", + "is_filter": "filter_format", + } + ) + + def __init__(self): + self.row_dimension = 1 + self.column_dimension = 1 + + def create( + self, + workbook, + sheet_name, + headers, + data, + filters=None, + merged_headers=None, + add_totals=True, + ): + """Create worksheet""" + self.headers = headers + + self.ws = workbook.create_sheet(sheet_name) + self.add_data(filters, is_filter=True) + self.add_merged_header(merged_headers) + self.add_data(headers, is_header=True) + self.add_data(data, is_data=True) + + if add_totals: + self.add_data(self.get_totals(), is_total=True) + + self.apply_conditional_formatting(add_totals) + + def add_data(self, data, **kwargs): + if not data: + return + + if kwargs.get("is_data"): + self.data_row = self.row_dimension + + for row in self.parse_data(data): + for idx, val in enumerate(row, 1): + cell = self.ws.cell(row=self.row_dimension, column=idx) + self.apply_format(row=self.row_dimension, column=idx, **kwargs) + cell.value = val + + self.row_dimension += 1 + + def add_merged_header(self, merged_headers): + if not merged_headers: + return + + for key, value in merged_headers.items(): + merge_from_idx = self.get_column_index(value[0]) + merge_to_idx = self.get_column_index(value[1]) + + cell_range = self.get_range( + start_row=self.row_dimension, + start_column=merge_from_idx, + end_row=self.row_dimension, + end_column=merge_to_idx, + ) + + self.ws.cell(row=self.row_dimension, column=merge_from_idx).value = key + + self.ws.merge_cells(cell_range) + + self.apply_format( + row=self.row_dimension, + column=merge_from_idx, + is_header=True, + ) + + self.row_dimension += 1 + + def get_totals(self): + """build total row array of fields to be calculated""" + total_row = [] + + for idx, column in enumerate(self.headers, 1): + if idx == 1: + total_row.append("Totals") + elif column.get("fieldtype") in ("Float", "Int"): + cell_range = self.get_range(self.data_row, idx, self.ws.max_row, idx) + total_row.append(f"=SUM({cell_range})") + else: + total_row.append("") + + return total_row + + def apply_format(self, row, column, **kwargs): + """Get style if defined or apply default format to the cell""" + + key, value = kwargs.popitem() + if not value: + return + + # get default style + style_name = self.default_styles.get(key) + style = getattr(self, style_name).copy() + + # update custom style + custom_styles = self.headers[column - 1].get(style_name) + if custom_styles: + style.update(custom_styles) + + if key == "is_total": + style.update( + { + "horizontal": self.data_format.horizontal, + "height": self.data_format.height, + } + ) + + self.apply_style(row, column, style) + + def apply_style(self, row, column, style): + """Apply style to cell""" + + cell = self.ws.cell(row=row, column=column) + cell.font = Font(name=style.font_family, size=style.font_size, bold=style.bold) + cell.alignment = Alignment( + horizontal=style.horizontal, + vertical=style.vertical, + wrap_text=style.wrap_text, + ) + cell.number_format = style.number_format + + if style.bg_color: + cell.fill = PatternFill(fill_type="solid", fgColor=style.bg_color) + + self.ws.column_dimensions[get_column_letter(column)].width = style.width + self.ws.row_dimensions[row].height = style.height + + def apply_conditional_formatting(self, has_totals): + """Apply conditional formatting to data based on comparable fields as defined in headers""" + + for row in self.headers: + if not (compare_field := row.get("compare_with")): + continue + + column = get_column_letter(self.get_column_index(row["fieldname"])) + compare_column = get_column_letter(self.get_column_index(compare_field)) + + # eg formula used: IF(ISBLANK(H6), FALSE, H6<>R6) + formula = f"IF(ISBLANK({column}{self.data_row}), FALSE, {column}{self.data_row}<>{compare_column}{self.data_row})" + + cell_range = self.get_range( + start_row=self.data_row, + start_column=column, + end_row=self.ws.max_row - has_totals, + end_column=column, + ) + + self.ws.conditional_formatting.add( + cell_range, + FormulaRule( + formula=[formula], + stopIfTrue=True, + font=Font( + name=self.data_format.get("font_family"), + size=self.data_format.get("font_size"), + bold=True, + color="FF0000", + ), + ), + ) + + def parse_data(self, data): + """Convert data to List of Lists""" + out = [] + + if isinstance(data, dict): + for key, value in data.items(): + # eg: {"fieldname": "value"} => ["fieldname", "value"]. for filters. + out.append([key, value]) + + elif isinstance(data, list): + # eg: ["value1", "value2"] => ["value1", "value2"]. for totals. + if isinstance(data[0], str): + return [data] + + for row in data: + # eg: [{"label": "value1"}] => "value1". for headers. + if row.get("label"): + out.append(row.get("label")) + else: + # eg: [{"fieldname1": "value1", "fieldname2": "value2"}] => ["value1", "value2"]. for data. + out.append(list(row.values())) + + if row.get("label"): + return [out] + + return out + + def get_range(self, start_row, start_column, end_row, end_column, freeze=False): + """ + Get range of cells + parameters: + start_row (int): row number of the first cell + start_column (int | string): column number / letter of the first cell + end_row (int): row number of the last cell + end_column (int | string): column number / letter of the last cell + freeze (bool): freeze the range + + returns: + string: range of cells eg: A1:B2 + """ + + if isinstance(start_column, int): + start_column = get_column_letter(start_column) + + if isinstance(end_column, int): + end_column = get_column_letter(end_column) + + if freeze: + return f"${start_column}${start_row}:${end_column}${end_row}" + + return f"{start_column}{start_row}:{end_column}{end_row}" + + def get_column_index(self, column_name): + """Get column index / position from column name""" + + for (idx, field) in enumerate(self.headers, 1): + if field["fieldname"] == column_name: + return idx diff --git a/india_compliance/gst_india/utils/gstin_info.py b/india_compliance/gst_india/utils/gstin_info.py index 3663f7cd8..77d710031 100644 --- a/india_compliance/gst_india/utils/gstin_info.py +++ b/india_compliance/gst_india/utils/gstin_info.py @@ -23,7 +23,7 @@ @frappe.whitelist() -def get_gstin_info(gstin): +def get_gstin_info(gstin, *, throw_error=True): if not frappe.get_cached_doc("User", frappe.session.user).has_desk_access(): frappe.throw(_("Not allowed"), frappe.PermissionError) @@ -31,12 +31,20 @@ def get_gstin_info(gstin): response = get_archived_gstin_info(gstin) if not response: - response = PublicAPI().get_gstin_info(gstin) - frappe.enqueue( - "india_compliance.gst_india.doctype.gstin.gstin.create_or_update_gstin_status", - queue="long", - response=response, - ) + try: + response = PublicAPI().get_gstin_info(gstin) + frappe.enqueue( + "india_compliance.gst_india.doctype.gstin.gstin.create_or_update_gstin_status", + queue="long", + response=response, + ) + except Exception as exc: + if throw_error: + raise exc + + frappe.log_error(title="Failed to Fetch GSTIN Info", exc=exc) + frappe.clear_last_message() + return business_name = ( response.tradeNam if response.ctb == "Proprietorship" else response.lgnm diff --git a/india_compliance/gst_india/utils/gstr/__init__.py b/india_compliance/gst_india/utils/gstr/__init__.py new file mode 100644 index 000000000..3ac2eac56 --- /dev/null +++ b/india_compliance/gst_india/utils/gstr/__init__.py @@ -0,0 +1,385 @@ +from enum import Enum + +import frappe +from frappe import _ +from frappe.query_builder.terms import Criterion +from frappe.utils import cint + +from india_compliance.gst_india.api_classes.returns import ( + GSTR2aAPI, + GSTR2bAPI, + ReturnsAPI, +) +from india_compliance.gst_india.doctype.gstr_import_log.gstr_import_log import ( + create_import_log, + toggle_scheduled_jobs, +) +from india_compliance.gst_india.utils import get_party_for_gstin +from india_compliance.gst_india.utils.gstr import gstr_2a, gstr_2b + + +class ReturnType(Enum): + GSTR2A = "GSTR2a" + GSTR2B = "GSTR2b" + + +class GSTRCategory(Enum): + B2B = "B2B" + B2BA = "B2BA" + CDNR = "CDNR" + CDNRA = "CDNRA" + ISD = "ISD" + ISDA = "ISDA" + IMPG = "IMPG" + IMPGSEZ = "IMPGSEZ" + + +ACTIONS = { + "B2B": GSTRCategory.B2B, + "B2BA": GSTRCategory.B2BA, + "CDN": GSTRCategory.CDNR, + "CDNA": GSTRCategory.CDNRA, + "ISD": GSTRCategory.ISD, + "IMPG": GSTRCategory.IMPG, + "IMPGSEZ": GSTRCategory.IMPGSEZ, +} + +GSTR_MODULES = { + ReturnType.GSTR2A.value: gstr_2a, + ReturnType.GSTR2B.value: gstr_2b, +} + +IMPORT_CATEGORY = ("IMPG", "IMPGSEZ") + + +def download_gstr_2a(gstin, return_periods, otp=None): + total_expected_requests = len(return_periods) * len(ACTIONS) + requests_made = 0 + queued_message = False + settings = frappe.get_cached_doc("GST Settings") + + return_type = ReturnType.GSTR2A + api = GSTR2aAPI(gstin) + for return_period in return_periods: + is_last_period = return_periods[-1] == return_period + + json_data = frappe._dict({"gstin": gstin, "fp": return_period}) + for action, category in ACTIONS.items(): + requests_made += 1 + + if ( + not settings.enable_overseas_transactions + and category.value in IMPORT_CATEGORY + ): + continue + + frappe.publish_realtime( + "update_api_progress", + { + "current_progress": requests_made * 100 / total_expected_requests, + "return_period": return_period, + "is_last_period": is_last_period, + }, + user=frappe.session.user, + doctype="Purchase Reconciliation Tool", + ) + + response = api.get_data(action, return_period, otp) + if response.error_type in ["otp_requested", "invalid_otp"]: + return response + + if response.error_type == "no_docs_found": + create_import_log( + gstin, + return_type.value, + return_period, + classification=category.value, + data_not_found=True, + ) + continue + + # Queued + if response.token: + create_import_log( + gstin, + return_type.value, + return_period, + classification=category.value, + request_id=response.token, + retry_after_mins=cint(response.est), + ) + queued_message = True + continue + + if response.error_type: + continue + + if not (data := response.get(action.lower())): + frappe.throw( + _( + "Data received seems to be invalid from the GST Portal. Please try" + " again or raise support ticket." + ), + title=_("Invalid Response Received."), + ) + + # making consistent with GSTR2a upload + json_data[action.lower()] = data + + save_gstr_2a(gstin, return_period, json_data) + + if queued_message: + show_queued_message() + + +def download_gstr_2b(gstin, return_periods, otp=None): + total_expected_requests = len(return_periods) + requests_made = 0 + queued_message = False + + api = GSTR2bAPI(gstin) + for return_period in return_periods: + is_last_period = return_periods[-1] == return_period + requests_made += 1 + frappe.publish_realtime( + "update_api_progress", + { + "current_progress": requests_made * 100 / total_expected_requests, + "return_period": return_period, + "is_last_period": is_last_period, + }, + user=frappe.session.user, + doctype="Purchase Reconciliation Tool", + ) + + # TODO: skip if today is not greater than 14th return period's next months + response = api.get_data(return_period, otp) + if response.error_type in ["otp_requested", "invalid_otp"]: + return response + + if response.error_type == "no_docs_found": + create_import_log( + gstin, ReturnType.GSTR2B.value, return_period, data_not_found=True + ) + continue + + if response.error_type == "queued": + create_import_log( + gstin, + ReturnType.GSTR2B.value, + return_period, + request_id=response.requestid, + retry_after_mins=response.retryTimeInMinutes, + ) + queued_message = True + continue + + if response.error_type: + continue + + # Handle multiple files for GSTR2B + if response.data and (file_count := response.data.get("fc")): + for file_num in range(1, file_count + 1): + r = api.get_data(return_period, otp, file_num) + save_gstr_2b(gstin, return_period, r) + + continue # skip first response if file_count is greater than 1 + + save_gstr_2b(gstin, return_period, response) + + if queued_message: + show_queued_message() + + +def save_gstr_2a(gstin, return_period, json_data): + return_type = ReturnType.GSTR2A + if ( + not json_data + or json_data.get("gstin") != gstin + or json_data.get("fp") != return_period + ): + frappe.throw( + _( + "Data received seems to be invalid from the GST Portal. Please try" + " again or raise support ticket." + ), + title=_("Invalid Response Received."), + ) + + for action, category in ACTIONS.items(): + if action.lower() not in json_data: + continue + + create_import_log( + gstin, return_type.value, return_period, classification=category.value + ) + + # making consistent with GSTR2b + json_data[category.value.lower()] = json_data.pop(action.lower()) + + save_gstr(gstin, return_type, return_period, json_data) + + +def save_gstr_2b(gstin, return_period, json_data): + json_data = json_data.data + return_type = ReturnType.GSTR2B + if not json_data or json_data.get("gstin") != gstin: + frappe.throw( + _( + "Data received seems to be invalid from the GST Portal. Please try" + " again or raise support ticket." + ), + title=_("Invalid Response Received."), + ) + + create_import_log(gstin, return_type.value, return_period) + save_gstr( + gstin, + return_type, + return_period, + json_data.get("docdata"), + json_data.get("gendt"), + ) + update_import_history(return_period) + + +def save_gstr(gstin, return_type, return_period, json_data, gen_date_2b=None): + frappe.enqueue( + _save_gstr, + queue="long", + now=frappe.flags.in_test, + timeout=1800, + gstin=gstin, + return_type=return_type.value, + return_period=return_period, + json_data=json_data, + gen_date_2b=gen_date_2b, + ) + + +def _save_gstr(gstin, return_type, return_period, json_data, gen_date_2b=None): + """Save GSTR data to Inward Supply + + :param return_period: str + :param json_data: dict of list (GSTR category: suppliers) + :param gen_date_2b: str (Date when GSTR 2B was generated) + """ + + company = get_party_for_gstin(gstin, "Company") + for category in GSTRCategory: + gstr = get_data_handler(return_type, category) + gstr(company, gstin, return_period, json_data, gen_date_2b).create_transactions( + category, + json_data.get(category.value.lower()), + ) + + +def get_data_handler(return_type, category): + class_name = return_type + category.value + return getattr(GSTR_MODULES[return_type], class_name) + + +def update_import_history(return_periods): + """Updates 2A data availability from 2B Import""" + + if not ( + inward_supplies := frappe.get_all( + "GST Inward Supply", + filters={"return_period_2b": ("in", return_periods)}, + fields=("sup_return_period as return_period", "classification"), + distinct=True, + ) + ): + return + + log = frappe.qb.DocType("GSTR Import Log") + ( + frappe.qb.update(log) + .set(log.data_not_found, 0) + .where(log.data_not_found == 1) + .where( + Criterion.any( + (log.return_period == doc.return_period) + & (log.classification == doc.classification) + for doc in inward_supplies + ) + ) + .run() + ) + + +def _download_gstr_2a(gstin, return_period, json_data): + json_data.gstin = gstin + json_data.fp = return_period + save_gstr_2a(gstin, return_period, json_data) + + +GSTR_FUNCTIONS = { + ReturnType.GSTR2A.value: _download_gstr_2a, + ReturnType.GSTR2B.value: save_gstr_2b, +} + + +def download_queued_request(): + queued_requests = frappe.get_all( + "GSTR Import Log", + filters={"request_id": ["is", "set"]}, + fields=[ + "name", + "gstin", + "return_type", + "classification", + "return_period", + "request_id", + "request_time", + ], + ) + + if not queued_requests: + return toggle_scheduled_jobs(stopped=True) + + for doc in queued_requests: + frappe.enqueue(_download_queued_request, queue="long", doc=doc) + + +def _download_queued_request(doc): + try: + api = ReturnsAPI(doc.gstin) + response = api.download_files( + doc.return_period, + doc.request_id, + ) + + except Exception as e: + frappe.db.delete("GSTR Import Log", {"name": doc.name}) + raise e + + if response.error_type in ["otp_requested", "invalid_otp"]: + return toggle_scheduled_jobs(stopped=True) + + if response.error_type == "no_docs_found": + return create_import_log( + doc.gstin, + doc.return_type, + doc.return_period, + doc.classification, + data_not_found=True, + ) + + if response.error_type == "queued": + return + + if response.error_type: + return frappe.db.delete("GSTR Import Log", {"name": doc.name}) + + frappe.db.set_value("GSTR Import Log", doc.name, "request_id", None) + GSTR_FUNCTIONS[doc.return_type](doc.gstin, doc.return_period, response) + + +def show_queued_message(): + frappe.msgprint( + _( + "Some returns are queued for download at GSTN as there may be over" + " 10000 records. Please check the imports after about 35 minutes." + ) + ) diff --git a/india_compliance/gst_india/utils/gstr/gstr.py b/india_compliance/gst_india/utils/gstr/gstr.py new file mode 100644 index 000000000..afa480ac4 --- /dev/null +++ b/india_compliance/gst_india/utils/gstr/gstr.py @@ -0,0 +1,124 @@ +import frappe + +from india_compliance.gst_india.constants import STATE_NUMBERS +from india_compliance.gst_india.doctype.gst_inward_supply.gst_inward_supply import ( + create_inward_supply, +) + + +def get_mapped_value(value, mapping): + return mapping.get(value) + + +class GSTR: + # Maps of API keys to doctype fields + KEY_MAPS = frappe._dict() + + # Maps of API values to doctype values + VALUE_MAPS = frappe._dict( + { + "Y_N_to_check": {"Y": 1, "N": 0}, + "yes_no": {"Y": "Yes", "N": "No"}, + "gst_category": { + "R": "Regular", + "SEZWP": "SEZ supplies with payment of tax", + "SEZWOP": "SEZ supplies with out payment of tax", + "DE": "Deemed exports", + "CBW": "Intra-State Supplies attracting IGST", + }, + "states": {value: f"{value}-{key}" for key, value in STATE_NUMBERS.items()}, + "note_type": {"C": "Credit Note", "D": "Debit Note"}, + "isd_type_2a": {"ISDCN": "ISD Credit Note", "ISD": "ISD Invoice"}, + "isd_type_2b": {"ISDC": "ISD Credit Note", "ISDI": "ISD Invoice"}, + "amend_type": { + "R": "Receiver GSTIN Amended", + "N": "Invoice Number Amended", + "D": "Other Details Amended", + }, + } + ) + + def __init__(self, company, gstin, return_period, data, gen_date_2b): + self.company = company + self.gstin = gstin + self.return_period = return_period + self._data = data + self.gen_date_2b = gen_date_2b + self.setup() + + def setup(self): + pass + + def create_transactions(self, category, suppliers): + if not suppliers: + return + + transactions = self.get_all_transactions(category, suppliers) + total_transactions = len(transactions) + current_transaction = 0 + + for transaction in transactions: + create_inward_supply(transaction) + + current_transaction += 1 + frappe.publish_realtime( + "update_transactions_progress", + { + "current_progress": current_transaction * 100 / total_transactions, + "return_period": self.return_period, + }, + user=frappe.session.user, + doctype="Purchase Reconciliation Tool", + ) + + def get_all_transactions(self, category, suppliers): + transactions = [] + for supplier in suppliers: + transactions.extend(self.get_supplier_transactions(category, supplier)) + + self.update_gstins() + + return transactions + + def get_supplier_transactions(self, category, supplier): + return [ + self.get_transaction( + category, frappe._dict(supplier), frappe._dict(invoice) + ) + for invoice in supplier.get(self.get_key("invoice_key")) + ] + + def get_transaction(self, category, supplier, invoice): + return frappe._dict( + company=self.company, + company_gstin=self.gstin, + # TODO: change classification to gstr_category + classification=category.value, + **self.get_supplier_details(supplier), + **self.get_invoice_details(invoice), + items=self.get_transaction_items(invoice), + ) + + def get_supplier_details(self, supplier): + return {} + + def get_invoice_details(self, invoice): + return {} + + def get_transaction_items(self, invoice): + return [ + self.get_transaction_item(frappe._dict(item)) + for item in invoice.get(self.get_key("items_key")) + ] + + def get_transaction_item(self, item): + return frappe._dict() + + def get_key(self, key): + return self.KEY_MAPS.get(key) + + def set_key(self, key, value): + self.KEY_MAPS[key] = value + + def update_gstins(self): + pass diff --git a/india_compliance/gst_india/utils/gstr/gstr_2a.py b/india_compliance/gst_india/utils/gstr/gstr_2a.py new file mode 100644 index 000000000..ca9767d45 --- /dev/null +++ b/india_compliance/gst_india/utils/gstr/gstr_2a.py @@ -0,0 +1,247 @@ +from datetime import datetime + +import frappe + +from india_compliance.gst_india.utils import get_datetime, parse_datetime +from india_compliance.gst_india.utils.gstr.gstr import GSTR, get_mapped_value + + +def map_date_format(date_str, source_format, target_format): + return date_str and datetime.strptime(date_str, source_format).strftime( + target_format + ) + + +class GSTR2a(GSTR): + def setup(self): + self.all_gstins = set() + self.cancelled_gstins = {} + + def get_supplier_details(self, supplier): + supplier_details = { + "supplier_gstin": supplier.ctin, + "gstr_1_filled": get_mapped_value( + supplier.cfs, self.VALUE_MAPS.Y_N_to_check + ), + "gstr_3b_filled": get_mapped_value( + supplier.cfs3b, self.VALUE_MAPS.Y_N_to_check + ), + "gstr_1_filing_date": parse_datetime(supplier.fldtr1), + "registration_cancel_date": parse_datetime(supplier.dtcancel), + "sup_return_period": map_date_format(supplier.flprdr1, "%b-%y", "%m%Y"), + } + + self.update_gstins_list(supplier_details) + + return supplier_details + + def update_gstins_list(self, supplier_details): + self.all_gstins.add(supplier_details.get("supplier_gstin")) + + if supplier_details.get("registration_cancel_date"): + self.cancelled_gstins.setdefault( + supplier_details.get("supplier_gstin"), + supplier_details.get("registration_cancel_date"), + ) + + # item details are in item_det for GSTR2a + def get_transaction_items(self, invoice): + return [ + self.get_transaction_item( + frappe._dict(item.get("itm_det", {})), item.get("num", 0) + ) + for item in invoice.get(self.get_key("items_key")) + ] + + def get_transaction_item(self, item, item_number=None): + return { + "item_number": item_number, + "rate": item.rt, + "taxable_value": item.txval, + "igst": item.iamt, + "cgst": item.camt, + "sgst": item.samt, + "cess": item.csamt, + } + + def update_gstins(self): + if not self.all_gstins: + return + + frappe.db.set_value( + "GSTIN", + {"name": ("in", self.all_gstins)}, + "last_updated_on", + get_datetime(), + ) + if not self.cancelled_gstins: + return + + cancelled_gstins_to_update = frappe.db.get_all( + "GSTIN", + filters={ + "name": ("in", self.cancelled_gstins), + "status": ("!=", "Cancelled"), + }, + pluck="name", + ) + + for gstin in cancelled_gstins_to_update: + cancelled_date = self.cancelled_gstins.get(gstin) + frappe.db.set_value( + "GSTIN", + gstin, + {"cancelled_date": cancelled_date, "status": "Cancelled"}, + ) + + +class GSTR2aB2B(GSTR2a): + def setup(self): + super().setup() + self.set_key("invoice_key", "inv") + self.set_key("items_key", "itms") + + def get_invoice_details(self, invoice): + return { + "bill_no": invoice.inum, + "supply_type": get_mapped_value( + invoice.inv_typ, self.VALUE_MAPS.gst_category + ), + "bill_date": parse_datetime(invoice.idt, day_first=True), + "document_value": invoice.val, + "place_of_supply": get_mapped_value(invoice.pos, self.VALUE_MAPS.states), + "other_return_period": map_date_format(invoice.aspd, "%b-%y", "%m%Y"), + "amendment_type": get_mapped_value( + invoice.atyp, self.VALUE_MAPS.amend_type + ), + "is_reverse_charge": get_mapped_value( + invoice.rchrg, self.VALUE_MAPS.Y_N_to_check + ), + "diffprcnt": get_mapped_value( + invoice.diff_percent, {1: 1, 0.65: 0.65, None: 1} + ), + "irn_source": invoice.srctyp, + "irn_number": invoice.irn, + "irn_gen_date": parse_datetime(invoice.irngendate, day_first=True), + "doc_type": "Invoice", + } + + +class GSTR2aB2BA(GSTR2aB2B): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "original_bill_no": invoice.oinum, + "original_bill_date": parse_datetime(invoice.oidt, day_first=True), + } + ) + return invoice_details + + +class GSTR2aCDNR(GSTR2aB2B): + def setup(self): + super().setup() + self.set_key("invoice_key", "nt") + + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "bill_no": invoice.nt_num, + "doc_type": get_mapped_value(invoice.ntty, self.VALUE_MAPS.note_type), + "bill_date": parse_datetime(invoice.nt_dt, day_first=True), + } + ) + return invoice_details + + +class GSTR2aCDNRA(GSTR2aCDNR): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "original_bill_no": invoice.ont_num, + "original_bill_date": parse_datetime(invoice.ont_dt, day_first=True), + "original_doc_type": get_mapped_value( + invoice.ntty, self.VALUE_MAPS.note_type + ), + } + ) + return invoice_details + + +class GSTR2aISD(GSTR2a): + def setup(self): + super().setup() + self.set_key("invoice_key", "doclist") + + def get_invoice_details(self, invoice): + return { + "doc_type": get_mapped_value( + invoice.isd_docty, self.VALUE_MAPS.isd_type_2a + ), + "bill_no": invoice.docnum, + "bill_date": parse_datetime(invoice.docdt, day_first=True), + "itc_availability": get_mapped_value( + invoice.itc_elg, self.VALUE_MAPS.yes_no + ), + "other_return_period": map_date_format(invoice.aspd, "%b-%y", "%m%Y"), + "is_amended": 1 if invoice.atyp else 0, + "amendment_type": get_mapped_value( + invoice.atyp, self.VALUE_MAPS.amend_type + ), + "document_value": invoice.iamt + invoice.camt + invoice.samt + invoice.cess, + } + + def get_transaction_item(self, item, item_number=None): + transaction_item = super().get_transaction_item(item) + transaction_item["cess"] = item.cess + return transaction_item + + # item details are included in invoice details + def get_transaction_items(self, invoice): + return [self.get_transaction_item(invoice)] + + +class GSTR2aISDA(GSTR2aISD): + pass + + +class GSTR2aIMPG(GSTR2a): + def get_supplier_details(self, supplier): + return {} + + def get_invoice_details(self, invoice): + return { + "doc_type": "Bill of Entry", # custom field + "bill_no": invoice.benum, + "bill_date": parse_datetime(invoice.bedt, day_first=True), + "is_amended": get_mapped_value(invoice.amd, self.VALUE_MAPS.Y_N_to_check), + "port_code": invoice.portcd, + "document_value": invoice.txval + invoice.iamt + invoice.csamt, + } + + # invoice details are included in supplier details + def get_supplier_transactions(self, category, supplier): + return [ + self.get_transaction( + category, frappe._dict(supplier), frappe._dict(supplier) + ) + ] + + # item details are included in invoice details + def get_transaction_items(self, invoice): + return [self.get_transaction_item(invoice)] + + +class GSTR2aIMPGSEZ(GSTR2aIMPG): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "supplier_gstin": invoice.sgstin, + "supplier_name": invoice.tdname, + } + ) + return invoice_details diff --git a/india_compliance/gst_india/utils/gstr/gstr_2b.py b/india_compliance/gst_india/utils/gstr/gstr_2b.py new file mode 100644 index 000000000..a1165623e --- /dev/null +++ b/india_compliance/gst_india/utils/gstr/gstr_2b.py @@ -0,0 +1,186 @@ +import frappe + +from india_compliance.gst_india.utils import parse_datetime +from india_compliance.gst_india.utils.gstr.gstr import GSTR, get_mapped_value + + +class GSTR2b(GSTR): + def get_transaction(self, category, supplier, invoice): + transaction = super().get_transaction(category, supplier, invoice) + transaction.return_period_2b = self.return_period + transaction.gen_date_2b = parse_datetime(self.gen_date_2b, day_first=True) + return transaction + + def get_supplier_details(self, supplier): + return { + "supplier_gstin": supplier.ctin, + "supplier_name": supplier.trdnm, + "gstr_1_filing_date": parse_datetime(supplier.supfildt, day_first=True), + "sup_return_period": supplier.supprd, + } + + def get_transaction_item(self, item): + return { + "item_number": item.num, + "rate": item.rt, + "taxable_value": item.txval, + "igst": item.igst, + "cgst": item.cgst, + "sgst": item.sgst, + "cess": item.cess, + } + + +class GSTR2bB2B(GSTR2b): + def setup(self): + super().setup() + self.set_key("invoice_key", "inv") + self.set_key("items_key", "items") + + def get_invoice_details(self, invoice): + return { + "bill_no": invoice.inum, + "supply_type": get_mapped_value(invoice.typ, self.VALUE_MAPS.gst_category), + "bill_date": parse_datetime(invoice.dt, day_first=True), + "document_value": invoice.val, + "place_of_supply": get_mapped_value(invoice.pos, self.VALUE_MAPS.states), + "is_reverse_charge": get_mapped_value( + invoice.rev, self.VALUE_MAPS.Y_N_to_check + ), + "itc_availability": get_mapped_value( + invoice.itcavl, {**self.VALUE_MAPS.yes_no, "T": "Temporary"} + ), + "reason_itc_unavailability": get_mapped_value( + invoice.rsn, + { + "P": ( + "POS and supplier state are same but recipient state is" + " different" + ), + "C": "Return filed post annual cut-off", + }, + ), + "diffprcnt": get_mapped_value( + invoice.diffprcnt, {1: 1, 0.65: 0.65, None: 1} + ), + "irn_source": invoice.srctyp, + "irn_number": invoice.irn, + "irn_gen_date": parse_datetime(invoice.irngendate, day_first=True), + "doc_type": "Invoice", # Custom Field + } + + +class GSTR2bB2BA(GSTR2bB2B): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "original_bill_no": invoice.oinum, + "original_bill_date": parse_datetime(invoice.oidt, day_first=True), + } + ) + return invoice_details + + +class GSTR2bCDNR(GSTR2bB2B): + def setup(self): + super().setup() + self.set_key("invoice_key", "nt") + + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "bill_no": invoice.ntnum, + "doc_type": get_mapped_value(invoice.typ, self.VALUE_MAPS.note_type), + "supply_type": get_mapped_value( + invoice.suptyp, self.VALUE_MAPS.gst_category + ), + } + ) + return invoice_details + + +class GSTR2bCDNRA(GSTR2bCDNR): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "original_bill_no": invoice.ontnum, + "original_bill_date": parse_datetime(invoice.ontdt, day_first=True), + "original_doc_type": get_mapped_value( + invoice.onttyp, self.VALUE_MAPS.note_type + ), + } + ) + return invoice_details + + +class GSTR2bISD(GSTR2b): + def setup(self): + super().setup() + self.set_key("invoice_key", "doclist") + + def get_invoice_details(self, invoice): + return { + "doc_type": get_mapped_value(invoice.doctyp, self.VALUE_MAPS.isd_type_2b), + "bill_no": invoice.docnum, + "bill_date": parse_datetime(invoice.docdt, day_first=True), + "itc_availability": get_mapped_value( + invoice.itcelg, self.VALUE_MAPS.yes_no + ), + "document_value": invoice.igst + invoice.cgst + invoice.sgst + invoice.cess, + } + + # item details are included in invoice details + def get_transaction_items(self, invoice): + return [self.get_transaction_item(invoice)] + + +class GSTR2bISDA(GSTR2bISD): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "original_bill_no": invoice.odocnum, + "original_bill_date": parse_datetime(invoice.odocdt, day_first=True), + "original_doc_type": get_mapped_value( + invoice.odoctyp, self.VALUE_MAPS.isd_type_2b + ), + } + ) + return invoice_details + + +class GSTR2bIMPGSEZ(GSTR2b): + def setup(self): + super().setup() + self.set_key("invoice_key", "boe") + + def get_invoice_details(self, invoice): + return { + "doc_type": "Bill of Entry", # custom field + "bill_no": invoice.boenum, + "bill_date": parse_datetime(invoice.boedt, day_first=True), + "is_amended": get_mapped_value(invoice.isamd, self.VALUE_MAPS.Y_N_to_check), + "port_code": invoice.portcode, + "document_value": invoice.txval + invoice.igst + invoice.cess, + "itc_availability": "Yes", # always available + } + + # item details are included in invoice details + def get_transaction_items(self, invoice): + return [self.get_transaction_item(invoice)] + + +class GSTR2bIMPG(GSTR2bIMPGSEZ): + def get_supplier_details(self, supplier): + return {} + + # invoice details are included in supplier details + def get_supplier_transactions(self, category, supplier): + return [ + self.get_transaction( + category, frappe._dict(supplier), frappe._dict(supplier) + ) + ] diff --git a/india_compliance/gst_india/utils/gstr/test_gstr_2a.py b/india_compliance/gst_india/utils/gstr/test_gstr_2a.py new file mode 100644 index 000000000..6139d0e9d --- /dev/null +++ b/india_compliance/gst_india/utils/gstr/test_gstr_2a.py @@ -0,0 +1,325 @@ +from datetime import date, timedelta +from unittest.mock import Mock, patch + +import frappe +from frappe import parse_json, read_file +from frappe.tests.utils import FrappeTestCase +from frappe.utils import get_datetime + +from india_compliance.gst_india.utils import get_data_file_path +from india_compliance.gst_india.utils.gstr import ( + GSTRCategory, + ReturnType, + download_gstr_2a, + save_gstr_2a, +) + + +class TestGSTRMixin: + def get_doc(self, category): + docname = frappe.get_value( + self.doctype, + {"company_gstin": self.gstin, "classification": category.value}, + ) + self.assertIsNotNone(docname) + return frappe.get_doc(self.doctype, docname) + + def assertImportLog(self, category=None): + if category: + return_type = ReturnType.GSTR2A + else: + return_type = ReturnType.GSTR2B + + filters = {"gstin": self.gstin, "return_type": return_type} + if category: + filters["classification"] = category.value + + docname, last_updated_on = frappe.get_value( + self.log_doctype, filters, ["name", "last_updated_on"] + ) + self.assertIsNotNone(docname) + self.assertAlmostEqual( + last_updated_on, get_datetime(), delta=timedelta(minutes=2) + ) + + +class TestGSTR2a(FrappeTestCase, TestGSTRMixin): + # Tests as per version 2.1 of GSTR2A Dt: 14-10-2020 + # TODO: make tests for individual categories + @classmethod + def setUpClass(cls): + cls.gstin = "01AABCE2207R1Z5" + cls.return_period = "032020" + cls.doctype = "GST Inward Supply" + cls.log_doctype = "GSTR Import Log" + cls.test_data = parse_json(read_file(get_data_file_path("test_gstr_2a.json"))) + + save_gstr_2a( + cls.gstin, + cls.return_period, + cls.test_data.copy(), + ) + + @classmethod + def tearDownClass(cls): + frappe.db.delete(cls.doctype, {"company_gstin": cls.gstin}) + frappe.db.delete(cls.log_doctype, {"gstin": cls.gstin}) + + @patch("india_compliance.gst_india.utils.gstr.save_gstr") + @patch("india_compliance.gst_india.utils.gstr.GSTR2aAPI") + def test_download_gstr_2a(self, mock_gstr_2a_api, mock_save_gstr): + def mock_get_data(action, return_period, otp): + if action in ["B2B", "B2BA", "CDN", "CDNA"]: + return frappe._dict({action.lower(): self.test_data[action.lower()]}) + else: + return frappe._dict(error_type="no_docs_found") + + def mock_save_gstr_func(gstin, return_type, return_period, json_data): + self.assertEqual(gstin, self.gstin) + self.assertEqual(return_period, self.return_period) + self.assertTrue("cdnr" in json_data) + self.assertTrue("cdnra" in json_data) + self.assertTrue("isd" not in json_data) + self.assertListEqual(json_data.cdnr, self.test_data.cdn) + + mock_gstr_2a_api.return_value = Mock() + mock_gstr_2a_api.return_value.get_data.side_effect = mock_get_data + mock_save_gstr.side_effect = mock_save_gstr_func + download_gstr_2a(self.gstin, (self.return_period,)) + + def test_gstr2a_b2b(self): + doc = self.get_doc(GSTRCategory.B2B) + self.assertImportLog(GSTRCategory.B2B) + self.assertDocumentEqual( + { + "bill_date": date(2016, 11, 24), + "bill_no": "S008400", + "doc_type": "Invoice", + "supplier_gstin": "01AABCE2207R1Z5", + "supply_type": "Regular", + "place_of_supply": "06-Haryana", + "items": [ + { + "item_number": 1, + "taxable_value": 400, + "rate": 5.00, + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + }, + ], + "document_value": 729248.16, + "diffprcnt": "1", + "other_return_period": "122018", + "amendment_type": "Receiver GSTIN Amended", + "sup_return_period": "112019", + "gstr_1_filled": 1, + "gstr_3b_filled": 1, + "gstr_1_filing_date": date(2019, 11, 18), + "registration_cancel_date": date(2019, 8, 27), + }, + doc, + ) + + def test_gstr2a_b2ba(self): + doc = self.get_doc(GSTRCategory.B2BA) + self.assertImportLog(GSTRCategory.B2BA) + self.assertDocumentEqual( + { + "bill_date": date(2016, 11, 24), + "bill_no": "S008400", + "doc_type": "Invoice", + "supplier_gstin": "01AABCE2207R1Z5", + "supply_type": "Regular", + "place_of_supply": "06-Haryana", + "items": [ + { + "item_number": 1, + "taxable_value": 6210.99, + "rate": 1.00, + "igst": 0, + "cgst": 614.44, + "sgst": 5.68, + "cess": 621.09, + }, + { + "item_number": 2, + "taxable_value": 1000.05, + "rate": 2.00, + "igst": 0, + "cgst": 887.44, + "sgst": 5.68, + "cess": 50.12, + }, + ], + "document_value": 729248.16, + "diffprcnt": "0.65", + "other_return_period": "122018", + "amendment_type": "Receiver GSTIN Amended", + "original_bill_no": "S008400", + "original_bill_date": date(2016, 11, 24), + "is_amended": 1, + "sup_return_period": "042018", + "gstr_1_filled": 1, + "gstr_3b_filled": 1, + "gstr_1_filing_date": date(2020, 5, 12), + "registration_cancel_date": date(2019, 8, 27), + }, + doc, + ) + + def test_gstr2a_cdn(self): + doc = self.get_doc(GSTRCategory.CDNR) + self.assertImportLog(GSTRCategory.CDNR) + self.assertDocumentEqual( + { + "bill_date": date(2018, 9, 23), + "bill_no": "533515", + "doc_type": "Credit Note", + "supplier_gstin": "01AAAAP1208Q1ZS", + "supply_type": "Regular", + "place_of_supply": "06-Haryana", + "items": [ + { + "item_number": 1, + "taxable_value": 6210.99, + "rate": 10.1, + "igst": 0, + "cgst": 614.44, + "sgst": 5.68, + "cess": 621.09, + } + ], + "document_value": 729248.16, + "diffprcnt": "0.65", + "other_return_period": "122018", + "amendment_type": "Receiver GSTIN Amended", + "sup_return_period": "042018", + "gstr_1_filled": 1, + "gstr_3b_filled": 1, + "gstr_1_filing_date": date(2020, 5, 12), + "registration_cancel_date": date(2019, 8, 27), + "irn_source": "e-Invoice", + "irn_number": ( + "897ADG56RTY78956HYUG90BNHHIJK453GFTD99845672FDHHHSHGFH4567FG56TR" + ), + "irn_gen_date": date(2019, 12, 24), + }, + doc, + ) + + def test_gstr2a_cdna(self): + doc = self.get_doc(GSTRCategory.CDNRA) + self.assertImportLog(GSTRCategory.CDNRA) + self.assertDocumentEqual( + { + "bill_date": date(2018, 9, 23), + "bill_no": "533515", + "doc_type": "Credit Note", + "supplier_gstin": "01AAAAP1208Q1ZS", + "supply_type": "Regular", + "place_of_supply": "01-Jammu and Kashmir", + "items": [ + { + "item_number": 1, + "taxable_value": 400, + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + } + ], + "document_value": 729248.16, + "diffprcnt": "1", + "other_return_period": "122018", + "amendment_type": "Receiver GSTIN Amended", + "original_bill_no": "533515", + "original_bill_date": date(2016, 9, 23), + "original_doc_type": "Credit Note", + "sup_return_period": "112019", + "gstr_1_filled": 1, + "gstr_3b_filled": 1, + "gstr_1_filing_date": date(2019, 11, 18), + "registration_cancel_date": date(2019, 8, 27), + }, + doc, + ) + + def test_gstr2a_isd(self): + doc = self.get_doc(GSTRCategory.ISD) + self.assertImportLog(GSTRCategory.ISD) + self.assertDocumentEqual( + { + "bill_date": date(2016, 3, 3), + "bill_no": "S0080", + "doc_type": "ISD Invoice", + "supplier_gstin": "16DEFPS8555D1Z7", + "itc_availability": "Yes", + "other_return_period": "122018", + "amendment_type": "Receiver GSTIN Amended", + "is_amended": 1, + "document_value": 80, + "items": [ + { + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 20, + } + ], + }, + doc, + ) + + def test_gstr2a_isda(self): + # No such API exists. Its merged with ISD. + pass + + def test_gstr2a_impg(self): + doc = self.get_doc(GSTRCategory.IMPG) + self.assertImportLog(GSTRCategory.IMPG) + self.assertDocumentEqual( + { + "bill_date": date(2019, 11, 18), + "port_code": "18272A", + "bill_no": "2566282", + "doc_type": "Bill of Entry", + "is_amended": 0, + "document_value": 246.54, + "items": [ + { + "taxable_value": 123.02, + "igst": 123.02, + "cess": 0.5, + } + ], + }, + doc, + ) + + def test_gstr2a_impgsez(self): + doc = self.get_doc(GSTRCategory.IMPGSEZ) + self.assertImportLog(GSTRCategory.IMPGSEZ) + self.assertDocumentEqual( + { + "bill_date": date(2019, 11, 18), + "port_code": "18272A", + "bill_no": "2566282", + "doc_type": "Bill of Entry", + "supplier_gstin": self.gstin, + "supplier_name": "GSTN", + "is_amended": 0, + "document_value": 246.54, + "items": [ + { + "taxable_value": 123.02, + "igst": 123.02, + "cgst": 0, + "sgst": 0, + "cess": 0.5, + } + ], + }, + doc, + ) diff --git a/india_compliance/gst_india/utils/gstr/test_gstr_2b.py b/india_compliance/gst_india/utils/gstr/test_gstr_2b.py new file mode 100644 index 000000000..3d7002b6b --- /dev/null +++ b/india_compliance/gst_india/utils/gstr/test_gstr_2b.py @@ -0,0 +1,297 @@ +from datetime import date + +import frappe +from frappe import parse_json, read_file +from frappe.tests.utils import FrappeTestCase + +from india_compliance.gst_india.utils import get_data_file_path +from india_compliance.gst_india.utils.gstr import GSTRCategory, save_gstr_2b +from india_compliance.gst_india.utils.gstr.test_gstr_2a import TestGSTRMixin + + +class TestGSTR2b(FrappeTestCase, TestGSTRMixin): + @classmethod + def setUpClass(cls): + cls.gstin = "01AABCE2207R1Z5" + cls.return_period = "032020" + cls.doctype = "GST Inward Supply" + cls.log_doctype = "GSTR Import Log" + cls.test_data = parse_json(read_file(get_data_file_path("test_gstr_2b.json"))) + + save_gstr_2b( + cls.gstin, + cls.return_period, + cls.test_data, + ) + + @classmethod + def tearDownClass(cls): + frappe.db.delete(cls.doctype, {"company_gstin": cls.gstin}) + frappe.db.delete(cls.log_doctype, {"gstin": cls.gstin}) + + def test_gstr2b_b2b(self): + doc = self.get_doc(GSTRCategory.B2B) + self.assertImportLog() + self.assertDocumentEqual( + { + "company_gstin": "01AABCE2207R1Z5", + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "supplier_gstin": "01AABCE2207R1Z5", + "supplier_name": "GSTN", + "gstr_1_filing_date": date(2019, 11, 18), + "sup_return_period": "112019", + "bill_no": "S008400", + "supply_type": "Regular", + "bill_date": date(2016, 11, 24), + "document_value": 729248.16, + "place_of_supply": "06-Haryana", + "is_reverse_charge": 0, + "itc_availability": "No", + "reason_itc_unavailability": ( + "POS and supplier state are same but recipient state is different" + ), + "diffprcnt": "1", + "irn_source": "e-Invoice", + "irn_number": ( + "897ADG56RTY78956HYUG90BNHHIJK453GFTD99845672FDHHHSHGFH4567FG56TR" + ), + "irn_gen_date": date(2019, 12, 24), + "doc_type": "Invoice", + "items": [ + { + "item_number": 1, + "rate": 5, + "taxable_value": 400, + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + } + ], + }, + doc, + ) + + def test_gstr2b_b2ba(self): + doc = self.get_doc(GSTRCategory.B2BA) + self.assertDocumentEqual( + { + "company_gstin": "01AABCE2207R1Z5", + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "supplier_gstin": "01AABCE2207R1Z5", + "supplier_name": "GSTN", + "gstr_1_filing_date": date(2019, 11, 18), + "sup_return_period": "112019", + "bill_no": "S008400", + "supply_type": "Regular", + "bill_date": date(2016, 11, 24), + "document_value": 729248.16, + "place_of_supply": "06-Haryana", + "is_reverse_charge": 0, + "itc_availability": "No", + "reason_itc_unavailability": ( + "POS and supplier state are same but recipient state is different" + ), + "diffprcnt": "1", + "original_bill_no": "S008400", + "original_bill_date": date(2016, 11, 24), + "doc_type": "Invoice", + "items": [ + { + "item_number": 1, + "rate": 5, + "taxable_value": 400, + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + } + ], + }, + doc, + ) + + def test_gstr2b_cdnr(self): + doc = self.get_doc(GSTRCategory.CDNR) + self.assertDocumentEqual( + { + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "supplier_gstin": "01AAAAP1208Q1ZS", + "supplier_name": "GSTN", + "gstr_1_filing_date": date(2019, 11, 18), + "sup_return_period": "112019", + "bill_no": "533515", + "supply_type": "Regular", + "bill_date": date(2016, 9, 23), + "document_value": 729248.16, + "place_of_supply": "01-Jammu and Kashmir", + "is_reverse_charge": 0, + "itc_availability": "No", + "reason_itc_unavailability": "Return filed post annual cut-off", + "diffprcnt": "1", + "irn_source": "e-Invoice", + "irn_number": ( + "897ADG56RTY78956HYUG90BNHHIJK453GFTD99845672FDHHHSHGFH4567FG56TR" + ), + "irn_gen_date": date(2019, 12, 24), + "doc_type": "Credit Note", + "items": [ + { + "item_number": 1, + "rate": 5, + "taxable_value": 400, + "igst": 400, + "cgst": 0, + "sgst": 0, + "cess": 0, + } + ], + }, + doc, + ) + + def test_gstr2b_cdnra(self): + doc = self.get_doc(GSTRCategory.CDNRA) + self.assertDocumentEqual( + { + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "supplier_gstin": "01AAAAP1208Q1ZS", + "supplier_name": "GSTN", + "gstr_1_filing_date": date(2019, 11, 18), + "sup_return_period": "112019", + "original_bill_no": "533515", + "original_bill_date": date(2016, 9, 23), + "original_doc_type": "Credit Note", + "bill_no": "533515", + "supply_type": "Regular", + "bill_date": date(2016, 9, 23), + "document_value": 729248.16, + "place_of_supply": "01-Jammu and Kashmir", + "is_reverse_charge": 0, + "itc_availability": "No", + "reason_itc_unavailability": "Return filed post annual cut-off", + "diffprcnt": "1", + "doc_type": "Credit Note", + "items": [ + { + "item_number": 1, + "rate": 5, + "taxable_value": 400, + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + } + ], + }, + doc, + ) + + def test_gstr2b_isd(self): + doc = self.get_doc(GSTRCategory.ISD) + self.assertDocumentEqual( + { + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "gstr_1_filing_date": date(2020, 3, 2), + "sup_return_period": "022020", + "supplier_gstin": "16DEFPS8555D1Z7", + "supplier_name": "GSTN", + "doc_type": "ISD Invoice", + "bill_no": "S0080", + "bill_date": date(2016, 3, 3), + "itc_availability": "Yes", + "document_value": 400, + "items": [ + { + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + } + ], + }, + doc, + ) + + def test_gstr2b_isda(self): + doc = self.get_doc(GSTRCategory.ISDA) + self.assertDocumentEqual( + { + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "gstr_1_filing_date": date(2020, 3, 2), + "sup_return_period": "022020", + "supplier_gstin": "16DEFPS8555D1Z7", + "supplier_name": "GSTN", + "original_doc_type": "ISD Credit Note", + "original_bill_no": "1004", + "original_bill_date": date(2016, 3, 2), + "doc_type": "ISD Invoice", + "bill_no": "S0080", + "bill_date": date(2016, 3, 3), + "itc_availability": "Yes", + "document_value": 400, + "items": [ + { + "igst": 0, + "cgst": 200, + "sgst": 200, + "cess": 0, + } + ], + }, + doc, + ) + + def test_gstr2b_impg(self): + doc = self.get_doc(GSTRCategory.IMPG) + self.assertDocumentEqual( + { + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "doc_type": "Bill of Entry", + "port_code": "18272A", + "bill_no": "2566282", + "bill_date": date(2019, 11, 18), + "is_amended": 0, + "document_value": 246.54, + "items": [ + { + "taxable_value": 123.02, + "igst": 123.02, + "cess": 0.5, + } + ], + }, + doc, + ) + + def test_gstr2b_impgsez(self): + doc = self.get_doc(GSTRCategory.IMPGSEZ) + self.assertDocumentEqual( + { + "return_period_2b": "032020", + "gen_date_2b": date(2020, 4, 14), + "supplier_gstin": "01AABCE2207R1Z5", + "supplier_name": "GSTN", + "doc_type": "Bill of Entry", + "port_code": "18272A", + "bill_no": "2566282", + "bill_date": date(2019, 11, 18), + "is_amended": 0, + "document_value": 246.54, + "items": [ + { + "taxable_value": 123.02, + "igst": 123.02, + "cess": 0.5, + } + ], + }, + doc, + ) diff --git a/india_compliance/gst_india/utils/test_e_invoice.py b/india_compliance/gst_india/utils/test_e_invoice.py index e433202ef..e3dcbf7ff 100644 --- a/india_compliance/gst_india/utils/test_e_invoice.py +++ b/india_compliance/gst_india/utils/test_e_invoice.py @@ -16,6 +16,7 @@ EInvoiceData, cancel_e_invoice, generate_e_invoice, + mark_e_invoice_as_cancelled, validate_e_invoice_applicability, validate_if_e_invoice_can_be_cancelled, ) @@ -476,6 +477,36 @@ def test_cancel_e_invoice(self): ) self.assertDocumentEqual({"ewaybill": ""}, cancelled_doc) + @responses.activate + def test_mark_e_invoice_as_cancelled(self): + """Test for mark e-Invoice as cancelled""" + test_data = self.e_invoice_test_data.get("goods_item_with_ewaybill") + + si = create_sales_invoice(**test_data.get("kwargs"), qty=1000) + + # Mock response for generating irn + self._mock_e_invoice_response(data=test_data) + + generate_e_invoice(si.name) + si.reload() + si.cancel() + + values = frappe._dict( + {"reason": "Others", "remark": "Manually deleted from GSTR-1"} + ) + + mark_e_invoice_as_cancelled("Sales Invoice", si.name, values) + cancelled_doc = frappe.get_doc("Sales Invoice", si.name) + + self.assertDocumentEqual( + {"einvoice_status": "Manually Cancelled", "irn": ""}, + cancelled_doc, + ) + + self.assertTrue( + frappe.get_cached_value("e-Invoice Log", si.irn, "is_cancelled"), 1 + ) + def test_validate_e_invoice_applicability(self): """Test if e_invoicing is applicable""" @@ -606,6 +637,29 @@ def test_invoice_update_after_submit(self): "You have already generated e-Waybill/e-Invoice for this document. This could result in mismatch of item details in e-Waybill/e-Invoice with print format.", ) + @responses.activate + def test_e_invoice_for_duplicate_irn(self): + test_data = self.e_invoice_test_data.get("goods_item_with_ewaybill") + + si = create_sales_invoice(**test_data.get("kwargs"), qty=1000) + + # Mock response for generating irn + self._mock_e_invoice_response(data=test_data) + generate_e_invoice(si.name) + + test_data_with_diff_value = self.e_invoice_test_data.get("duplicate_irn") + + si = create_sales_invoice(rate=1400, is_in_state=True) + self._mock_e_invoice_response(data=test_data_with_diff_value) + + # Assert if Invoice amount has changed + self.assertRaisesRegex( + frappe.ValidationError, + re.compile(r"^(e-Invoice is already available against Invoice.*)$"), + generate_e_invoice, + si.name, + ) + def _cancel_e_invoice(self, invoice_no): values = frappe._dict( {"reason": "Data Entry Mistake", "remark": "Data Entry Mistake"} @@ -660,41 +714,57 @@ def _mock_e_invoice_response(self, data, api="ei/api/invoice"): status=200, ) + # Mock get e_invoice by IRN response + data = self.e_invoice_test_data.get("get_e_invoice_by_irn") + + responses.add( + responses.GET, + url + "/irn", + body=json.dumps(data.get("response_data")), + match=[matchers.query_string_matcher(data.get("request_data"))], + status=200, + ) + def update_dates_for_test_data(test_data): """Update test data for e-invoice and e-waybill""" today = format_date(frappe.utils.today(), "dd/mm/yyyy") - now = now_datetime().strftime("%Y-%m-%d %H:%M:%S") - validity = add_to_date(getdate(), days=1).strftime("%Y-%m-%d %I:%M:%S %p") - - # Update test data for goods_item_with_ewaybill - goods_item = test_data.get("goods_item_with_ewaybill") - goods_item.get("response_data").get("result").update( - { - "EwbDt": now, - "EwbValidTill": validity, - } - ) - - # Update Document Date in given test data - for key in ( - "goods_item_with_ewaybill", - "service_item", - "return_invoice", - "debit_invoice", - "foreign_transaction", - ): - test_data.get(key).get("request_data").get("DocDtls")["Dt"] = today - if exp_details := test_data.get(key).get("request_data").get("ExpDtls"): - exp_details["ShipBDt"] = today - - if "response_data" in test_data.get(key): - test_data.get(key).get("response_data").get("result")["AckDt"] = now - - response = test_data.cancel_e_waybill.get("response_data") - response.get("result")["cancelDate"] = now_datetime().strftime( - "%d/%m/%Y %I:%M:%S %p" - ) - - response = test_data.cancel_e_invoice.get("response_data") - response.get("result")["CancelDate"] = now + current_datetime = now_datetime().strftime("%Y-%m-%d %H:%M:%S") + valid_upto = add_to_date(getdate(), days=1).strftime("%Y-%m-%d %I:%M:%S %p") + + for value in test_data.values(): + if not (value.get("response_data") or value.get("request_data")): + continue + + response_request = ( + value.get("request_data") + if isinstance(value.get("request_data"), dict) + else {} + ) + response_result = ( + value.get("response_data").get("result") + if value.get("response_data") + else {} + ) + + # Handle Duplicate IRN test data + if isinstance(response_result, list): + response_result = response_result[0].get("Desc") + + for k in response_request: + if k == "DocDtls": + response_request[k]["Dt"] = today + elif k == "ExpDtls": + response_request[k]["ShipBDt"] = today + + for k in response_result: + if k == "EwbDt": + response_result[k] = current_datetime + elif k == "EwbValidTill": + response_result[k] = valid_upto + elif k == "AckDt": + response_result[k] = current_datetime + elif k == "cancelDate": + response_result[k] = now_datetime().strftime("%d/%m/%Y %I:%M:%S %p") + elif k == "CancelDate": + response_result[k] = current_datetime diff --git a/india_compliance/gst_india/utils/test_e_waybill.py b/india_compliance/gst_india/utils/test_e_waybill.py index 8fab7b2c3..e78b63c59 100644 --- a/india_compliance/gst_india/utils/test_e_waybill.py +++ b/india_compliance/gst_india/utils/test_e_waybill.py @@ -243,6 +243,7 @@ def test_credit_note_e_waybill(self): self._generate_e_waybill() credit_note = make_return_doc("Sales Invoice", si.name) + credit_note.vehicle_no = "GJ05DL9009" credit_note.save() credit_note.submit() @@ -793,9 +794,10 @@ def test_e_waybill_for_registered_purchase(self): ) # Return Note - return_note = make_return_doc( - "Purchase Invoice", purchase_invoice.name - ).submit() + return_note = make_return_doc("Purchase Invoice", purchase_invoice.name) + return_note.distance = 10 + return_note.vehicle_no = "GJ05DL9009" + return_note.submit() return_pi_data = self.e_waybill_test_data.get( "purchase_return_for_registered_supplier" diff --git a/india_compliance/gst_india/workspace/gst_india/gst_india.json b/india_compliance/gst_india/workspace/gst_india/gst_india.json index 31d35179d..976fc50dc 100644 --- a/india_compliance/gst_india/workspace/gst_india/gst_india.json +++ b/india_compliance/gst_india/workspace/gst_india/gst_india.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"id\":\"Xz6h3FH8sZ\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Pending e-Waybills\",\"col\":4}},{\"id\":\"5wHVnb2VB-\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Pending e-Invoices\",\"col\":4}},{\"id\":\"FT76_s4_M1\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Invoice Cancelled, e-Invoice Active\",\"col\":4}},{\"id\":\"ROouz1137a\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"pSkSY7vg5b\",\"type\":\"header\",\"data\":{\"text\":\"Shortcuts\",\"col\":12}},{\"id\":\"kJscjOHSLN\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"GST Settings\",\"col\":3}},{\"id\":\"BT3ZIyt2_1\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"GSTR-1\",\"col\":3}},{\"id\":\"fVgN1kK1r6\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"GSTR-3B\",\"col\":3}},{\"id\":\"-G2gVutaOP\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"HSN Code\",\"col\":3}},{\"id\":\"sEPHpNM3bl\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"India Compliance Account\",\"col\":3}},{\"id\":\"bVd6Hbw3Yp\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"oN0FbtHAdc\",\"type\":\"header\",\"data\":{\"text\":\"Reports and Masters\",\"col\":12}},{\"id\":\"doCaNmtmY8\",\"type\":\"card\",\"data\":{\"card_name\":\"Sales and Purchase Reports\",\"col\":4}},{\"id\":\"Emy7VbsSYq\",\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"id\":\"2FUZmTJKVp\",\"type\":\"card\",\"data\":{\"card_name\":\"Other GST Reports\",\"col\":4}}]", + "content": "[{\"id\":\"Xz6h3FH8sZ\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Pending e-Waybills\",\"col\":4}},{\"id\":\"5wHVnb2VB-\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Pending e-Invoices\",\"col\":4}},{\"id\":\"FT76_s4_M1\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Invoice Cancelled, e-Invoice Active\",\"col\":4}},{\"id\":\"ROouz1137a\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"pSkSY7vg5b\",\"type\":\"header\",\"data\":{\"text\":\"Shortcuts\",\"col\":12}},{\"id\":\"kJscjOHSLN\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"GST Settings\",\"col\":3}},{\"id\":\"BT3ZIyt2_1\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"GSTR-1\",\"col\":3}},{\"id\":\"fVgN1kK1r6\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"GSTR-3B\",\"col\":3}},{\"id\":\"kxE1gBYYUW\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Reconciliation Tool\",\"col\":3}},{\"id\":\"-G2gVutaOP\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"HSN Code\",\"col\":3}},{\"id\":\"sEPHpNM3bl\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"India Compliance Account\",\"col\":3}},{\"id\":\"bVd6Hbw3Yp\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"oN0FbtHAdc\",\"type\":\"header\",\"data\":{\"text\":\"Reports and Masters\",\"col\":12}},{\"id\":\"doCaNmtmY8\",\"type\":\"card\",\"data\":{\"card_name\":\"Sales and Purchase Reports\",\"col\":4}},{\"id\":\"Emy7VbsSYq\",\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"id\":\"2FUZmTJKVp\",\"type\":\"card\",\"data\":{\"card_name\":\"Other GST Reports\",\"col\":4}}]", "creation": "2022-02-28 19:04:58.655348", "custom_blocks": [], "docstatus": 0, @@ -12,44 +12,6 @@ "is_hidden": 0, "label": "GST India", "links": [ - { - "hidden": 0, - "is_query_report": 0, - "label": "Logs", - "link_count": 3, - "onboard": 0, - "type": "Card Break" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "e-Waybill Log", - "link_count": 0, - "link_to": "e-Waybill Log", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "e-Invoice Log", - "link_count": 0, - "link_to": "e-Invoice Log", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Integration Request", - "link_count": 0, - "link_to": "Integration Request", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "is_query_report": 0, @@ -165,9 +127,57 @@ "link_type": "Report", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Logs", + "link_count": 4, + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "e-Waybill Log", + "link_count": 0, + "link_to": "e-Waybill Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "e-Invoice Log", + "link_count": 0, + "link_to": "e-Invoice Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "GST Inward Supply", + "link_count": 0, + "link_to": "GST Inward Supply", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Integration Request", + "link_count": 0, + "link_to": "Integration Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2023-08-04 11:36:58.261982", + "modified": "2023-09-06 12:35:16.364094", "modified_by": "Administrator", "module": "GST India", "name": "GST India", @@ -204,6 +214,13 @@ "link_to": "GSTR-1", "type": "Report" }, + { + "color": "Grey", + "doc_view": "List", + "label": "Purchase Reconciliation Tool", + "link_to": "Purchase Reconciliation Tool", + "type": "DocType" + }, { "color": "Grey", "doc_view": "List", diff --git a/india_compliance/hooks.py b/india_compliance/hooks.py index 92f92dee8..46e169ea5 100644 --- a/india_compliance/hooks.py +++ b/india_compliance/hooks.py @@ -104,9 +104,9 @@ "validate": "india_compliance.gst_india.overrides.journal_entry.validate", }, "Payment Entry": { - "validate": ( - "india_compliance.gst_india.overrides.payment_entry.update_place_of_supply" - ) + "validate": "india_compliance.gst_india.overrides.payment_entry.validate", + "on_submit": "india_compliance.gst_india.overrides.payment_entry.on_submit", + "on_update_after_submit": "india_compliance.gst_india.overrides.payment_entry.on_update_after_submit", }, "Purchase Invoice": { "onload": "india_compliance.gst_india.overrides.purchase_invoice.onload", @@ -190,6 +190,12 @@ "erpnext.controllers.taxes_and_totals.get_itemised_tax_breakup_data": "india_compliance.gst_india.overrides.transaction.get_itemised_tax_breakup_data", "erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts": "india_compliance.gst_india.overrides.transaction.get_regional_round_off_accounts", "erpnext.controllers.accounts_controller.update_gl_dict_with_regional_fields": "india_compliance.gst_india.overrides.gl_entry.update_gl_dict_with_regional_fields", + "erpnext.controllers.accounts_controller.get_advance_payment_entries_for_regional": ( + "india_compliance.gst_india.overrides.payment_entry.get_advance_payment_entries_for_regional" + ), + "erpnext.accounts.doctype.payment_reconciliation.payment_reconciliation.adjust_allocations_for_taxes": ( + "india_compliance.gst_india.overrides.payment_entry.adjust_allocations_for_taxes_in_payment_reconciliation" + ), "erpnext.accounts.party.get_regional_address_details": ( "india_compliance.gst_india.overrides.transaction.update_party_details" ), @@ -280,6 +286,7 @@ "cron": { "*/5 * * * *": [ "india_compliance.gst_india.utils.e_invoice.retry_e_invoice_generation", + "india_compliance.gst_india.utils.gstr.download_queued_request", ], } } diff --git a/india_compliance/install.py b/india_compliance/install.py index e80e55566..53405187f 100644 --- a/india_compliance/install.py +++ b/india_compliance/install.py @@ -37,6 +37,7 @@ "remove_old_fields", "update_custom_role_for_e_invoice_summary", "update_company_gstin", + "update_payment_entry_fields", ) diff --git a/india_compliance/patches.txt b/india_compliance/patches.txt index 7ba6ef1d0..bb0b858bf 100644 --- a/india_compliance/patches.txt +++ b/india_compliance/patches.txt @@ -3,7 +3,7 @@ execute:import frappe; frappe.delete_doc_if_exists("DocType", "GSTIN") [post_model_sync] india_compliance.patches.v14.set_default_for_overridden_accounts_setting -execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #29 +execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #31 execute:from india_compliance.gst_india.setup import create_property_setters; create_property_setters() #3 india_compliance.patches.post_install.remove_old_fields india_compliance.patches.post_install.update_company_gstin @@ -27,3 +27,6 @@ india_compliance.patches.v14.set_reverse_charge_applicability_in_supplier india_compliance.patches.post_install.update_e_waybill_status india_compliance.patches.post_install.add_einvoice_status_field execute:import frappe; frappe.db.set_single_value("GST Settings", "gstin_status_refresh_interval", 30) +execute:from india_compliance.gst_india.setup import create_email_template; create_email_template() +india_compliance.patches.post_install.update_reconciliation_status +india_compliance.patches.post_install.update_payment_entry_fields diff --git a/india_compliance/patches/check_version_compatibility.py b/india_compliance/patches/check_version_compatibility.py index 971231174..7e21e477c 100644 --- a/india_compliance/patches/check_version_compatibility.py +++ b/india_compliance/patches/check_version_compatibility.py @@ -13,12 +13,12 @@ { "app_name": "Frappe", "current_version": version.parse(frappe.__version__), - "required_versions": {"version-14": "14.42.0"}, + "required_versions": {"version-14": "14.52.0"}, }, { "app_name": "ERPNext", "current_version": version.parse(erpnext.__version__), - "required_versions": {"version-14": "14.35.0"}, + "required_versions": {"version-14": "14.44.0"}, }, ] diff --git a/india_compliance/patches/post_install/update_company_fixtures.py b/india_compliance/patches/post_install/update_company_fixtures.py index c002f6c89..174d43843 100644 --- a/india_compliance/patches/post_install/update_company_fixtures.py +++ b/india_compliance/patches/post_install/update_company_fixtures.py @@ -2,7 +2,8 @@ from erpnext.setup.setup_wizard.operations.taxes_setup import get_or_create_tax_group from india_compliance.gst_india.overrides.company import ( - create_company_fixtures as create_gst_fixtures, + make_default_customs_accounts, + make_default_tax_templates, ) from india_compliance.income_tax_india.overrides.company import ( create_company_fixtures as create_income_tax_fixtures, @@ -27,7 +28,12 @@ def execute(): # GST fixtures update_root_for_rcm(company) if not frappe.db.exists("GST Account", {"company": company}): - create_gst_fixtures(company) + make_default_tax_templates(company) + + if not frappe.db.exists( + "Account", {"company": company, "account_name": "Customs Duty Payable"} + ): + make_default_customs_accounts(company) def update_root_for_rcm(company): diff --git a/india_compliance/patches/post_install/update_payment_entry_fields.py b/india_compliance/patches/post_install/update_payment_entry_fields.py new file mode 100644 index 000000000..93984a072 --- /dev/null +++ b/india_compliance/patches/post_install/update_payment_entry_fields.py @@ -0,0 +1,15 @@ +import frappe + +from india_compliance.gst_india.utils.custom_fields import delete_old_fields + + +def execute(): + if not frappe.db.has_column("Payment Entry", "customer_gstin"): + return + + payment_entry = frappe.qb.DocType("Payment Entry") + frappe.qb.update(payment_entry).set( + payment_entry.billing_address_gstin, payment_entry.customer_gstin + ).run() + + delete_old_fields("customer_gstin", "Payment Entry") diff --git a/india_compliance/patches/post_install/update_reconciliation_status.py b/india_compliance/patches/post_install/update_reconciliation_status.py new file mode 100644 index 000000000..6843fd4d2 --- /dev/null +++ b/india_compliance/patches/post_install/update_reconciliation_status.py @@ -0,0 +1,43 @@ +import frappe +from frappe.query_builder.functions import IfNull + + +def execute(): + PI = frappe.qb.DocType("Purchase Invoice") + PI_ITEM = frappe.qb.DocType("Purchase Invoice Item") + BOE = frappe.qb.DocType("Bill of Entry") + + ( + frappe.qb.update(PI) + .set(PI.reconciliation_status, "Not Applicable") + .join(PI_ITEM) + .on(PI.name == PI_ITEM.parent) + .where(PI.docstatus == 1) + .where( + (IfNull(PI.supplier_gstin, "") == "") + | ( + IfNull(PI.gst_category, "").isin( + ["Registered Composition", "Unregistered", "Overseas"] + ) + ) + | (IfNull(PI.supplier_gstin, "") == PI.company_gstin) + | (IfNull(PI.is_opening, "") == "Yes") + | (PI_ITEM.is_non_gst == 1) + ) + .run() + ) + + ( + frappe.qb.update(PI) + .set(PI.reconciliation_status, "Unreconciled") + .where(PI.docstatus == 1) + .where(IfNull(PI.reconciliation_status, "") == "") + .run() + ) + + ( + frappe.qb.update(BOE) + .set(BOE.reconciliation_status, "Unreconciled") + .where(BOE.docstatus == 1) + .run() + ) diff --git a/india_compliance/public/js/india_compliance.bundle.js b/india_compliance/public/js/india_compliance.bundle.js index 21ec170f0..f4e34791c 100644 --- a/india_compliance/public/js/india_compliance.bundle.js +++ b/india_compliance/public/js/india_compliance.bundle.js @@ -2,3 +2,4 @@ import "./utils"; import "./quick_entry"; import "./transaction"; import "./audit_trail_notification"; +import "./quick_info_popover"; diff --git a/india_compliance/public/js/purchase_reconciliation_tool/data_table_manager.js b/india_compliance/public/js/purchase_reconciliation_tool/data_table_manager.js new file mode 100644 index 000000000..a26900945 --- /dev/null +++ b/india_compliance/public/js/purchase_reconciliation_tool/data_table_manager.js @@ -0,0 +1,144 @@ +frappe.provide("india_compliance"); + +india_compliance.DataTableManager = class DataTableManager { + constructor(options) { + Object.assign(this, options); + this.data = this.data || []; + this.make(); + } + + make() { + this.format_data(this.data); + this.make_no_data(); + this.render_datatable(); + + this.columns_dict = {}; + for (const column of this.datatable.getColumns()) { + const fieldname = column.field || column.id; + this.columns_dict[fieldname] = column; + this.columns_dict[fieldname].$filter_input = $( + `.dt-row-filter .dt-cell--col-${column.colIndex} .dt-filter`, + this.$datatable + )[0]; + } + } + + refresh(data, columns) { + this.data = data; + this.datatable.refresh(data, columns); + } + + get_column(fieldname) { + return this.columns_dict[fieldname]; + } + + get_filter_input(fieldname) { + return this.get_column(fieldname).$filter_input; + } + + make_no_data() { + this.$no_data = + this.$no_data || + $('
No Matching Data Found!
'); + + this.$wrapper.parent().append(this.$no_data); + + this.$no_data.hide(); + } + + get_dt_columns() { + if (!this.columns) return []; + return this.columns.map(this.get_dt_column); + } + + get_dt_column(column) { + const docfield = { + options: column.options || column.doctype, + fieldname: column.fieldname, + fieldtype: column.fieldtype, + link_onclick: column.link_onclick, + precision: column.precision, + }; + column.width = column.width || 100; + + let compareFn = null; + if (docfield.fieldtype === "Date") { + compareFn = (cell, keyword) => { + if (!cell.content) return null; + if (keyword.length !== "YYYY-MM-DD".length) return null; + + const keywordValue = frappe.datetime.user_to_obj(keyword); + const cellValue = frappe.datetime.str_to_obj(cell.content); + return [+cellValue, +keywordValue]; + }; + } + + let format = function (value, row, column, data) { + if (column._value) { + value = column._value(value, column, data); + } + + return frappe.form.get_formatter(column.docfield.fieldtype)( + value, + column.docfield, + { always_show_decimals: true }, + data + ); + }; + + return { + id: column.fieldname, + field: column.fieldname, + name: column.label, + content: column.label, + editable: false, + format, + docfield, + ...column, + }; + } + + format_data() { + if (!Array.isArray(this.data)) { + this.data = Object.values(this.data); + } + + if (!this.format_row) return; + + this.data = this.data.map(this.format_row); + } + + get_checked_items() { + const indices = this.datatable.rowmanager.getCheckedRows(); + return indices.map(index => this.data[index]); + } + + clear_checked_items() { + const { rowmanager } = this.datatable; + rowmanager + .getCheckedRows() + .map(rowIndex => rowmanager.checkRow(rowIndex, false)); + } + + render_datatable() { + const datatable_options = { + dynamicRowHeight: true, + checkboxColumn: true, + inlineFilters: true, + noDataMessage: "No Matching Data Found!", + // clusterize: false, + events: { + onCheckRow: () => { + const checked_items = this.get_checked_items(); + // this.toggle_actions_menu_button(checked_items.length > 0); + }, + }, + cellHeight: 34, + ...this.options, + columns: this.get_dt_columns(), + data: this.data, + }; + this.datatable = new frappe.DataTable(this.$wrapper.get(0), datatable_options); + this.$datatable = $(`.${this.datatable.style.scopeClass}`); + } +}; diff --git a/india_compliance/public/js/purchase_reconciliation_tool/filter_group.js b/india_compliance/public/js/purchase_reconciliation_tool/filter_group.js new file mode 100644 index 000000000..020ed40a0 --- /dev/null +++ b/india_compliance/public/js/purchase_reconciliation_tool/filter_group.js @@ -0,0 +1,62 @@ +frappe.provide("india_compliance"); + +india_compliance.FILTER_OPERATORS = { + "=": (expected_value, value) => value == expected_value, + "!=": (expected_value, value) => value != expected_value, + ">": (expected_value, value) => value > expected_value, + "<": (expected_value, value) => value < expected_value, + ">=": (expected_value, value) => value >= expected_value, + "<=": (expected_value, value) => value <= expected_value, + like: (expected_value, value) => _like(expected_value, value), + "not like": (expected_value, value) => !_like(expected_value, value), + in: (expected_values, value) => expected_values.includes(value), + "not in": (expected_values, value) => !expected_values.includes(value), + is: (expected_value, value) => { + if (expected_value === "set") { + return !!value; + } else { + return !value; + } + }, +}; + +class _Filter extends frappe.ui.Filter { + set_conditions_from_config() { + let filter_options = this.filter_list.filter_options; + if (filter_options) { + filter_options = { ...filter_options }; + if (this.fieldname && this.fieldname !== "name") + delete filter_options.fieldname; + + Object.assign(this, filter_options); + } + + this.conditions = this.conditions.filter( + condition => india_compliance.FILTER_OPERATORS[condition && condition[0]] + ); + } +} + +india_compliance.FilterGroup = class FilterGroup extends frappe.ui.FilterGroup { + _push_new_filter(...args) { + const Filter = frappe.ui.Filter; + try { + frappe.ui.Filter = _Filter; + return super._push_new_filter(...args); + } finally { + frappe.ui.Filter = Filter; + } + } +}; + +function _like(expected_value, value) { + expected_value = expected_value.toLowerCase(); + value = value.toLowerCase(); + + if (!expected_value.endsWith("%")) return value.endsWith(expected_value.slice(1)); + + if (!expected_value.startsWith("%")) + return value.startsWith(expected_value.slice(0, -1)); + + return value.includes(expected_value.slice(1, -1)); +} diff --git a/india_compliance/public/js/purchase_reconciliation_tool/number_card.js b/india_compliance/public/js/purchase_reconciliation_tool/number_card.js new file mode 100644 index 000000000..f3a2d0de8 --- /dev/null +++ b/india_compliance/public/js/purchase_reconciliation_tool/number_card.js @@ -0,0 +1,34 @@ +frappe.provide("india_compliance"); + +india_compliance.NumberCardManager = class NumberCardManager { + constructor(opts) { + Object.assign(this, opts); + this.make_cards(); + this.show_summary(); + } + + make_cards() { + this.$wrapper.empty(); + this.$cards = []; + this.$summary = $(`
`) + .hide() + .appendTo(this.$wrapper); + + this.cards.forEach(summary => { + let number_card = frappe.utils.build_summary_item(summary); + this.$cards.push(number_card); + + number_card.appendTo(this.$summary); + }); + + this.$summary.css({ + "border-bottom": "0px", + "margin-left": "0px", + "margin-right": "0px", + }); + } + + show_summary() { + if (this.cards.length) this.$summary.show(); + } +}; diff --git a/india_compliance/public/js/purchase_reconciliation_tool/purchase_reconciliation_tool.bundle.js b/india_compliance/public/js/purchase_reconciliation_tool/purchase_reconciliation_tool.bundle.js new file mode 100644 index 000000000..88785c2ba --- /dev/null +++ b/india_compliance/public/js/purchase_reconciliation_tool/purchase_reconciliation_tool.bundle.js @@ -0,0 +1,3 @@ +import "./data_table_manager"; +import "./filter_group"; +import "./number_card"; diff --git a/india_compliance/public/js/quick_entry.js b/india_compliance/public/js/quick_entry.js index fcec7b809..4ce83286e 100644 --- a/india_compliance/public/js/quick_entry.js +++ b/india_compliance/public/js/quick_entry.js @@ -284,11 +284,19 @@ class AddressQuickEntryForm extends GSTQuickEntryForm { get_default_party() { const doc = cur_frm && cur_frm.doc; - if (doc && frappe.dynamic_link && frappe.dynamic_link.doc === doc) { - return { - party_type: frappe.dynamic_link.doctype, - party: frappe.dynamic_link.doc[frappe.dynamic_link.fieldname], - }; + if (!doc) return; + + let party_type = doc.doctype; + let party = doc.name; + + if (frappe.dynamic_link && frappe.dynamic_link.doc === doc) { + party_type = frappe.dynamic_link.doctype; + party = frappe.dynamic_link.doc[frappe.dynamic_link.fieldname]; + } + + return { + party_type: party_type, + party: party } } } @@ -356,11 +364,11 @@ function setup_pincode_field(dialog, gstin_info) { }; } -function get_gstin_info(gstin) { +function get_gstin_info(gstin, throw_error = true) { return frappe .call({ method: "india_compliance.gst_india.utils.gstin_info.get_gstin_info", - args: { gstin }, + args: { gstin, throw_error }, }) .then(r => r.message); } @@ -409,4 +417,4 @@ function get_gstin_description() { } return __("Autofill is not supported in sandbox mode"); -} \ No newline at end of file +} diff --git a/india_compliance/public/js/quick_info_popover.js b/india_compliance/public/js/quick_info_popover.js new file mode 100644 index 000000000..0934f3d15 --- /dev/null +++ b/india_compliance/public/js/quick_info_popover.js @@ -0,0 +1,50 @@ +frappe.provide("india_compliance"); + +india_compliance.quick_info_popover = class QuickInfoPopover { + constructor(frm, field_dict) { + /** + * Setup tooltip for fields to show details + * @param {Object} frm Form object + * @param {Object} field_dict Dictionary of fields with info to show + */ + + this.frm = frm; + this.field_dict = field_dict; + this.make(); + } + make() { + this.create_info_popover(); + } + create_info_popover() { + if (!this.field_dict) return; + + for (const [field, info] of Object.entries(this.field_dict)) { + this.create_info_icon(field); + + if (!this.info_btn) return; + + this.info_btn.popover({ + trigger: "hover", + placement: "top", + content: () => this.get_content_html(field, info), + html: true, + }); + } + } + create_info_icon(field) { + let field_area = this.frm.get_field(field).$wrapper.find(".clearfix"); + this.info_btn = $(``).appendTo(field_area); + } + get_content_html(field, info) { + let field_lable = frappe.meta.get_label(this.frm.doctype, field); + + return ` +
+
+
${__(field_lable)}
+
+
${info}
+
+ `; + } +}; diff --git a/india_compliance/public/js/setup_wizard.js b/india_compliance/public/js/setup_wizard.js index e1d024e6c..ad489cc22 100644 --- a/india_compliance/public/js/setup_wizard.js +++ b/india_compliance/public/js/setup_wizard.js @@ -44,7 +44,7 @@ function update_erpnext_slides_settings() { async function autofill_company_info(slide) { const gstin = slide.get_input("company_gstin").val(); const gstin_field = slide.get_field("company_gstin"); - const gstin_info = await get_gstin_info(gstin); + const gstin_info = await get_gstin_info(gstin, false); if (gstin_info.business_name) { await slide.get_field("company_name").set_value(gstin_info.business_name); diff --git a/india_compliance/public/js/transaction.js b/india_compliance/public/js/transaction.js index ad45d4f46..dbc034ab7 100644 --- a/india_compliance/public/js/transaction.js +++ b/india_compliance/public/js/transaction.js @@ -1,5 +1,6 @@ // functions in this file will apply to most transactions // POS Invoice is a notable exception since it doesn't get created from the UI +frappe.provide("india_compliance"); const TRANSACTION_DOCTYPES = [ "Quotation", @@ -110,8 +111,12 @@ async function update_gst_details(frm, event) { args.party_details = JSON.stringify(party_details); + india_compliance.fetch_and_update_gst_details(frm, args); +} + +india_compliance.fetch_and_update_gst_details = function (frm, args, method) { frappe.call({ - method: "india_compliance.gst_india.overrides.transaction.get_gst_details", + method: method || "india_compliance.gst_india.overrides.transaction.get_gst_details", args, async callback(r) { if (!r.message) return; diff --git a/india_compliance/public/js/utils.js b/india_compliance/public/js/utils.js index 16002c179..65a7ee443 100644 --- a/india_compliance/public/js/utils.js +++ b/india_compliance/public/js/utils.js @@ -51,7 +51,7 @@ Object.assign(india_compliance, { return in_list(frappe.boot.sales_doctypes, doctype) ? "Customer" : "Supplier"; }, - async set_gstin_status(field, transaction_date) { + async set_gstin_status(field, transaction_date, force_update = 0) { const gstin = field.value; if (!gstin || gstin.length != 15) return field.set_description(""); @@ -61,10 +61,20 @@ Object.assign(india_compliance, { gstin, transaction_date, is_request_from_ui: 1, + force_update, }, }); - field.set_description(india_compliance.get_gstin_status_desc(message?.status, message?.last_updated_on)); + if (!message) return field.set_description(""); + + field.set_description( + india_compliance.get_gstin_status_desc( + message?.status, + message?.last_updated_on + ) + ); + + this.set_gstin_refresh_btn(field, transaction_date); return message; }, @@ -77,12 +87,39 @@ Object.assign(india_compliance, { const STATUS_COLORS = { Active: "green", Cancelled: "red" }; return `
Status: ${status} - - ${datetime ? "updated " + pretty_date : ""} + + + ${datetime ? "updated " + pretty_date : ""} +
`; }, + set_gstin_refresh_btn(field, transaction_date) { + if ( + !this.is_api_enabled() || + gst_settings.sandbox_mode || + !gst_settings.validate_gstin_status || + field.$wrapper.find(".refresh-gstin").length + ) + return; + + const refresh_btn = $(` + + + + `).appendTo(field.$wrapper.find(".gstin-last-updated")); + + refresh_btn.on("click", async function () { + const force_update = 1; + await india_compliance.set_gstin_status( + field, + transaction_date, + force_update + ); + }); + }, + set_state_options(frm) { const state_field = frm.get_field("state"); const country = frm.get_field("country").value; @@ -117,6 +154,28 @@ Object.assign(india_compliance, { } }, + get_gstin_otp(error_type) { + let description = "An OTP has been sent to your registered mobile/email for further authentication. Please provide OTP."; + if (error_type === "invalid_otp") + description = "Invalid OTP was provided. Please try again."; + + return new Promise(resolve => { + frappe.prompt( + { + fieldtype: "Data", + label: "One Time Password", + fieldname: "otp", + reqd: 1, + description: description, + }, + function ({ otp }) { + resolve(otp); + }, + "Enter OTP" + ); + }); + }, + guess_gst_category(gstin, country) { if (!gstin) { if (country && country !== "India") return "Overseas"; @@ -132,14 +191,32 @@ Object.assign(india_compliance, { set_hsn_code_query(field) { if (!field || !gst_settings.validate_hsn_code) return; field.get_query = function () { - const wildcard = '_'.repeat(gst_settings.min_hsn_digits) + '%'; + const wildcard = "_".repeat(gst_settings.min_hsn_digits) + "%"; return { filters: { - 'name': ['like', wildcard] - } + name: ["like", wildcard], + }, }; - } - } + }; + }, + + set_reconciliation_status(frm, field) { + if (!frm.doc.docstatus === 1 || !frm.doc.reconciliation_status) return; + + const STATUS_COLORS = { + Reconciled: "green", + Unreconciled: "red", + Ignored: "grey", + "Not Applicable": "grey", + }; + const color = STATUS_COLORS[frm.doc.reconciliation_status]; + + frm.get_field(field).set_description( + `
+ Reco Status: ${frm.doc.reconciliation_status} +
` + ); + }, }); function is_gstin_check_digit_valid(gstin) { diff --git a/india_compliance/setup_wizard.py b/india_compliance/setup_wizard.py index 469884f5c..5b308509c 100644 --- a/india_compliance/setup_wizard.py +++ b/india_compliance/setup_wizard.py @@ -51,9 +51,12 @@ def setup_company_gstin_details(params): if not params.company_gstin: return + if not (params.company_name or frappe.db.exists("Company", params.company_name)): + return + gstin_info = frappe._dict() if can_fetch_gstin_info(): - gstin_info = get_gstin_info(params.company_gstin) + gstin_info = get_gstin_info(params.company_gstin, throw_error=False) update_company_info(params, gstin_info.gst_category) create_address(gstin_info) diff --git a/pyproject.toml b/pyproject.toml index 6763e6545..0b119010a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dynamic = ["version"] dependencies = [ "python-barcode~=0.13.1", "titlecase~=2.3", + "pycryptodome~=3.19.0", # Not used directly - required by PyQRCode for PNG generation "pypng~=0.20220715.0",