diff --git a/india_compliance/gst_india/api_classes/taxpayer_base.py b/india_compliance/gst_india/api_classes/taxpayer_base.py index 8f5ecb0316..da77b00582 100644 --- a/india_compliance/gst_india/api_classes/taxpayer_base.py +++ b/india_compliance/gst_india/api_classes/taxpayer_base.py @@ -466,13 +466,12 @@ def generate_app_key(self): return app_key - def get_files(self, return_period, token, action, endpoint, otp=None): + def get_files(self, return_period, token, action, endpoint): response = self.get( action=action, return_period=return_period, params={"ret_period": return_period, "token": token}, endpoint=endpoint, - otp=otp, ) if response.error_type == "queued": diff --git a/india_compliance/gst_india/api_classes/taxpayer_returns.py b/india_compliance/gst_india/api_classes/taxpayer_returns.py index 26807a075e..ac7e605b4e 100644 --- a/india_compliance/gst_india/api_classes/taxpayer_returns.py +++ b/india_compliance/gst_india/api_classes/taxpayer_returns.py @@ -1,7 +1,10 @@ import frappe from frappe import _ -from india_compliance.gst_india.api_classes.taxpayer_base import TaxpayerBaseAPI +from india_compliance.gst_india.api_classes.taxpayer_base import ( + FilesAPI, + TaxpayerBaseAPI, +) class ReturnsAPI(TaxpayerBaseAPI): @@ -23,9 +26,9 @@ class ReturnsAPI(TaxpayerBaseAPI): "RET2B1010": "authorization_failed", # API Authorization Failed for 2B } - def download_files(self, return_period, token, otp=None): + def download_files(self, return_period, token): return super().get_files( - return_period, token, action="FILEDET", endpoint="returns", otp=otp + return_period, token, action="FILEDET", endpoint="returns" ) def get_return_status(self, return_period, reference_id, otp=None): @@ -57,6 +60,7 @@ def proceed_to_file(self, return_type, return_period, is_nil_return, otp=None): class GSTR2bAPI(ReturnsAPI): API_NAME = "GSTR-2B" + END_POINT = "returns/gstr2b" def get_data(self, return_period, otp=None, file_num=None): params = {"rtnprd": return_period} @@ -67,10 +71,29 @@ def get_data(self, return_period, otp=None, file_num=None): action="GET2B", return_period=return_period, params=params, - endpoint="returns/gstr2b", + endpoint=self.END_POINT, otp=otp, ) + def regenerate_2b(self, return_period): + return self.put( + json={ + "action": "GEN2B", + "data": {"rtin": self.company_gstin, "itcprd": return_period}, + }, + endpoint=self.END_POINT, + ) + + def get_2b_gen_status(self, transaction_id): + return self.get( + action="GENSTS2B", + params={ + "gstin": self.company_gstin, + "int_tran_id": transaction_id, + }, + endpoint=self.END_POINT, + ) + class GSTR2aAPI(ReturnsAPI): API_NAME = "GSTR-2A" @@ -153,3 +176,215 @@ def file_gstr_1(self, return_period, summary_data, pan, evc_otp): }, endpoint="returns/gstr1", ) + + +class GSTR3bAPI(ReturnsAPI): + END_POINT = "returns/gstr3b" + + def setup(self, company_gstin, return_period): + self.return_period = return_period + super().setup(company_gstin=company_gstin) + + def get_data(self): + return self.get( + action="RETSUM", + return_period=self.return_period, + params={"gstin": self.company_gstin, "ret_period": self.return_period}, + endpoint=self.END_POINT, + ) + + def save_gstr3b(self, data): + return self.put( + return_period=self.return_period, + json={ + "action": "RETSAVE", + "data": data, + }, + endpoint=self.END_POINT, + ) + + def submit_gstr3b(self, data): + return self.post( + return_period=self.return_period, + json={ + "action": "RETSUBMIT", + "data": data, + }, + endpoint=self.END_POINT, + ) + + def save_offset_liability_gstr3b(self, data): + return self.put( + return_period=self.return_period, + json={ + "action": "RETOFFSET", + "data": data, + }, + endpoint=self.END_POINT, + ) + + def file_gstr_3b(self, data, pan, evc_otp): + return self.post( + return_period=self.return_period, + json={ + "action": "RETFILE", + "data": data, + "st": "EVC", + "sid": f"{pan}|{evc_otp}", + }, + endpoint=self.END_POINT, + ) + + def get_itc_liab_data(self): + return self.get( + action="AUTOLIAB", + return_period=self.return_period, + params={"gstin": self.company_gstin, "ret_period": self.return_period}, + endpoint=self.END_POINT, + ) + + def validate_3b_against_auto_calc(self, data): + return self.post( + return_period=self.return_period, + json={ + "action": "VALID", + "data": data, + }, + endpoint=self.END_POINT, + ) + + def get_system_calc_interest(self): + return self.get( + action="RETINT", + return_period=self.return_period, + params={"gstin": self.company_gstin, "ret_period": self.return_period}, + endpoint=self.END_POINT, + ) + + def recompute_interest(self): + return self.post( + return_period=self.return_period, + json={ + "action": "CMPINT", + "data": {"gstn": self.company_gstin, "ret_period": self.return_period}, + }, + endpoint=self.END_POINT, + ) + + def save_past_liab(self, data): + return self.put( + return_period=self.return_period, + json={"action": "RETBKP", "data": data}, + endpoint=self.END_POINT, + ) + + def get_itc_reversal_bal(self): + return self.get( + action="CLOSINGBAL", + return_period=self.return_period, + params={"gstin": self.company_gstin}, + endpoint=self.END_POINT, + ) + + def get_rcm_bal(self): + return self.get( + action="RCMCLOSINGBAL", + return_period=self.return_period, + params={"gstin": self.company_gstin}, + endpoint=self.END_POINT, + ) + + def get_opening_bal(self): + return self.get( + action="OPENINGBAL", + return_period=self.return_period, + params={"gstin": self.company_gstin}, + endpoint=self.END_POINT, + ) + + def get_rcm_opening_bal(self): + return self.get( + action="RCMOPNBAL", + return_period=self.return_period, + params={"gstin": self.company_gstin}, + endpoint=self.END_POINT, + ) + + def save_opening_bal(self, data): + return self.post( + return_period=self.return_period, + json={"action": "SAVEOB", "data": data}, + endpoint=self.END_POINT, + ) + + def submit_rcm_opening_bal(self, data): + return self.post( + return_period=self.return_period, + json={ + "action": "SAVERCMOPNBAL", + "data": data, + }, + endpoint=self.END_POINT, + ) + + +class IMSAPI(ReturnsAPI): + API_NAME = "IMS" + END_POINT = "returns/ims" + + def get_data(self, section): + return self.get( + action="GETINV", + params={ + "gstin": self.company_gstin, + "section": section, + }, + endpoint=self.END_POINT, + ) + + def download_files(self, return_period, token): + return self.get_files( + return_period, token, action="FILEDET", endpoint=self.END_POINT + ) + + def get_files(self, return_period, token, action, endpoint): + response = self.get( + action=action, + return_period=return_period, + params={"gstin": self.company_gstin, "token": token}, + endpoint=endpoint, + ) + + if response.error_type == "queued": + return response + + return FilesAPI().get_all(response) + + def save(self, data): + return self.put( + endpoint=self.END_POINT, + json={ + "action": "SAVE", + "data": {"rtin": self.company_gstin, "reqtyp": "SAVE", "invdata": data}, + }, + ) + + def reset(self, data): + return self.put( + endpoint=self.END_POINT, + json={ + "action": "RESETIMS", + "data": { + "rtin": self.company_gstin, + "reqtyp": "RESET", + "invdata": data, + }, + }, + ) + + def get_request_status(self, transaction_id): + return self.get( + action="REQSTS", + endpoint=self.END_POINT, + params={"gstin": self.company_gstin, "int_tran_id": transaction_id}, + ) diff --git a/india_compliance/gst_india/client_scripts/purchase_invoice.js b/india_compliance/gst_india/client_scripts/purchase_invoice.js index dae0d69e32..bb4948ed16 100644 --- a/india_compliance/gst_india/client_scripts/purchase_invoice.js +++ b/india_compliance/gst_india/client_scripts/purchase_invoice.js @@ -74,20 +74,19 @@ frappe.ui.form.on(DOCTYPE, { 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, + frappe.route_hooks.after_load = source_frm => { + if (!source_frm.reconciliation_tabs) return; + reconciliation.link_documents( + source_frm, frm.doc.name, frm._inward_supply.name, "Purchase Invoice", false ); }; - frappe.set_route("Form", "Purchase Reconciliation Tool"); + frappe.set_route("Form", frm._inward_supply.source_doc); }, 2000); }, }); diff --git a/india_compliance/gst_india/constants/__init__.py b/india_compliance/gst_india/constants/__init__.py index 39f0996658..6086191907 100644 --- a/india_compliance/gst_india/constants/__init__.py +++ b/india_compliance/gst_india/constants/__init__.py @@ -36,6 +36,23 @@ "Input Service Distributor": "B2B", } +GST_CATEGORY_MAP = { + "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", +} + +ACTION_MAP = {"A": "Accepted", "R": "Rejected", "P": "Pending", "N": "No Action"} + +STATUS_CODE_MAP = { + "P": "Processed", + "PE": "Processed with Errors", + "ER": "Error", + "IP": "In Progress", +} + EXPORT_TYPES = ( "WOP", # Without Payment of Tax [0] "WP", # With Payment of Tax [1] diff --git a/india_compliance/gst_india/data/test_ims.json b/india_compliance/gst_india/data/test_ims.json new file mode 100644 index 0000000000..214fee0d8e --- /dev/null +++ b/india_compliance/gst_india/data/test_ims.json @@ -0,0 +1,134 @@ +{ + "b2b": [ + { + "stin": "24MAYAS0100J1JD", + "inum": "b1", + "inv_typ": "R", + "action": "A", + "ispendactblocked": "N", + "srcform": "R1", + "rtnprd": "012023", + "srcfilstatus": "Not Filed", + "idt": "23-01-2023", + "val": 1000, + "pos": "24", + "txval": 100, + "iamt": 20, + "camt": 20, + "samt": 20, + "cess": 0, + "hash": "1f5fb5de9e491c24dcdee9ac01d3fa5f7889f20d3a8c923bfd2e3c7f0d0125f1" + } + ], + "b2ba": [ + { + "oinum": "ab2", + "oidt": "24-02-2023", + "stin": "24MAYAS0100J1JD", + "rtnprd": "012023", + "inum": "b1a", + "action": "A", + "ispendactblocked": "N", + "inv_typ": "R", + "srcform": "R1", + "idt": "23-01-2023", + "val": 1000, + "pos": "07", + "txval": 100, + "iamt": 20, + "camt": 20, + "samt": 20, + "cess": 0, + "srcfilstatus": "Not Filed", + "hash": "1f5fb5de9e491c24dcdee9ac01d3fa5f7889f20d3a8c923bfd2e3c7f0d0125f1" + } + ], + "b2bdn": [ + { + "stin": "24MAYAS0100J1JD", + "nt_num": "dn2", + "action": "A", + "inv_typ": "R", + "srcform": "R1", + "ispendactblocked": "N", + "rtnprd": "012023", + "nt_dt": "24-02-2023", + "val": 1000.1, + "pos": "07", + "txval": 1000.1, + "iamt": 20, + "camt": 20, + "samt": 20, + "cess": 0, + "srcfilstatus": "Filed", + "hash": "1f5fb5de9e491c24dcdee9ac01d3fa5f7889f20d3a8c923bfd2e3c7f0d0125f1" + } + ], + "b2bdna": [ + { + "stin": "24MAYAS0100J1JD", + "ont_num": "dn2", + "ont_dt": "24-02-2023", + "nt_num": "dna2", + "action": "A", + "inv_typ": "R", + "srcform": "R1", + "ispendactblocked": "N", + "rtnprd": "012023", + "nt_dt": "24-02-2023", + "val": 1000.1, + "pos": "07", + "txval": 1000.1, + "iamt": 20, + "camt": 20, + "samt": 20, + "cess": 0, + "srcfilstatus": "Filed", + "hash": "1f5fb5de9e491c24dcdee9ac01d3fa5f7889f20d3a8c923bfd2e3c7f0d0125f1" + } + ], + "b2bcn": [ + { + "stin": "24MAYAS0100J1JD", + "nt_num": "cn2", + "action": "A", + "inv_typ": "R", + "srcform": "R1", + "rtnprd": "012023", + "ispendactblocked": "N", + "nt_dt": "24-02-2023", + "val": 1000.1, + "pos": "07", + "txval": 1000.1, + "iamt": 20, + "camt": 20, + "samt": 20, + "cess": 0, + "srcfilstatus": "Not Filed", + "hash": "1f5fb5de9e491c24dcdee9ac01d3fa5f7889f20d3a8c923bfd2e3c7f0d0125f1" + } + ], + "b2bcna": [ + { + "stin": "24MAYAS0100J1JD", + "ont_num": "cn2", + "ont_dt": "24-02-2023", + "nt_num": "cna2", + "action": "A", + "inv_typ": "R", + "srcform": "R1", + "ispendactblocked": "N", + "rtnprd": "012023", + "nt_dt": "24-02-2023", + "val": 1000.1, + "pos": "07", + "txval": 1000.1, + "iamt": 20, + "camt": 20, + "samt": 20, + "cess": 0, + "srcfilstatus": "Not Filed", + "hash": "1f5fb5de9e491c24dcdee9ac01d3fa5f7889f20d3a8c923bfd2e3c7f0d0125f1" + } + ] +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/gst_invoice_management_system/__init__.py b/india_compliance/gst_india/doctype/gst_invoice_management_system/__init__.py new file mode 100644 index 0000000000..dde5bb557a --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/__init__.py @@ -0,0 +1,246 @@ +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 india_compliance.gst_india.constants import GST_TAX_TYPES +from india_compliance.gst_india.doctype.purchase_reconciliation_tool import ( + GSTIN_RULES, + PAN_RULES, + BaseUtil, + Reconciler, +) + + +class IMSReconciler(Reconciler): + CATEGORIES = ( + {"doc_type": "Invoice", "category": "B2B"}, + {"doc_type": "Debit Note", "category": "CDNR"}, + {"doc_type": "Credit Note", "category": "CDNR"}, + ) + + def reconcile(self, filters): + """ + Reconcile purchases and inward supplies. + """ + for row in self.CATEGORIES: + filters["doc_type"], self.category = row.values() + + purchases = PurchaseInvoice().get_unmatched(filters) + inward_supplies = InwardSupply().get_unmatched(filters) + + # GSTIN Level matching + self.reconcile_for_rules(GSTIN_RULES, purchases, inward_supplies) + + # 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) + + +class InwardSupply: + def __init__(self): + self.IMS = frappe.qb.DocType("GST Inward Supply") + + def get_all(self, company_gstin, names=None): + query = self.get_query(company_gstin, ["action", "doc_type"]) + + if names: + query = query.where(self.IMS.name.isin(names)) + + return query.run(as_dict=True) + + def get_for_save(self, company_gstin): + return ( + self.get_query_for_upload(company_gstin) + .where(self.IMS.ims_action != "No Action") + .run(as_dict=True) + ) + + def get_for_reset(self, company_gstin): + return ( + self.get_query_for_upload(company_gstin) + .where(self.IMS.ims_action == "No Action") + .run(as_dict=True) + ) + + def get_query_for_upload(self, company_gstin): + return self.get_query( + company_gstin, + additional_fields=[ + "doc_type", + "is_amended", + "sup_return_period", + "document_value", + ], + ).where(self.IMS.ims_action != self.IMS.previous_ims_action) + + def get_unmatched(self, filters): + query = self.get_query(filters.company_gstin) + data = ( + query.where(IfNull(self.IMS.match_status, "") == "") + .where(self.IMS.doc_type == filters.doc_type) + .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 get_query(self, company_gstin, additional_fields=None): + fields = self.get_fields(additional_fields=additional_fields) + + return ( + frappe.qb.from_(self.IMS) + .select( + *fields, + ConstantColumn("GST Inward Supply").as_("doctype"), + Case() + .when( + (self.IMS.ims_action == self.IMS.previous_ims_action), + False, + ) + .else_(True) + .as_("pending_upload"), + ) + .where(IfNull(self.IMS.previous_ims_action, "") != "") + .where(self.IMS.company_gstin == company_gstin) + ) + + def get_fields(self, additional_fields=None): + fields = [ + "supplier_gstin", + "supplier_name", + "company_gstin", + "bill_no", + "bill_date", + "name", + "is_reverse_charge", + "place_of_supply", + "link_name", + "link_doctype", + "match_status", + "ims_action", + "previous_ims_action", + "supply_type", + "classification", + "is_pending_action_allowed", + "supplier_return_form", + "is_supplier_return_filed", + ] + + if additional_fields: + fields += additional_fields + + fields = [self.IMS[field] for field in fields] + fields += self.get_tax_fields() + + return fields + + def get_tax_fields(self): + fields = GST_TAX_TYPES[:-1] + ("taxable_value",) + return [self.IMS[field] for field in fields] + + +class PurchaseInvoice: + def __init__(self): + self.PI = frappe.qb.DocType("Purchase Invoice") + self.PI_ITEM = frappe.qb.DocType("Purchase Invoice Item") + + def get_all(self, names=None, filters=None): + query = self.get_query(filters=filters) + + if names: + query = query.where(self.PI.name.isin(names)) + + purchases = query.run(as_dict=True) + + return {doc.name: doc for doc in purchases} + + def get_unmatched(self, filters): + gst_category = ( + "Registered Regular", + "Tax Deductor", + "Tax Collector", + "Input Service Distributor", + ) + is_return = 1 if filters.doc_type == "Credit Note" else 0 + + data = ( + self.get_query(filters=filters) + .where(self.PI.gst_category.isin(gst_category)) + .where(self.PI.reconciliation_status == "Unreconciled") + .where(self.PI.is_return == is_return) + .where(self.PI.ineligibility_reason != "ITC restricted due to PoS rules") + .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 get_query(self, filters=None, additional_fields=None): + fields = self.get_fields(additional_fields) + + query = ( + frappe.qb.from_(self.PI) + .left_join(self.PI_ITEM) + .on(self.PI_ITEM.parent == self.PI.name) + .select( + Abs(Sum(self.PI_ITEM.taxable_value)).as_("taxable_value"), + *fields, + ConstantColumn("Purchase Invoice").as_("doctype"), + ) + .where(self.PI.docstatus == 1) + .where(IfNull(self.PI.reconciliation_status, "") != "Not Applicable") + .where(self.PI.is_opening == "No") + .where(self.PI_ITEM.parenttype == "Purchase Invoice") + .where(self.PI.is_reverse_charge == 0) # for IMS + .groupby(self.PI.name) + ) + + if filters: + query = self.apply_filters(query, filters) + + return query + + def apply_filters(self, query, filters): + if filters.get("company"): + query = query.where(self.PI.company == filters.company) + + if filters.get("company_gstin"): + query = query.where(self.PI.company_gstin == filters.company_gstin) + + return query + + def get_fields(self, additional_fields=None): + fields = [ + "supplier_gstin", + "supplier_name", + "bill_no", + "bill_date", + "name", + "company", + "company_gstin", + "is_reverse_charge", + "place_of_supply", + ] + + if additional_fields: + fields += additional_fields + + fields = [self.PI[field] for field in fields] + fields += self.get_tax_fields() + + return fields + + def get_tax_fields(self): + return [ + self.query_tax_amount(f"{tax_type}_amount").as_(tax_type) + for tax_type in GST_TAX_TYPES + ] + + def query_tax_amount(self, field): + return Abs(Sum(getattr(self.PI_ITEM, field))) diff --git a/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.css b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.css new file mode 100644 index 0000000000..003527b27c --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.css @@ -0,0 +1,109 @@ +div[data-page-route="GST Invoice Management System"] { + --dt-row-height: 34px; +} + +div[data-page-route="GST Invoice Management System"] .section-body { + max-width: 100% !important; +} + +div[data-page-route="GST Invoice Management System"] + [data-fieldname="invoice_html"] + .section-body { + margin-top: 0; +} + +div[data-page-route="GST Invoice Management System"] + [data-fieldname="invoice_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="GST Invoice Management System"] + [data-fieldname="invoice_html"] + .form-tabs-list + .custom-button-group { + display: flex; +} + +div[data-page-route="GST Invoice Management System"] + [data-fieldname="invoice_html"] + .form-tabs-list + .inner-group-button, +.filter-selector { + margin-bottom: 8px; + margin-left: 8px; +} + +div[data-page-route="GST Invoice Management System"] + [data-fieldname="invoice_html"] + .form-tabs-list + .custom-button-group + .btn { + padding: 5px 10px; +} + +div[data-page-route="GST Invoice Management System"] .title-area .indicator-pill { + display: none; +} + +div[data-page-route="GST Invoice Management System"] .datatable .dt-scrollable { + overflow-y: auto !important; + margin-bottom: 2em; + min-height: calc(100vh - 450px); +} + +div[data-page-route="GST Invoice Management System"] .datatable .dt-row { + height: unset; +} + +div[data-page-route="GST Invoice Management System"] .datatable .dt-row-filter { + height: var(--dt-row-height); +} + +div[data-page-route="GST Invoice Management System"] + .datatable + .dt-row-filter + .dt-cell { + max-height: var(--dt-row-height); +} + +div[data-page-route="GST Invoice Management System"] [data-fieldname="no_invoice_data"], +div[data-page-route="GST Invoice Management System"] + [data-fieldname="invoice_empty_state"] { + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +div[data-page-route="GST Invoice Management System"] + [data-fieldname="no_invoice_data"] + > img, +div[data-page-route="GST Invoice Management System"] + [data-fieldname="invoice_empty_state"] + > img { + margin-bottom: var(--margin-md); + max-height: 100px; +} + +div[data-page-route="GST Invoice Management System"] .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%; +} + +div[data-page-route="GST Invoice Management System"] .action-summary { + text-decoration: none; +} diff --git a/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.js b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.js new file mode 100644 index 0000000000..d693e43489 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.js @@ -0,0 +1,990 @@ +// Copyright (c) 2024, Resilient Tech and contributors +// For license information, please see license.txt + +const api_enabled = india_compliance.is_api_enabled(); +const DOCTYPE = "GST Invoice Management System"; +const DOC_PATH = + "india_compliance.gst_india.doctype.gst_invoice_management_system.gst_invoice_management_system"; + +const category_map = { + "B2B-Invoices": "Invoice", + "B2B-Credit Notes": "Credit Note", + "B2B-Debit Notes": "Debit Note", +}; + +const ACTION_MAP = { + "No Action": "No Action", + Accept: "Accepted", + Pending: "Pending", + Reject: "Rejected", +}; + +frappe.ui.form.on(DOCTYPE, { + async setup(frm) { + await frappe.require("ims.bundle.js"); + + frm.reconciliation_tabs = new IMS( + frm, + ["invoice", "match_summary", "action_summary"], + "invoice_html" + ); + + frm.trigger("company"); + + // Setup Listeners + + // Download Queued + frappe.realtime.on("ims_download_queued", message => { + frappe.msgprint(message["message"]); + }); + + // Downloaded and Reconciled Invoices + frappe.realtime.on("ims_download_completed", message => { + frm.ims_actions.get_ims_data(); + frappe.show_alert({ message: message["message"], indicator: "green" }); + }); + + // Upload and Check Status + frappe.realtime.on("upload_data_and_check_status", async message => { + await frm.ims_actions.get_ims_data(); + frm.ims_actions.upload_ims_data(); + }); + }, + + async company(frm) { + render_empty_state(frm); + if (!frm.doc.company) return; + const options = await india_compliance.set_gstin_options(frm); + + frm.set_value("company_gstin", options[0]); + }, + + company_gstin: render_empty_state, + + refresh(frm) { + show_download_invoices_message(frm); + + frm.ims_actions = new IMSAction(frm); + frm.ims_actions.setup_actions(); + }, +}); + +class IMS extends reconciliation.reconciliation_tabs { + refresh(data) { + super.refresh(data); + this.set_actions_summary(); + } + + get_tab_group_fields() { + return [ + { + //hack: for the FieldGroup(Layout) to avoid rendering default "details" tab + fieldtype: "Section Break", + }, + { + label: "Match Summary", + fieldtype: "Tab Break", + fieldname: "match_summary_tab", + active: 1, + }, + { + fieldtype: "HTML", + fieldname: "match_summary_data", + }, + { + label: "Actions Summary", + fieldtype: "Tab Break", + fieldname: "action_summary_tab", + }, + { + fieldtype: "HTML", + fieldname: "action_summary_data", + }, + { + label: "Document View", + fieldtype: "Tab Break", + fieldname: "invoice_tab", + }, + { + fieldtype: "HTML", + fieldname: "invoice_data", + }, + ]; + } + + 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 PI", + ], + }, + { + label: "Action", + fieldname: "ims_action", + fieldtype: "Select", + options: ["No Action", "Accepted", "Rejected", "Pending"], + }, + { + label: "Document Type", + fieldname: "doc_type", + fieldtype: "Select", + options: ["Invoice", "Credit Note", "Debit Note"], + }, + { + label: "Upload Pending", + fieldname: "pending_upload", + fieldtype: "Check", + }, + { + label: "Is Pending Action Allowed", + fieldname: "is_pending_action_allowed", + fieldtype: "Check", + }, + { + label: "Classification", + fieldname: "classification", + fieldtype: "Select", + options: ["B2B", "B2BA", "CDNR", "CDNRA"], + }, + { + label: "Is Supplier Return Filed", + fieldname: "is_supplier_return_filed", + fieldtype: "Check", + }, + ]; + + fields.forEach(field => (field.parent = DOCTYPE)); + return fields; + } + + set_listeners() { + const me = this; + + // TODO: Refactor like purchase_reconciliation.js + + this.tabs.invoice_tab.datatable.$datatable.on( + "click", + ".supplier-gstin", + function (e) { + me.update_filter(e, "supplier_gstin", $(this).text().trim(), me); + } + ); + + this.tabs.invoice_tab.datatable.$datatable.on( + "click", + ".match-status", + function (e) { + me.update_filter(e, "match_status", $(this).text(), me); + } + ); + + this.tabs.match_summary_tab.datatable.$datatable.on( + "click", + ".match-status", + function (e) { + me.update_filter(e, "match_status", $(this).text(), me); + } + ); + + this.tabs.invoice_tab.datatable.$datatable.on( + "click", + ".ims-action", + function (e) { + me.update_filter(e, "ims_action", $(this).text(), me); + } + ); + + this.tabs.action_summary_tab.datatable.$datatable.on( + "click", + ".invoice-category", + function (e) { + me.update_filter(e, "doc_type", category_map[$(this).text()], me); + } + ); + + this.tabs.invoice_tab.datatable.$datatable.on( + "click", + ".classification", + function (e) { + me.update_filter(e, "classification", $(this).text(), me); + } + ); + + this.tabs.invoice_tab.datatable.$datatable.on( + "click", + ".btn.eye", + function (e) { + const row = me.mapped_invoice_data[$(this).attr("data-name")]; + me.dm = new DetailViewDialog(me.frm, row); + } + ); + } + + async update_filter(e, field, field_value, me) { + e.preventDefault(); + + await me.filter_group.add_or_remove_filter([DOCTYPE, field, "=", field_value]); + me.filter_group.apply(); + } + + get_match_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_match_summary_data() { + if (!this.data.length) return []; + + 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.ims_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_invoice_columns() { + return [ + { + fieldname: "view", + fieldtype: "html", + width: 60, + align: "center", + _value: (...args) => get_icon(...args), + }, + { + label: "Supplier Name", + fieldname: "supplier_name_gstin", + align: "center", + width: 200, + }, + { + label: "Bill No.", + fieldname: "bill_no", + align: "center", + width: 120, + }, + { + label: "Match Status", + fieldname: "match_status", + align: "center", + width: 120, + _value: (...args) => `${args[0]}`, + }, + { + label: "Action", + fieldname: "ims_action", + align: "center", + width: 100, + _value: (...args) => `${args[0]}`, + }, + { + label: "GST Inward
Supply", + fieldname: "inward_supply_name", + align: "center", + fieldtype: "Link", + options: "GST Inward Supply", + width: 150, + _after_format: (...args) => get_value_with_indicator(...args), + }, + { + label: "Linked Voucher", + fieldname: "linked_doc", + align: "center", + width: 150, + fieldtype: "Dynamic Link", + options: "linked_voucher_type", + }, + { + label: "Tax Difference
2A/2B - Purchase", + fieldname: "tax_difference", + align: "center", + width: 150, + _value: (...args) => format_number(args[0]), + }, + { + label: "Taxable Amount Diff
2A/2B - Purchase", + fieldname: "taxable_value_difference", + align: "center", + width: 160, + _value: (...args) => format_number(args[0]), + }, + { + label: "Classification", + fieldname: "classification", + align: "center", + width: 100, + _value: (...args) => + `${args[0]}`, + }, + ]; + } + + get_invoice_data() { + if (!this.data.length) return []; + + const data = []; + this.mapped_invoice_data = {}; + + this.filtered_data.forEach(row => { + this.mapped_invoice_data[row.inward_supply_name] = row; + + data.push({ + supplier_name_gstin: this.get_supplier_name_gstin(row), + bill_no: row.bill_no, + classification: row._inward_supply.classification, + ims_action: row.ims_action || "", + match_status: row.match_status, + linked_doc: row.purchase_invoice_name, + tax_difference: row.tax_difference, + taxable_value_difference: row.taxable_value_difference, + inward_supply_name: row.inward_supply_name, + pending_upload: row.pending_upload, + is_supplier_return_filed: row.is_supplier_return_filed, + }); + }); + + return data; + } + + get_action_summary_columns() { + return [ + { + label: "Category", + fieldname: "category", + width: 200, + _value: (...args) => + `${args[0]}`, + }, + { + label: "No Action", + fieldname: "no_action", + width: 200, + }, + { + label: "Accepted", + fieldname: "accepted", + width: 200, + }, + { + label: "Pending", + fieldname: "pending", + width: 200, + }, + { + label: "Rejected", + fieldname: "rejected", + width: 200, + }, + ]; + } + + get_action_summary_data(data) { + const category_map = { + Invoice: "B2B-Invoices", + "Credit Note": "B2B-Credit Notes", + "Debit Note": "B2B-Debit Notes", + }; + let summary_data = {}; + if (!data) data = this.filtered_data; + + data.forEach(row => { + const action = frappe.scrub(row.ims_action); + const category = category_map[row.doc_type]; + if (!summary_data[category]) { + summary_data[category] = { + category, + no_action: 0, + accepted: 0, + rejected: 0, + pending: 0, + }; + } + summary_data[category][action] += 1; + }); + + return Object.values(summary_data); + } + + async set_actions_summary() { + const actions_data = this.get_action_summary_data(this.data); + + if ($(".action-performed-summary").length) { + $(".action-performed-summary").remove(); + } + + $(function () { + $('[data-toggle="tooltip"]').tooltip(); + }); + + const actions_summary = { + no_action: { count: 0, color: "#7c7c7c" }, + accepted: { count: 0, color: "#28a745" }, + pending: { count: 0, color: "#ffc107" }, + rejected: { count: 0, color: "#e03636" }, + }; + + actions_data.forEach(row => { + actions_summary.accepted.count += row.accepted; + actions_summary.pending.count += row.pending; + actions_summary.rejected.count += row.rejected; + actions_summary.no_action.count += row.no_action; + }); + + const action_performed_cards = Object.entries(actions_summary) + .map(([value, data]) => { + const action = frappe.unscrub(value); + return `
+
${action}
+
+ +

+ ${data.count} +

+
+
`; + }) + .join(""); + + const action_performed_html = ` +
+ ${action_performed_cards} +
+ `; + + let element = $('[data-fieldname="data_section"]'); + element.prepend(action_performed_html); + + const me = this; + this.frm.$wrapper.find(".action-summary").click(async function (e) { + const [action, action_count] = $(this).attr("data-name").split("-"); + + if (action_count === "0") return; + + const fg = me.filter_group; + const filter = [DOCTYPE, "ims_action", "=", action]; + + if (fg.filter_exists(filter.slice(0, 2)) && !fg.filter_exists(filter)) + await me.filter_group.remove_filter([DOCTYPE, "ims_action"]); + + me.update_filter(e, "ims_action", action, me); + }); + } +} + +class IMSAction { + RETRY_INTERVALS = [2000, 3000, 15000, 30000, 60000, 120000, 300000, 600000, 720000]; // 5 second, 15 second, 30 second, 1 min, 2 min, 5 min, 10 min, 12 min + + constructor(frm) { + this.frm = frm; + } + + setup_actions() { + this.setup_document_actions(); + this.setup_row_actions(); + } + + setup_document_actions() { + // Primary Action + this.frm.disable_save(); + if (!this.frm.doc.data_state) { + this.frm.page.set_primary_action(__("Show Invoices"), () => + this.get_ims_data() + ); + } else { + this.frm.page.set_primary_action(__("Upload Invoices"), () => + this.upload_ims_data() + ); + } + + this.frm.add_custom_button(__("Download Invoices"), () => { + render_empty_state(this.frm); + this.download_ims_data(); + }); + } + + setup_row_actions() { + // Setup Custom Buttons + if (!this.frm.reconciliation_tabs?.data?.length) return; + if (this.frm.get_active_tab()?.df.fieldname == "invoice_tab") { + this.frm.add_custom_button( + __("Unlink"), + () => reconciliation.unlink_documents(this.frm), + __("Actions") + ); + this.frm.add_custom_button(__("dropdown-divider"), () => {}, __("Actions")); + } + + // Setup Bulk Actions + ["No Action", "Accept", "Pending", "Reject"].forEach(action => + this.frm.add_custom_button( + __(action), + () => apply_bulk_action(this.frm, ACTION_MAP[action]), + __("Actions") + ) + ); + + // Add Dropdown Divider to differentiate between IMS and Reconciliation Actions + this.frm.$wrapper + .find("[data-label='dropdown-divider']") + .addClass("dropdown-divider"); + + // move actions button next to filters + for (let button of this.frm.$wrapper.find( + ".custom-actions .inner-group-button" + )) { + if (button.innerText?.trim() != __("Actions")) continue; + this.frm.$wrapper.find(".custom-button-group .inner-group-button").remove(); + $(button).appendTo(this.frm.$wrapper.find(".custom-button-group")); + } + } + + async download_ims_data() { + await taxpayer_api.call({ + method: `${DOC_PATH}.download_invoices`, + args: { company_gstin: this.frm.doc.company_gstin }, + }); + + frappe.show_alert({ + message: __("Downloading Invoices"), + }); + } + + async get_ims_data() { + const { message } = await this.frm.call("autoreconcile_and_get_data"); + this.frm.__invoice_data = message.invoice_data; + + this.frm.reconciliation_tabs.render_data(this.frm.__invoice_data); + this.frm.doc.data_state = this.frm.__invoice_data.length + ? "available" + : "unavailable"; + + if (message.pending_actions.length) { + this.handle_upload_status(); + } + + // Toggle HTML fields + this.frm.refresh(); + } + + async upload_ims_data() { + if (!this.filter_invoices_to_upload().length) { + frappe.msgprint({ + title: __("No Data Found"), + message: __("No Invoices to Upload"), + indicator: "red", + }); + return; + } + + frappe.show_alert(__("Checking Upload Status")); + + const save_status = await this.upload_and_check_status("save"); + const reset_status = await this.upload_and_check_status("reset"); + + this.handle_upload_status(save_status, reset_status); + } + + async upload_and_check_status(action) { + await taxpayer_api.call({ + method: `${DOC_PATH}.${action}_invoices`, + args: { company_gstin: this.frm.doc.company_gstin }, + }); + + return this.get_upload_status_with_retry(action); + } + + async handle_upload_status(save_status, reset_status) { + if (!save_status) save_status = await this.get_upload_status_with_retry("save"); + + if (!reset_status) + reset_status = await this.get_upload_status_with_retry("reset"); + + const error_statuses = ["ER", "PE"]; + if ( + error_statuses.includes(save_status.status_cd) || + error_statuses.includes(reset_status.status_cd) + ) + return this.on_failed_upload(); + + return this.on_successful_upload(); + } + + get_upload_status_with_retry(action, retries = 0, now = false) { + return new Promise(resolve => { + setTimeout( + async () => { + const { message } = await taxpayer_api.call({ + method: `${DOC_PATH}.check_action_status`, + args: { company_gstin: this.frm.doc.company_gstin, action }, + }); + + if (!message.status_cd) { + resolve({ status_cd: "ER" }); + return; + } + + if ( + message.status_cd === "IP" && + retries < this.RETRY_INTERVALS.length + ) { + resolve( + await this.get_upload_status_with_retry(action, retries + 1) + ); + return; + } + + // Not IP + resolve(message); + }, + now ? 0 : this.RETRY_INTERVALS[retries] + ); + }); + } + + filter_invoices_to_upload() { + return this.frm.reconciliation_tabs.data.filter(row => row.pending_upload); + } + + on_failed_upload() { + frappe.msgprint({ + message: + "An error occurred while uploading the data. Please try downloading the data again and re-uploading it.", + indicator: "red", + title: __("GSTN Sync Required"), + primary_action: { + label: __("Sync and Reupload"), + action: () => { + frappe.hide_msgprint(); + render_empty_state(this.frm); + + taxpayer_api.call({ + method: `${DOC_PATH}.sync_with_gstn_and_reupload`, + args: { company_gstin: this.frm.doc.company_gstin }, + }); + }, + }, + }); + } + + on_successful_upload() { + // refresh existing data + const data = this.frm.reconciliation_tabs.data; + data.forEach(row => { + if (!row.pending_upload) return; + + row.pending_upload = false; + row.previous_ims_action = row.ims_action; + }); + + this.frm.reconciliation_tabs.refresh(data); + + frappe.show_alert({ + message: __("Uploaded Invoices Successfully"), + indicator: "green", + }); + } +} + +class DetailViewDialog extends reconciliation.detail_view_dialog { + _get_custom_actions() { + // setup actions + let actions = ["No Action", "Reject"].filter( + action => ACTION_MAP[action] != this.row.ims_action + ); + + if ( + this.row.match_status !== "Missing in PI" && + this.row.ims_action != "Accepted" + ) + actions.push("Accept"); + + if (this.row.is_pending_action_allowed && this.row.ims_action != "Pending") + actions.push("Pending"); + + if (this.row.match_status == "Missing in PI") actions.push("Create", "Link"); + else actions.push("Unlink"); + + return actions; + } + + _apply_custom_action(action) { + if (action == "Unlink") { + reconciliation.unlink_documents(this.frm, [this.row]); + } else if (action == "Link") { + reconciliation.link_documents( + this.frm, + this.data.purchase_invoice_name, + this.data.inward_supply_name, + this.dialog.get_value("doctype"), + true + ); + } else if (action == "Create") { + reconciliation.create_new_purchase_invoice( + this.data, + this.frm.doc.company, + this.frm.doc.company_gstin, + DOCTYPE + ); + } else { + apply_action(this.frm, ACTION_MAP[action], [this.row.inward_supply_name]); + } + } + + _get_button_css(action) { + if (action == "No Action") return "btn-secondary"; + if (action == "Accept") return "btn-success not-grey"; + if (action == "Reject") return "btn-danger not-grey"; + if (action == "Pending") return "btn-warning not-grey"; + if (action == "Create") return "btn-primary not-grey"; + if (action == "Link") return "btn-primary not-grey btn-link disabled"; + } + + _set_missing_doctype() { + if (this.row.match_status == "Missing in PI") + this.missing_doctype = "Purchase Invoice"; + else return; + + this.doctype_options = ["Purchase Invoice"]; + } +} + +function render_empty_state(frm) { + frm.__invoice_data = null; + frm.doc.data_state = null; + + $(".action-performed-summary").remove(); + + frm.refresh(); +} + +function apply_bulk_action(frm, action) { + const active_tab = frm.get_active_tab()?.df.fieldname; + if (!active_tab) return; + + const tab = frm.reconciliation_tabs.tabs[active_tab]; + + // from current tab + const selected_rows = tab.datatable.get_checked_items(); + if (!selected_rows.length) { + frappe.show_alert({ message: __("Please select invoices"), indicator: "red" }); + return; + } + + // summary => invoice + const affected_rows = get_affected_rows( + active_tab, + selected_rows, + frm.reconciliation_tabs.filtered_data + ); + + apply_action(frm, action, affected_rows); + + if (tab) tab.datatable.clear_checked_items(); +} + +async function apply_action(frm, action, invoice_names) { + // Validate and Update JS + let pending_not_allowed = []; + let accept_not_allowed = []; + let new_data = []; + frm.reconciliation_tabs.data.forEach(row => { + if (invoice_names.includes(row.inward_supply_name)) { + if (!is_pending_allowed(row, action)) { + pending_not_allowed.push(row.inward_supply_name); + } else if (!is_accept_allowed(row, action)) { + accept_not_allowed.push(row.inward_supply_name); + } else { + row.ims_action = action; + + // Update pending upload status + if (row.ims_action !== row.previous_ims_action) + row.pending_upload = true; + else row.pending_upload = false; + } + } + + new_data.push({ ...row }); + }); + + invoice_names = invoice_names.filter( + name => + !(pending_not_allowed.includes(name) || accept_not_allowed.includes(name)) + ); + + if (pending_not_allowed.length) { + frappe.msgprint({ + message: __( + "Some invoices are not allowed to be marked as Pending." + ), + indicator: "red", + }); + } else if (accept_not_allowed.length) { + frappe.msgprint({ + message: __( + "Some invoices cannot be Accepted. Please ensure they are linked to a purchase." + ), + indicator: "red", + }); + } + + if (!invoice_names.length) return; + + // Update + frm._call("update_action", { invoice_names, action }); + + frm.reconciliation_tabs.refresh(new_data); + frappe.show_alert({ message: "Action applied successfully", indicator: "green" }); +} + +function is_pending_allowed(row, action) { + if (action === "Pending" && !row.is_pending_action_allowed) return false; + return true; +} + +function is_accept_allowed(row, action) { + // "Accept" not allowed for Missing in PI + if (action === "Accepted" && row.match_status === "Missing in PI") return false; + return true; +} + +function get_icon(value, column, data) { + return ``; +} + +function get_value_with_indicator(value, column, data) { + let color = "green"; + let title = "Supplier Return: Filed"; + + if (!data.is_supplier_return_filed) { + color = "red"; + title = "Supplier Return: Not Filed"; + } + + value = $(value) + .addClass(`indicator ${color}`) + .attr("title", title) + .prop("outerHTML"); + + return value; +} + +function get_affected_rows(tab, selection, data) { + let invoices = []; + if (tab == "invoice_tab") invoices = selection; + + if (tab == "match_summary_tab") + invoices = data.filter( + inv => selection.filter(row => row.match_status == inv.match_status).length + ); + + if (tab == "action_summary_tab") + invoices = data.filter( + inv => + selection.filter(row => category_map[row.category] == inv.doc_type) + .length + ); + + return invoices.map(row => row.inward_supply_name); +} + +function show_download_invoices_message(frm) { + if (!api_enabled) return; + + const msg_tag = frm + .get_field("no_invoice_data") + .$wrapper.find("#download-invoices-alert"); + + // show alert + msg_tag.removeClass("hidden"); + + // setup listener + msg_tag.on("click", () => { + frm.ims_actions.download_ims_data(); + }); +} diff --git a/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.json b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.json new file mode 100644 index 0000000000..728e7ca6cf --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.json @@ -0,0 +1,97 @@ +{ + "actions": [], + "creation": "2024-10-23 12:09:30.335663", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "company", + "column_break_2", + "company_gstin", + "data_section", + "invoice_html", + "invoice_empty_state", + "no_invoice_data" + ], + "fields": [ + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "fieldname": "company_gstin", + "fieldtype": "Autocomplete", + "label": "Company GSTIN" + }, + { + "depends_on": "eval: doc.data_state === \"available\"", + "fieldname": "invoice_html", + "fieldtype": "HTML" + }, + { + "depends_on": "eval: !doc.data_state", + "fieldname": "invoice_empty_state", + "fieldtype": "HTML", + "options": "\"No\n\t

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

" + }, + { + "depends_on": "eval: doc.data_state === \"unavailable\"", + "fieldname": "no_invoice_data", + "fieldtype": "HTML", + "options": "\"No\n\t

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

\n{{ __(\"Download Invoices\") }}" + }, + { + "fieldname": "data_section", + "fieldtype": "Section Break" + } + ], + "hide_toolbar": 1, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2025-01-17 12:19:24.001794", + "modified_by": "Administrator", + "module": "GST India", + "name": "GST Invoice Management System", + "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": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.py b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.py new file mode 100644 index 0000000000..ed8bc1ad9c --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/gst_invoice_management_system.py @@ -0,0 +1,420 @@ +# Copyright (c) 2024, Resilient Tech and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +from india_compliance.gst_india.api_classes.taxpayer_base import ( + TaxpayerBaseAPI, + otp_handler, +) +from india_compliance.gst_india.api_classes.taxpayer_returns import IMSAPI +from india_compliance.gst_india.constants import STATUS_CODE_MAP +from india_compliance.gst_india.doctype.gst_invoice_management_system import ( + IMSReconciler, + InwardSupply, + PurchaseInvoice, +) +from india_compliance.gst_india.doctype.gst_return_log.generate_gstr_1 import ( + verify_request_in_progress, +) +from india_compliance.gst_india.doctype.gstr_action.gstr_action import set_gstr_actions +from india_compliance.gst_india.doctype.purchase_reconciliation_tool import ( + ReconciledData, +) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + get_formatted_options, +) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + link_documents as _link_documents, +) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + unlink_documents as _unlink_documents, +) +from india_compliance.gst_india.utils.gstr_2 import ( + GSTRCategory, + ReturnType, + download_ims_invoices, + get_data_handler, +) +from india_compliance.gst_india.utils.gstr_utils import ( + publish_action_status_notification, +) + +CATEGORY_MAP = { + "Invoice_0": GSTRCategory.B2B.value, + "Invoice_1": GSTRCategory.B2BA.value, + "Debit Note_0": GSTRCategory.B2BDN.value, + "Debit Note_1": GSTRCategory.B2BDNA.value, + "Credit Note_0": GSTRCategory.B2BCN.value, + "Credit Note_1": GSTRCategory.B2BCNA.value, +} + + +class GSTInvoiceManagementSystem(Document): + @frappe.whitelist() + def autoreconcile_and_get_data(self): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + filters = frappe._dict( + { + "company": self.company, + "company_gstin": self.company_gstin, + } + ) + + # Auto-Reconcile invoices + IMSReconciler().reconcile(filters) + + return { + "invoice_data": self.get_invoice_data(filters=filters), + "pending_actions": self.get_pending_actions(), + } + + def get_invoice_data(self, inward_supply=None, purchase=None, filters=None): + if not filters: + filters = frappe._dict( + { + "company": self.company, + "company_gstin": self.company_gstin, + } + ) + + inward_supplies = InwardSupply().get_all( + company_gstin=self.company_gstin, names=inward_supply + ) + + if not purchase: + purchase = [doc.link_name for doc in inward_supplies] + + purchases = PurchaseInvoice().get_all(names=purchase, filters=filters) + + invoice_data = [] + for doc in inward_supplies: + invoice_data.append( + frappe._dict( + { + "ims_action": doc.ims_action, + "pending_upload": doc.pending_upload, + "previous_ims_action": doc.previous_ims_action, + "is_pending_action_allowed": doc.is_pending_action_allowed, + "is_supplier_return_filed": doc.is_supplier_return_filed, + "doc_type": doc.doc_type, + "_inward_supply": doc, + "_purchase_invoice": purchases.pop( + doc.link_name, frappe._dict() + ), + } + ) + ) + + # Missing in 2A/2B is ignored for IMS + + ReconciledData().process_data(invoice_data, retain_doc=True) + + return invoice_data + + def get_pending_actions(self): + return frappe.get_all( + "GSTR Action", + { + "parent": f"IMS-ALL-{self.company_gstin}", + "parenttype": "GST Return Log", + "status": ["is", "not set"], + "token": ["is", "set"], + }, + pluck="request_type", + ) + + @frappe.whitelist() + def update_action(self, invoice_names, action): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + invoice_names = frappe.parse_json(invoice_names) + + frappe.db.set_value( + "GST Inward Supply", + {"name": ("in", invoice_names)}, + "ims_action", + action, + ) + + @frappe.whitelist() + def get_invoice_details(self, purchase_name, inward_supply_name): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + inward_supply = InwardSupply().get_all( + self.company_gstin, names=[inward_supply_name] + ) + purchases = PurchaseInvoice().get_all(names=[purchase_name]) + + reconciliation_data = [ + frappe._dict( + { + "_inward_supply": ( + inward_supply[0] if inward_supply else frappe._dict() + ), + "_purchase_invoice": purchases.get(purchase_name, frappe._dict()), + } + ) + ] + + ReconciledData().process_data(reconciliation_data, retain_doc=True) + + return reconciliation_data[0] + + @frappe.whitelist() + def link_documents(self, purchase_invoice_name, inward_supply_name, link_doctype): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + purchases, inward_supplies = _link_documents( + purchase_invoice_name, inward_supply_name, link_doctype + ) + + return self.get_invoice_data(inward_supplies, purchases) + + @frappe.whitelist() + def unlink_documents(self, data): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + purchases, inward_supplies = _unlink_documents(data) + + return self.get_invoice_data(inward_supplies, purchases) + + @frappe.whitelist() + def get_link_options(self, doctype, filters): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + if isinstance(filters, dict): + filters = frappe._dict(filters) + + PI = frappe.qb.DocType("Purchase Invoice") + query = ( + PurchaseInvoice() + .get_query(additional_fields=["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.reconciliation_status == "Unreconciled") + + return get_formatted_options(query.run(as_dict=True)) + + +@frappe.whitelist() +@otp_handler +def download_invoices(company_gstin): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + + TaxpayerBaseAPI(company_gstin).validate_auth_token() + + frappe.enqueue(download_ims_invoices, queue="long", gstin=company_gstin) + + +@frappe.whitelist() +@otp_handler +def save_invoices(company_gstin): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + frappe.has_permission("GST Return Log", "write", throw=True) + + return save_ims_invoices(company_gstin) + + +@frappe.whitelist() +@otp_handler +def reset_invoices(company_gstin): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + frappe.has_permission("GST Return Log", "write", throw=True) + + return reset_ims_invoices(company_gstin) + + +@frappe.whitelist() +@otp_handler +def sync_with_gstn_and_reupload(company_gstin): + frappe.has_permission("GST Invoice Management System", "write", throw=True) + frappe.has_permission("GST Return Log", "write", throw=True) + + TaxpayerBaseAPI(company_gstin).validate_auth_token() + + frappe.enqueue( + download_and_upload_ims_invoices, + queue="long", + company_gstin=company_gstin, + ) + + +@frappe.whitelist() +@otp_handler +def check_action_status(company_gstin, action): + frappe.has_permission("GST Return Log", "write", throw=True) + + ims_log = frappe.get_doc( + "GST Return Log", + f"IMS-ALL-{company_gstin}", + ) + + return process_save_or_reset_ims(ims_log, action) + + +def download_and_upload_ims_invoices(company_gstin): + """ + 1. This function will download invoices from GST Portal, + and if there are some queued invoices then upload will be skipped. + + 2. If there are no queued invoices, then it will upload the invoices to GST Portal. + + 3. It will check the status regardless of whether any data was uploaded or not. + (To notify user that process is completed successfully). + """ + + has_queued_invoices = download_ims_invoices(company_gstin, for_upload=True) + + # TODO: flag for pending upload and cron job for queued invoices + if has_queued_invoices: + return + + frappe.publish_realtime( + "upload_data_and_check_status", + user=frappe.session.user, + ) + + +def save_ims_invoices(company_gstin): + if not frappe.db.exists("GST Return Log", f"IMS-ALL-{company_gstin}"): + frappe.throw(_("Please download invoices before uploading")) + + ims_log = frappe.get_doc( + "GST Return Log", + f"IMS-ALL-{company_gstin}", + ) + + save_data = get_data_for_upload(company_gstin, "save") + + if not save_data: + return + + verify_request_in_progress(ims_log, False) + + api = IMSAPI(company_gstin) + + # Upload invoices where action in ["Accepted", "Rejected", "Pending"] + response = api.save(save_data) + set_gstr_actions(ims_log, "save", response.get("reference_id"), api.request_id) + + +def reset_ims_invoices(company_gstin): + if not frappe.db.exists("GST Return Log", f"IMS-ALL-{company_gstin}"): + frappe.throw(_("Please download invoices before uploading")) + + ims_log = frappe.get_doc( + "GST Return Log", + f"IMS-ALL-{company_gstin}", + ) + + reset_data = get_data_for_upload(company_gstin, "reset") + + if not reset_data: + return + + verify_request_in_progress(ims_log, False) + + api = IMSAPI(company_gstin) + + # Reset invoices where action is "No Action" + response = api.reset(reset_data) + set_gstr_actions(ims_log, "reset", response.get("reference_id"), api.request_id) + + +def get_data_for_upload(company_gstin, request_type): + upload_data = {} + key_invoice_map = {} + + if request_type == "save": + gst_inward_supply_list = InwardSupply().get_for_save(company_gstin) + else: + gst_inward_supply_list = InwardSupply().get_for_reset(company_gstin) + + for invoice in gst_inward_supply_list: + key = f"{invoice.doc_type}_{invoice.is_amended}" + key_invoice_map.setdefault(key, []).append(invoice) + + for key, invoices in key_invoice_map.items(): + category = CATEGORY_MAP[key] + _class = get_data_handler(ReturnType.IMS.value, category)() + upload_invoices = [] + + for invoice in invoices: + upload_invoices.append( + { + **_class.convert_data_to_gov_format(invoice), + **_class.get_category_details(invoice), + } + ) + + if upload_invoices: + upload_data[category.lower()] = upload_invoices + + return upload_data + + +def process_save_or_reset_ims(return_log, action): + response = {"status_cd": "P"} # dummy_response + doc = return_log.get_unprocessed_action(action) + if not doc: + return response + + api = IMSAPI(return_log.gstin) + response = api.get_request_status(doc.token) + + status_cd = response.get("status_cd") + + if status_cd != "IP": + doc.db_set({"status": STATUS_CODE_MAP.get(status_cd)}) + publish_action_status_notification( + "IMS", + return_log.return_period, + doc.request_type, + status_cd, + return_log.gstin, + api.request_id if status_cd == "ER" else None, + ) + + if status_cd in ["P", "PE"]: + # Exclude erroneous invoices from previous IMS action update + # This is enqueued because linking of integration request is enqueued + # TODO: flag for re-upload? + frappe.enqueue( + update_previous_ims_action, + queue="long", + integration_request=doc.integration_request, + error_report=response.get("error_report") or dict(), + ) + + return response + + +def update_previous_ims_action(integration_request, error_report=None): + uploaded_invoices = get_uploaded_invoices(integration_request) + + for category, invoices in uploaded_invoices.items(): + _class = get_data_handler(ReturnType.IMS.value, category.upper()) + _class().update_previous_ims_action(invoices, error_report.get(category, [])) + + +def get_uploaded_invoices(integration_request): + request_data = frappe.parse_json( + frappe.db.get_value( + "Integration Request", {"name": integration_request}, "data" + ) + ) + + if not request_data: + return {} + + if isinstance(request_data, str): + request_data = frappe.parse_json(request_data) + + return request_data["body"]["data"]["invdata"] diff --git a/india_compliance/gst_india/doctype/gst_invoice_management_system/invoice_detail_comparison.html b/india_compliance/gst_india/doctype/gst_invoice_management_system/invoice_detail_comparison.html new file mode 100644 index 0000000000..6735501a0d --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/invoice_detail_comparison.html @@ -0,0 +1,91 @@ +
+ + + + + + + + + + + + + + + + + {% 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
Company GSTIN{{ inward_supply.company_gstin || '-' }}{{ purchase.company_gstin || '-' }}
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/gst_invoice_management_system/test_gst_invoice_management_system.py b/india_compliance/gst_india/doctype/gst_invoice_management_system/test_gst_invoice_management_system.py new file mode 100644 index 0000000000..dece865045 --- /dev/null +++ b/india_compliance/gst_india/doctype/gst_invoice_management_system/test_gst_invoice_management_system.py @@ -0,0 +1,29 @@ +# Copyright (c) 2024, Resilient Tech and Contributors +# See license.txt + +# import frappe +# from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +# class TestGSTInvoiceManagementSystem(UnitTestCase): +# """ +# Unit tests for GSTInvoiceManagementSystem. +# Use this class for testing individual functions and methods. +# """ + +# pass + + +# class TestGSTInvoiceManagementSystem(IntegrationTestCase): +# """ +# Integration tests for GSTInvoiceManagementSystem. +# Use this class for testing interactions between multiple components. +# """ + +# pass 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 index 96091e8262..11dc8d019c 100644 --- 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 @@ -70,7 +70,15 @@ "gstr_3b_filled", "column_break_47", "gstr_1_filing_date", - "registration_cancel_date" + "registration_cancel_date", + "ims_details_section", + "ims_action", + "is_pending_action_allowed", + "previous_ims_action", + "column_break_ppww", + "supplier_return_form", + "is_supplier_return_filed", + "is_downloaded_from_ims" ], "fields": [ { @@ -397,6 +405,57 @@ "fieldtype": "Float", "label": "Taxable Value" }, + { + "fieldname": "ims_action", + "fieldtype": "Data", + "label": "IMS Action", + "read_only": 1 + }, + { + "default": "No Action", + "fieldname": "previous_ims_action", + "fieldtype": "Data", + "hidden": 1, + "label": "Previous IMS Action", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_pending_action_allowed", + "fieldtype": "Check", + "label": "Is Pending Action Allowed", + "read_only": 1 + }, + { + "fieldname": "column_break_ppww", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_supplier_return_filed", + "fieldtype": "Check", + "label": "Is Supplier Return Filed", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_downloaded_from_ims", + "fieldtype": "Check", + "hidden": 1, + "label": "Downloaded from IMS", + "read_only": 1 + }, + { + "fieldname": "ims_details_section", + "fieldtype": "Section Break", + "label": "IMS Details" + }, + { + "fieldname": "supplier_return_form", + "fieldtype": "Data", + "label": "Supplier Return Form", + "read_only": 1 + }, { "default": "0", "fieldname": "is_downloaded_from_2b", 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 index 354ebff4d9..03e15b135e 100644 --- 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 @@ -17,6 +17,9 @@ def before_save(self): if self.gstr_1_filing_date: self.gstr_1_filled = True + if self.previous_ims_action and not self.get("ims_action"): + self.ims_action = self.previous_ims_action + if self.match_status != "Amended" and ( self.other_return_period or self.is_amended ): @@ -49,6 +52,26 @@ def create_inward_supply(transaction): return gst_inward_supply.save(ignore_permissions=True) +def update_previous_ims_action(transaction): + """ + After successfull upload of IMS Invoices, + update the ims_action taken in previous_ims_action field. + """ + filters = { + "bill_no": transaction.bill_no, + "bill_date": transaction.bill_date, + "classification": transaction.classification, + "supplier_gstin": transaction.supplier_gstin, + } + + frappe.db.set_value( + "GST Inward Supply", + filters, + "previous_ims_action", + transaction.previous_ims_action or "No Action", + ) + + def update_docs_for_amendment(doc): fields = [ "name", diff --git a/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py b/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py index 67bbd380c9..a24305e62d 100644 --- a/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py +++ b/india_compliance/gst_india/doctype/gst_return_log/generate_gstr_1.py @@ -7,13 +7,16 @@ from frappe.utils import flt, sbool from india_compliance.gst_india.api_classes.taxpayer_returns import GSTR1API -from india_compliance.gst_india.utils.gstr_1 import GovJsonKey, GSTR1_SubCategory -from india_compliance.gst_india.utils.gstr_1.__init__ import ( +from india_compliance.gst_india.constants import STATUS_CODE_MAP +from india_compliance.gst_india.doctype.gstr_action.gstr_action import set_gstr_actions +from india_compliance.gst_india.utils.gstr_1 import ( CATEGORY_SUB_CATEGORY_MAPPING, SUBCATEGORIES_NOT_CONSIDERED_IN_TOTAL_TAX, SUBCATEGORIES_NOT_CONSIDERED_IN_TOTAL_TAXABLE_VALUE, + GovJsonKey, GSTR1_Category, GSTR1_DataField, + GSTR1_SubCategory, ) from india_compliance.gst_india.utils.gstr_1.gstr_1_download import ( download_gstr1_json_data, @@ -23,13 +26,10 @@ convert_to_internal_data_format, summarize_retsum_data, ) +from india_compliance.gst_india.utils.gstr_utils import ( + publish_action_status_notification, +) -status_code_map = { - "P": "Processed", - "PE": "Processed with Errors", - "ER": "Error", - "IP": "In Progress", -} MAXIMUM_UPLOAD_SIZE = 5200000 @@ -739,7 +739,7 @@ def reset_gstr1(self, is_nil_return, force): api = GSTR1API(self) response = api.reset_gstr_1_data(self.return_period) - set_gstr1_actions(self, "reset", response.get("reference_id"), api.request_id) + set_gstr_actions(self, "reset", response.get("reference_id"), api.request_id) def process_reset_gstr1(self): if not self.actions: @@ -756,8 +756,9 @@ def process_reset_gstr1(self): response = api.get_return_status(self.return_period, doc.token) if response.get("status_cd") != "IP": - doc.db_set({"status": status_code_map.get(response.get("status_cd"))}) - enqueue_notification( + doc.db_set({"status": STATUS_CODE_MAP.get(response.get("status_cd"))}) + publish_action_status_notification( + "GSTR-1", self.return_period, "reset", response.get("status_cd"), @@ -790,7 +791,7 @@ def upload_gstr1(self, json_data, force): api = GSTR1API(self) response = api.save_gstr_1_data(self.return_period, json_data) - set_gstr1_actions(self, "upload", response.get("reference_id"), api.request_id) + set_gstr_actions(self, "upload", response.get("reference_id"), api.request_id) def process_upload_gstr1(self): if not self.actions: @@ -808,8 +809,9 @@ def process_upload_gstr1(self): status_cd = response.get("status_cd") if status_cd != "IP": - doc.db_set({"status": status_code_map.get(status_cd)}) - enqueue_notification( + doc.db_set({"status": STATUS_CODE_MAP.get(status_cd)}) + publish_action_status_notification( + "GSTR-1", self.return_period, "upload", status_cd, @@ -844,7 +846,7 @@ def proceed_to_file_gstr1(self, is_nil_return, force): # Return Form already ready to be filed if response.error and response.error.error_cd == "RET00003" or is_nil_return: - set_gstr1_actions( + set_gstr_actions( self, "proceed_to_file", response.get("reference_id"), @@ -853,7 +855,7 @@ def proceed_to_file_gstr1(self, is_nil_return, force): ) return self.fetch_and_compare_summary(api) - set_gstr1_actions( + set_gstr_actions( self, "proceed_to_file", response.get("reference_id"), api.request_id ) @@ -874,7 +876,7 @@ def process_proceed_to_file_gstr1(self): if response.get("status_cd") == "IP": return response - doc.db_set({"status": status_code_map.get(response.get("status_cd"))}) + doc.db_set({"status": STATUS_CODE_MAP.get(response.get("status_cd"))}) return self.fetch_and_compare_summary(api, response) @@ -908,7 +910,8 @@ def fetch_and_compare_summary(self, api, response=None): "differing_categories": differing_categories, } ) - enqueue_notification( + publish_action_status_notification( + "GSTR-1", self.return_period, "proceed_to_file", response.get("status_cd"), @@ -940,7 +943,7 @@ def file_gstr1(self, pan, otp, force): } ) - set_gstr1_actions( + set_gstr_actions( self, "file", response.get("ack_num"), @@ -1061,85 +1064,3 @@ def get_differing_categories(mapped_summary, gov_summary): break return differing_categories - - -def set_gstr1_actions(doc, request_type, token, request_id, status=None): - if not token: - return - - row = { - "request_type": request_type, - "token": token, - "creation_time": frappe.utils.now_datetime(), - } - - if status: - row["status"] = status - - doc.append("actions", row) - doc.save() - enqueue_link_integration_request(token, request_id) - - -def enqueue_link_integration_request(token, request_id): - """ - Integration request is enqueued. Hence, it's name is not available immediately. - Hence, link it after the request is processed. - """ - frappe.enqueue( - link_integration_request, queue="long", token=token, request_id=request_id - ) - - -def link_integration_request(token, request_id): - doc_name = frappe.db.get_value("Integration Request", {"request_id": request_id}) - if doc_name: - frappe.db.set_value( - "GSTR Action", {"token": token}, {"integration_request": doc_name} - ) - - -def enqueue_notification( - return_period, request_type, status_cd, gstin, request_id=None -): - frappe.enqueue( - create_notification, - queue="long", - return_period=return_period, - request_type=request_type, - status_cd=status_cd, - gstin=gstin, - request_id=request_id, - ) - - -def create_notification(return_period, request_type, status_cd, gstin, request_id=None): - # request_id shows failure response - status_message_map = { - "P": f"Data {request_type} for GSTIN {gstin} and return period {return_period} has been successfully completed.", - "PE": f"Data {request_type} for GSTIN {gstin} and return period {return_period} is completed with errors", - "ER": f"Data {request_type} for GSTIN {gstin} and return period {return_period} has encountered errors", - } - - if request_id and ( - doc_name := frappe.db.get_value( - "Integration Request", {"request_id": request_id} - ) - ): - document_type = "Integration Request" - document_name = doc_name - else: - document_type = document_name = "GSTR-1 Beta" - - notification = frappe.get_doc( - { - "doctype": "Notification Log", - "for_user": frappe.session.user, - "type": "Alert", - "document_type": document_type, - "document_name": document_name, - "subject": f"Data {request_type} for GSTIN {gstin} and return period {return_period}", - "email_content": status_message_map.get(status_cd), - } - ) - notification.insert() diff --git a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py index 44242e0206..7796741e55 100644 --- a/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py +++ b/india_compliance/gst_india/doctype/gst_return_log/gst_return_log.py @@ -19,7 +19,10 @@ FileGSTR1, GenerateGSTR1, ) -from india_compliance.gst_india.utils import is_production_api_enabled +from india_compliance.gst_india.utils import ( + get_party_for_gstin, + is_production_api_enabled, +) DOCTYPE = "GST Return Log" @@ -337,3 +340,17 @@ def get_compressed_data(json_data): def get_decompressed_data(content): return frappe.parse_json(frappe.safe_decode(gzip.decompress(content))) + + +def create_ims_return_log(company_gstin): + company = get_party_for_gstin(company_gstin, "Company") + + if frappe.db.exists("GST Return Log", f"IMS-ALL-{company_gstin}"): + return + + ims_log = frappe.new_doc("GST Return Log") + ims_log.return_period = "ALL" + ims_log.company = company + ims_log.gstin = company_gstin + ims_log.return_type = "IMS" + ims_log.insert() diff --git a/india_compliance/gst_india/doctype/gstr_action/gstr_action.py b/india_compliance/gst_india/doctype/gstr_action/gstr_action.py index f267f39ec4..5f99f6a0ac 100644 --- a/india_compliance/gst_india/doctype/gstr_action/gstr_action.py +++ b/india_compliance/gst_india/doctype/gstr_action/gstr_action.py @@ -1,9 +1,45 @@ # Copyright (c) 2024, Resilient Tech and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document class GSTRAction(Document): pass + + +def set_gstr_actions(doc, request_type, token, request_id, status=None): + if not token: + return + + row = { + "request_type": request_type, + "token": token, + "creation_time": frappe.utils.now_datetime(), + } + + if status: + row["status"] = status + + doc.append("actions", row) + doc.save() + enqueue_link_integration_request(token, request_id) + + +def enqueue_link_integration_request(token, request_id): + """ + Integration request is enqueued. Hence, it's name is not available immediately. + Hence, link it after the request is processed. + """ + frappe.enqueue( + link_integration_request, queue="long", token=token, request_id=request_id + ) + + +def link_integration_request(token, request_id): + doc_name = frappe.db.get_value("Integration Request", {"request_id": request_id}) + if doc_name: + frappe.db.set_value( + "GSTR Action", {"token": token}, {"integration_request": doc_name} + ) 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 index da40c8e6cb..d464ba105d 100644 --- 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 @@ -24,10 +24,9 @@ }, { "fieldname": "classification", - "fieldtype": "Select", + "fieldtype": "Data", "in_standard_filter": 1, - "label": "Classification", - "options": "\nB2B\nB2BA\nCDNR\nCDNRA\nISD\nISDA\nIMPG\nIMPGSEZ" + "label": "Classification" }, { "fieldname": "return_period", @@ -68,7 +67,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-29 11:54:43.449587", + "modified": "2024-12-31 18:32:42.409478", "modified_by": "Administrator", "module": "GST India", "name": "GSTR Import Log", diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_detail_comparison.html b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/invoice_detail_comparison.html similarity index 98% rename from india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_detail_comparison.html rename to india_compliance/gst_india/doctype/purchase_reconciliation_tool/invoice_detail_comparison.html index 3bef8ae602..1e44a0e33d 100644 --- a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_detail_comparison.html +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/invoice_detail_comparison.html @@ -15,7 +15,7 @@ Document Links - {% if inward_supply.name %} + {% if inward_supply.name %} {{ frappe.utils.get_form_link("GST Inward Supply", inward_supply.name, true) }} {% else %} 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 index be3295606c..157fc43e79 100644 --- 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 @@ -1,8 +1,6 @@ // Copyright (c) 2022, Resilient Tech and contributors // For license information, please see license.txt -frappe.provide("purchase_reconciliation_tool"); - const DOCTYPE = "Purchase Reconciliation Tool"; const tooltip_info = { purchase_period: "Returns purchases during this period where no match is found.", @@ -70,7 +68,11 @@ frappe.ui.form.on(DOCTYPE, { await frappe.require("purchase_reconciliation_tool.bundle.js"); frm.trigger("company"); - frm.purchase_reconciliation_tool = new PurchaseReconciliationTool(frm); + frm.reconciliation_tabs = new PurchaseReconciliationTool( + frm, + ["invoice", "supplier", "summary"], + "reconciliation_html" + ); frm.events.handle_download_message(frm); }, @@ -185,107 +187,42 @@ frappe.ui.form.on(DOCTYPE, { }, }); -class PurchaseReconciliationTool { - constructor(frm) { - this.init(frm); - this.render_tab_group(); - this.setup_filter_button(); - } - - init(frm) { - this.frm = frm; - this.data = []; - this.$wrapper = this.frm.get_field("reconciliation_html").$wrapper; - this._tabs = ["invoice", "supplier", "summary"]; - } - - generate_data() { - this.data = this.frm.__reconciliation_data; - this.filtered_data = this.frm.__reconciliation_data; - - // clear filters - this.filter_group.filter_x_button.click(); - this.render_data_tables(); - } - - 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`].datatable?.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() { - this.filter_group = new india_compliance.FilterGroup({ - doctype: DOCTYPE, - parent: this.$wrapper.find(".form-tabs-list"), - filter_options: { - fieldname: "supplier_name", - filter_fields: this.get_filter_fields(), +class PurchaseReconciliationTool extends reconciliation.reconciliation_tabs { + get_tab_group_fields() { + return [ + { + //hack: for the FieldGroup(Layout) to avoid rendering default "details" tab + fieldtype: "Section Break", }, - on_change: () => { - this.refresh(); + { + 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", + }, + ]; } get_filter_fields() { @@ -353,55 +290,6 @@ class PurchaseReconciliationTool { 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`].datatable = 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.$datatable.on( @@ -753,16 +641,6 @@ class PurchaseReconciliationTool { }, ]; } - - get_supplier_name_gstin(row) { - return ` - ${row.supplier_name} -
- - ${row.supplier_gstin || ""} - - `; - } } class PurchaseReconciliationToolAction { @@ -806,11 +684,11 @@ class PurchaseReconciliationToolAction { setup_row_actions() { const action_group = __("Actions"); - if (!this.frm.purchase_reconciliation_tool?.data?.length) return; + if (!this.frm.reconciliation_tabs?.data?.length) return; if (this.frm.get_active_tab()?.df.fieldname == "invoice_tab") { this.frm.add_custom_button( __("Unlink"), - () => unlink_documents(this.frm), + () => reconciliation.unlink_documents(this.frm), action_group ); this.frm.add_custom_button(__("dropdown-divider"), () => {}, action_group); @@ -849,7 +727,7 @@ class PurchaseReconciliationToolAction { frm.__reconciliation_data = message; - frm.purchase_reconciliation_tool.generate_data(); + frm.reconciliation_tabs.render_data(frm.__reconciliation_data); frm.doc.data_state = message.length ? "available" : "unavailable"; // Toggle HTML fields @@ -858,7 +736,7 @@ class PurchaseReconciliationToolAction { export_data(selected_row) { const data_to_export = - this.frm.purchase_reconciliation_tool.get_filtered_data(selected_row); + this.frm.reconciliation_tabs.get_filtered_data(selected_row); if (selected_row) delete data_to_export.supplier_summary; const url = @@ -872,207 +750,22 @@ class PurchaseReconciliationToolAction { } } -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 = []; +class DetailViewDialog extends reconciliation.detail_view_dialog { + _get_custom_actions() { const doctype = this.dialog.get_value("doctype"); - if (this.row.match_status == "Missing in 2A/2B") actions.push("Link", "Ignore"); + if (this.row.match_status == "Missing in 2A/2B") return ["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", "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"); + return ["Create", "Link", "Pending", "Ignore"]; + else return ["Link", "Pending", "Ignore"]; + else return ["Unlink", "Accept", "Pending"]; } _apply_custom_action(action) { if (action == "Unlink") { - unlink_documents(this.frm, [this.row]); + reconciliation.unlink_documents(this.frm, [this.row]); } else if (action == "Link") { - purchase_reconciliation_tool.link_documents( + reconciliation.link_documents( this.frm, this.data.purchase_invoice_name, this.data.inward_supply_name, @@ -1080,10 +773,11 @@ class DetailViewDialog { true ); } else if (action == "Create") { - create_new_purchase_invoice( + reconciliation.create_new_purchase_invoice( this.data, this.frm.doc.company, - this.frm.doc.company_gstin + this.frm.doc.company_gstin, + DOCTYPE ); } else { apply_action(this.frm, action, [this.row]); @@ -1099,87 +793,22 @@ class DetailViewDialog { if (action == "Accept") 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); + _set_missing_doctype() { + 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; 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, - }); + this.doctype_options = ["GST Inward Supply"]; + else this.doctype_options = ["Purchase Invoice", "Bill of Entry"]; } - render_table() { - const detail_table = this.dialog.fields_dict.detail_table; - - detail_table.html( - frappe.render_template("purchase_detail_comparison", { - 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"); - }); + _get_default_date_range() { + return [this.frm.doc.purchase_from_date, this.frm.doc.purchase_to_date]; } } @@ -1553,9 +1182,7 @@ class EmailDialog { } get_attachment() { - const export_data = this.frm.purchase_reconciliation_tool.get_filtered_data( - this.data - ); + const export_data = this.frm.reconciliation_tabs.get_filtered_data(this.data); frappe.call({ method: "india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_tool.generate_excel_attachment", @@ -1677,86 +1304,6 @@ function patch_set_active_tab(frm) { }; } -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.datatable.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", { data: 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)); } @@ -1765,11 +1312,11 @@ 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]; + const tab = frm.reconciliation_tabs.tabs[active_tab]; if (!selected_rows) selected_rows = tab.datatable.get_checked_items(); // get affected rows - const { filtered_data, data } = frm.purchase_reconciliation_tool; + const { filtered_data, data } = frm.reconciliation_tabs; let affected_rows = get_affected_rows(active_tab, selected_rows, filtered_data); if (!affected_rows.length) @@ -1821,16 +1368,8 @@ function apply_action(frm, action, selected_rows) { return true; }); - frm.purchase_reconciliation_tool.refresh(new_data); - after_successful_action(tab); -} - -function after_successful_action(tab) { - if (tab) tab.datatable.clear_checked_items(); - frappe.show_alert({ - message: "Action applied successfully", - indicator: "green", - }); + frm.reconciliation_tabs.refresh(new_data); + reconciliation.after_successful_action(tab); } function has_matching_row(row, array) { @@ -1852,65 +1391,6 @@ function get_affected_rows(tab, selection, data) { ); } -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, - is_return: ["CDNR", "CDNRA"].includes(doc.classification) ? 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"); -} - function render_empty_state(frm) { frm.__reconciliation_data = null; frm.doc.data_state = null; 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 index 2d341ae764..aa10e5b76d 100644 --- 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 @@ -22,6 +22,18 @@ ReconciledData, Reconciler, ) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + get_formatted_options, +) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + link_documents as _link_documents, +) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + set_reconciliation_status, +) +from india_compliance.gst_india.doctype.purchase_reconciliation_tool.purchase_reconciliation_utils import ( + unlink_documents as _unlink_documents, +) from india_compliance.gst_india.utils import ( get_gstin_list, get_json_from_file, @@ -30,7 +42,7 @@ ) from india_compliance.gst_india.utils.exporter import ExcelExporter from india_compliance.gst_india.utils.gstr_2 import ( - ACTIONS, + GSTR_2A_ACTIONS, IMPORT_CATEGORY, ReturnType, download_gstr_2a, @@ -79,6 +91,8 @@ def onload(self): @frappe.whitelist() def reconcile_and_generate_data(self): + frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) + # reconcile purchases and inward supplies if frappe.flags.in_install or frappe.flags.in_migrate: return @@ -229,46 +243,11 @@ def get_invoice_details(self, purchase_name, inward_supply_name): 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.set_reconciliation_status( - link_doctype, (isup_linked_with,), "Unreconciled" - ) - 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.extend(pur_linked_with) - - link_doc["match_status"] = "Manual Match" - - # link documents - frappe.db.set_value( - "GST Inward Supply", - inward_supply_name, - link_doc, + purchases, inward_supplies = _link_documents( + purchase_invoice_name, inward_supply_name, link_doctype ) - purchases.append(purchase_invoice_name) - inward_supplies.append(inward_supply_name) - self.set_reconciliation_status( - link_doctype, (purchase_invoice_name,), "Match Found" - ) + set_reconciliation_status(link_doctype, (purchase_invoice_name,), "Match Found") return self.ReconciledData.get(purchases, inward_supplies) @@ -276,57 +255,9 @@ def link_documents(self, purchase_invoice_name, inward_supply_name, link_doctype def unlink_documents(self, data): frappe.has_permission("Purchase Reconciliation Tool", "write", throw=True) - 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) - - 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 + purchases, inward_supplies = _unlink_documents(data) - 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() - ) + return self.ReconciledData.get(purchases, inward_supplies) @frappe.whitelist() def apply_action(self, data, action): @@ -361,8 +292,8 @@ def apply_action(self, data, action): "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) + set_reconciliation_status("Purchase Invoice", purchases, status) + set_reconciliation_status("Bill of Entry", boe, status) @frappe.whitelist() def get_link_options(self, doctype, filters): @@ -393,7 +324,7 @@ def get_purchase_invoice_options(self, filters): PI.name.notin(PurchaseInvoice.query_matched_purchase_invoice()) ) - return self._get_link_options(query.run(as_dict=True)) + return get_formatted_options(query.run(as_dict=True)) def get_inward_supply_options(self, filters): GSTR2 = frappe.qb.DocType("GST Inward Supply") @@ -411,7 +342,7 @@ def get_inward_supply_options(self, filters): if not filters.show_matched: query = query.where(IfNull(GSTR2.link_name, "") == "") - return self._get_link_options(query.run(as_dict=True)) + return get_formatted_options(query.run(as_dict=True)) def get_bill_of_entry_options(self, filters): BOE = frappe.qb.DocType("Bill of Entry") @@ -424,22 +355,7 @@ def get_bill_of_entry_options(self, filters): 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 + return get_formatted_options(query.run(as_dict=True)) def download_gstr( @@ -651,7 +567,7 @@ def download_gst_returns(self): def get_gst_categories(self): return [ category.value - for category in ACTIONS.values() + for category in GSTR_2A_ACTIONS.values() if getattr(self.gst_settings, "reconcile_for_" + category.value.lower()) ] diff --git a/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_utils.py b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_utils.py new file mode 100644 index 0000000000..fca48ae699 --- /dev/null +++ b/india_compliance/gst_india/doctype/purchase_reconciliation_tool/purchase_reconciliation_utils.py @@ -0,0 +1,124 @@ +import frappe + +from india_compliance.gst_india.doctype.purchase_reconciliation_tool import ( + BaseUtil, + ReconciledData, +) + + +def link_documents(purchase_invoice_name, inward_supply_name, link_doctype): + purchases = [] + inward_supplies = [] + + 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" + ): + set_reconciliation_status(link_doctype, (isup_linked_with,), "Unreconciled") + _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" + ): + _unlink_documents((pur_linked_with)) + inward_supplies.extend(pur_linked_with) + + link_doc["match_status"] = "Manual Match" + + # link documents + frappe.db.set_value("GST Inward Supply", inward_supply_name, link_doc) + set_reconciliation_status(link_doctype, (purchase_invoice_name,), "Match Found") + + purchases.append(purchase_invoice_name) + inward_supplies.append(inward_supply_name) + + return purchases, inward_supplies + + +def unlink_documents(data): + data = frappe.parse_json(data) + inward_supplies = set() + purchases = set() + boe = set() + + for row in data: + inward_supplies.add(row.get("inward_supply_name")) + + purchase_doctype = row.get("purchase_doctype") + if purchase_doctype == "Purchase Invoice": + purchases.add(row.get("purchase_invoice_name")) + + elif purchase_doctype == "Bill of Entry": + boe.add(row.get("purchase_invoice_name")) + + set_reconciliation_status("Purchase Invoice", purchases, "Unreconciled") + set_reconciliation_status("Bill of Entry", boe, "Unreconciled") + _unlink_documents(inward_supplies) + + return purchases.union(boe), inward_supplies + + +def _unlink_documents(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 Purchase Reconciliation action performed + ( + frappe.qb.update(GSTR2) + .set("action", "No Action") + .where(GSTR2.name.isin(inward_supplies)) + .where(GSTR2.action.notin(("Ignore", "Pending"))) + .run() + ) + + # Revert IMS action performed + ( + frappe.qb.update(GSTR2) + .set("ims_action", "No Action") + .where(GSTR2.name.isin(inward_supplies)) + .where(GSTR2.ims_action == "Accepted") + .run() + ) + + +def get_formatted_options(data): + for row in data: + row.value = row.label = row.name + if not row.get("classification"): + row.classification = 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 set_reconciliation_status(doctype, names, status): + if not names: + return + + frappe.db.set_value( + doctype, {"name": ("in", names)}, "reconciliation_status", status + ) diff --git a/india_compliance/gst_india/utils/__init__.py b/india_compliance/gst_india/utils/__init__.py index 5763d14143..974dd6cff5 100644 --- a/india_compliance/gst_india/utils/__init__.py +++ b/india_compliance/gst_india/utils/__init__.py @@ -116,6 +116,7 @@ def get_gstin_list(party, party_type="Company"): @frappe.whitelist() +@frappe.request_cache def get_party_for_gstin(gstin, party_type="Supplier"): if not gstin: return @@ -1040,3 +1041,29 @@ def is_outward_stock_entry(doc): and not doc.is_return ): return True + + +def create_notification( + message_content, document_type, document_name=None, request_id=None +): + # request_id shows failure response + if request_id and ( + doc_name := frappe.db.get_value( + "Integration Request", {"request_id": request_id} + ) + ): + document_type = "Integration Request" + document_name = doc_name + + notification = frappe.get_doc( + { + "doctype": "Notification Log", + "for_user": frappe.session.user, + "type": "Alert", + "document_type": document_type, + "document_name": document_name or document_type, + "subject": message_content.get("subject"), + "email_content": message_content.get("body"), + } + ) + notification.insert() diff --git a/india_compliance/gst_india/utils/gstr_2/__init__.py b/india_compliance/gst_india/utils/gstr_2/__init__.py index 01ad6d012b..8d373ad584 100644 --- a/india_compliance/gst_india/utils/gstr_2/__init__.py +++ b/india_compliance/gst_india/utils/gstr_2/__init__.py @@ -5,12 +5,19 @@ from frappe.query_builder.terms import Criterion from frappe.utils import cint -from india_compliance.gst_india.api_classes.taxpayer_returns import GSTR2aAPI, GSTR2bAPI +from india_compliance.gst_india.api_classes.taxpayer_returns import ( + IMSAPI, + GSTR2aAPI, + GSTR2bAPI, +) +from india_compliance.gst_india.doctype.gst_return_log.gst_return_log import ( + create_ims_return_log, +) from india_compliance.gst_india.doctype.gstr_import_log.gstr_import_log import ( create_import_log, ) from india_compliance.gst_india.utils import get_party_for_gstin -from india_compliance.gst_india.utils.gstr_2 import gstr_2a, gstr_2b +from india_compliance.gst_india.utils.gstr_2 import gstr_2a, gstr_2b, ims from india_compliance.gst_india.utils.gstr_utils import ReturnType @@ -24,8 +31,14 @@ class GSTRCategory(Enum): IMPG = "IMPG" IMPGSEZ = "IMPGSEZ" + # IMS + B2BCN = "B2BCN" + B2BCNA = "B2BCNA" + B2BDN = "B2BDN" + B2BDNA = "B2BDNA" + -ACTIONS = { +GSTR_2A_ACTIONS = { "B2B": GSTRCategory.B2B, "B2BA": GSTRCategory.B2BA, "CDN": GSTRCategory.CDNR, @@ -35,16 +48,27 @@ class GSTRCategory(Enum): "IMPGSEZ": GSTRCategory.IMPGSEZ, } +IMS_ACTIONS = { + "B2B": GSTRCategory.B2B, + "B2BA": GSTRCategory.B2BA, + "CN": GSTRCategory.B2BCN, + "CNA": GSTRCategory.B2BCNA, + "DN": GSTRCategory.B2BDN, + "DNA": GSTRCategory.B2BDNA, +} + + GSTR_MODULES = { ReturnType.GSTR2A.value: gstr_2a, ReturnType.GSTR2B.value: gstr_2b, + ReturnType.IMS.value: ims, } IMPORT_CATEGORY = ("IMPG", "IMPGSEZ") def download_gstr_2a(gstin, return_periods, gst_categories=None): - total_expected_requests = len(return_periods) * len(ACTIONS) + total_expected_requests = len(return_periods) * len(GSTR_2A_ACTIONS) requests_made = 0 queued_message = False @@ -55,7 +79,7 @@ def download_gstr_2a(gstin, return_periods, gst_categories=None): json_data = frappe._dict({"gstin": gstin, "fp": return_period}) has_data = False - for action, category in ACTIONS.items(): + for action, category in GSTR_2A_ACTIONS.items(): requests_made += 1 frappe.publish_realtime( @@ -115,7 +139,7 @@ def download_gstr_2a(gstin, return_periods, gst_categories=None): save_gstr_2a(gstin, return_period, json_data) if queued_message: - publish_queued_message() + publish_2a_2b_queued_message() if not has_data: end_transaction_progress(return_period) @@ -183,12 +207,58 @@ def download_gstr_2b(gstin, return_periods): save_gstr_2b(gstin, return_period, response) if queued_message: - publish_queued_message() + publish_2a_2b_queued_message() if not has_data: end_transaction_progress(return_period) +def download_ims_invoices(gstin, for_upload=False): + api = IMSAPI(gstin) + has_queued_invoices = False + has_non_queued_invoices = False + json_data = {} + + for action, category in IMS_ACTIONS.items(): + response = api.get_data(action) + category = category.value + + if response.error_type == "no_docs_found": + continue + + # Queued + if response.token: + create_import_log( + gstin, + "IMS", + "ALL", + classification=category, + request_id=response.token, + retry_after_mins=cint(response.est), + ) + has_queued_invoices = True + continue + + json_data[category.lower()] = response.get(category.lower()) + has_non_queued_invoices = True + + save_ims_invoices(gstin, None, json_data) + + create_ims_return_log(gstin) + + if has_queued_invoices: + publish_ims_queued_message(for_upload) + + if has_non_queued_invoices: + frappe.publish_realtime( + "ims_download_completed", + message={"message": _("Downloaded Invoices successfully")}, + user=frappe.session.user, + ) + + return has_queued_invoices + + def save_gstr_2a(gstin, return_period, json_data): return_type = ReturnType.GSTR2A if ( @@ -204,7 +274,7 @@ def save_gstr_2a(gstin, return_period, json_data): title=_("Invalid Response Received."), ) - for action, category in ACTIONS.items(): + for action, category in GSTR_2A_ACTIONS.items(): if action.lower() not in json_data: continue @@ -241,6 +311,10 @@ def save_gstr_2b(gstin, return_period, json_data): update_import_history(return_period) +def save_ims_invoices(gstin, return_period, json_data): + save_gstr(gstin, ReturnType.IMS, return_period, json_data) + + def save_gstr( gstin, return_type: ReturnType, return_period, json_data, gen_date_2b=None ): @@ -253,7 +327,10 @@ def save_gstr( company = get_party_for_gstin(gstin, "Company") for category in GSTRCategory: - gstr = get_data_handler(return_type.value, category) + gstr = get_data_handler(return_type.value, category.value) + if not gstr: + continue + gstr(company, gstin, return_period, json_data, gen_date_2b).create_transactions( category, json_data.get(category.value.lower()), @@ -261,8 +338,8 @@ def save_gstr( def get_data_handler(return_type, category): - class_name = return_type + category.value - return getattr(GSTR_MODULES[return_type], class_name) + class_name = return_type + category + return getattr(GSTR_MODULES[return_type], class_name, None) def update_import_history(return_periods): @@ -300,7 +377,7 @@ def _download_gstr_2a(gstin, return_period, json_data): save_gstr_2a(gstin, return_period, json_data) -def publish_queued_message(): +def publish_2a_2b_queued_message(): frappe.publish_realtime( "gstr_2a_2b_download_message", { @@ -315,6 +392,25 @@ def publish_queued_message(): ) +def publish_ims_queued_message(for_upload): + message = _( + "Some categories are queued for download at GSTN as there may be large data." + " We will retry downloading every few minutes until it succeeds." + ) + if for_upload: + message = _( + "Some categories are queued for download at GSTN as there may be large data." + " We will retry downloading every few minutes until it succeeds.

" + " Please try uploading the data again after a few minutes." + ) + + frappe.publish_realtime( + "ims_download_queued", + message={"message": message}, + user=frappe.session.user, + ) + + def end_transaction_progress(return_period): """ For last period, set progress to 100% if no data is found diff --git a/india_compliance/gst_india/utils/gstr_2/gstr.py b/india_compliance/gst_india/utils/gstr_2/gstr.py index ce284940b1..e77ff2bb9e 100644 --- a/india_compliance/gst_india/utils/gstr_2/gstr.py +++ b/india_compliance/gst_india/utils/gstr_2/gstr.py @@ -1,6 +1,6 @@ import frappe -from india_compliance.gst_india.constants import STATE_NUMBERS +from india_compliance.gst_india.constants import GST_CATEGORY_MAP, STATE_NUMBERS from india_compliance.gst_india.doctype.gst_inward_supply.gst_inward_supply import ( create_inward_supply, ) @@ -19,13 +19,7 @@ class GSTR: { "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", - }, + "gst_category": GST_CATEGORY_MAP, "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"}, diff --git a/india_compliance/gst_india/utils/gstr_2/ims.py b/india_compliance/gst_india/utils/gstr_2/ims.py new file mode 100644 index 0000000000..13c0bed85f --- /dev/null +++ b/india_compliance/gst_india/utils/gstr_2/ims.py @@ -0,0 +1,319 @@ +import frappe +from frappe.utils.data import format_date + +from india_compliance.gst_india.constants import ( + ACTION_MAP, + GST_CATEGORY_MAP, + STATE_NUMBERS, +) +from india_compliance.gst_india.doctype.gst_inward_supply.gst_inward_supply import ( + create_inward_supply, +) +from india_compliance.gst_india.doctype.gst_inward_supply.gst_inward_supply import ( + update_previous_ims_action as _update_previous_ims_action, +) +from india_compliance.gst_india.utils import parse_datetime +from india_compliance.gst_india.utils.gstr_2.gstr import get_mapped_value + +CLASSIFICATION_MAP = { + "B2B": ["B2B", "Invoice"], + "B2BA": ["B2BA", "Invoice"], + "B2BCN": ["CDNR", "Credit Note"], + "B2BCNA": ["CDNRA", "Credit Note"], + "B2BDN": ["CDNR", "Debit Note"], + "B2BDNA": ["CDNRA", "Debit Note"], +} + + +class IMS: + VALUE_MAPS = frappe._dict( + { + "states": {value: f"{value}-{key}" for key, value in STATE_NUMBERS.items()}, + "reverse_states": STATE_NUMBERS, + "action": ACTION_MAP, + "reverse_action": {v: k for k, v in ACTION_MAP.items()}, + "gst_category": GST_CATEGORY_MAP, + "reverse_gst_category": {v: k for k, v in GST_CATEGORY_MAP.items()}, + "classification": CLASSIFICATION_MAP, + } + ) + + def __init__(self, company=None, gstin=None, *args): + self.company_gstin = gstin + self.company = company + self.existing_transactions = self.get_existing_transactions() + + def create_transactions(self, category, invoices): + self.reset_previous_ims_action() + + if not invoices: + return + + transactions = self.get_all_transactions(invoices) + + for transaction in transactions: + create_inward_supply(transaction) + + if transaction.get("unique_key") in self.existing_transactions: + self.existing_transactions.pop(transaction.get("unique_key")) + + self.handle_missing_transactions() + + def get_all_transactions(self, invoices): + transactions = [] + for invoice in invoices: + invoice = frappe._dict(invoice) + transactions.append(self.get_transaction(invoice)) + + return transactions + + def update_previous_ims_action(self, uploaded_invoices, error_invoices): + errors = set() + + for supplier in error_invoices: + for invoice in supplier.get("inv"): + + # same key across categories + errors.add(f"{invoice.get('inum')}_{supplier.get('stin')}") + + for invoice in uploaded_invoices: + invoice = self.get_transaction(frappe._dict(invoice)) + + # different keys across categories + if f"{invoice.get('bill_no')}_{invoice.get('supplier_gstin')}" in errors: + continue + + _update_previous_ims_action(invoice) + + def get_transaction(self, invoice): + transaction = frappe._dict( + **self.convert_data_to_internal_format(invoice), + **self.get_invoice_details(invoice), + ) + + transaction["unique_key"] = ( + f"{transaction.get('supplier_gstin', '')}-{transaction.get('bill_no', '')}" + ) + + return transaction + + def convert_data_to_internal_format(self, invoice): + return { + "supplier_gstin": invoice.stin, + "sup_return_period": invoice.rtnprd, + "supply_type": get_mapped_value( + invoice.inv_typ, self.VALUE_MAPS.gst_category + ), + "place_of_supply": get_mapped_value(invoice.pos, self.VALUE_MAPS.states), + "document_value": invoice.val, + "company": self.company, + "company_gstin": self.company_gstin, + "is_pending_action_allowed": invoice.ispendactblocked == "N", + "previous_ims_action": get_mapped_value( + invoice.action, self.VALUE_MAPS.action + ), + "is_downloaded_from_ims": 1, + "is_supplier_return_filed": 0 if invoice.srcfilstatus == "Not Filed" else 1, + "supplier_return_form": invoice.srcform, + "cgst": invoice.camt, + "sgst": invoice.samt, + "igst": invoice.iamt, + "cess": invoice.cess, + "taxable_value": invoice.txval, + } + + def convert_data_to_gov_format(self, invoice): + data = { + "stin": invoice.supplier_gstin, + "inv_typ": get_mapped_value( + invoice.supply_type, self.VALUE_MAPS.reverse_gst_category + ), + "srcform": invoice.supplier_return_form, + "rtnprd": invoice.sup_return_period, + "val": invoice.document_value, + "pos": get_mapped_value( + invoice.place_of_supply.split("-")[1], self.VALUE_MAPS.reverse_states + ), + "prev_status": get_mapped_value( + invoice.previous_ims_action, self.VALUE_MAPS.reverse_action + ), + "iamt": invoice.igst, + "camt": invoice.cgst, + "samt": invoice.sgst, + "cess": invoice.cess, + "txval": invoice.taxable_value, + } + + if invoice.ims_action != "No Action": + data["action"] = get_mapped_value( + invoice.ims_action, self.VALUE_MAPS.reverse_action + ) + + return data + + def get_existing_transactions(self): + category, doc_type = get_mapped_value( + self.ims_category(), self.VALUE_MAPS.classification + ) + + inward_supply = frappe.qb.DocType("GST Inward Supply") + existing_transactions = ( + frappe.qb.from_(inward_supply) + .select( + inward_supply.name, inward_supply.supplier_gstin, inward_supply.bill_no + ) + .where(inward_supply.is_downloaded_from_2b == 0) + .where(inward_supply.is_downloaded_from_2a == 0) + .where(inward_supply.is_downloaded_from_ims == 1) + .where(inward_supply.is_supplier_return_filed == 0) + .where(inward_supply.classification == category) + .where(inward_supply.doc_type == doc_type) + .where(inward_supply.company_gstin == self.company_gstin) + .run(as_dict=True) + ) + + return { + f"{transaction.get('supplier_gstin', '')}-{transaction.get('bill_no', '')}": transaction.get( + "name" + ) + for transaction in existing_transactions + } + + def handle_missing_transactions(self): + if not self.existing_transactions: + return + + for inward_supply_name in self.existing_transactions.values(): + frappe.delete_doc("GST Inward Supply", inward_supply_name) + + def reset_previous_ims_action(self): + category, doc_type = get_mapped_value( + self.ims_category(), self.VALUE_MAPS.classification + ) + inward_supply = frappe.qb.DocType("GST Inward Supply") + + ( + frappe.qb.update(inward_supply) + .set(inward_supply.previous_ims_action, "") + .where(inward_supply.classification == category) + .where(inward_supply.doc_type == doc_type) + .where(inward_supply.company_gstin == self.company_gstin) + .run() + ) + + def ims_category(self): + return type(self).__name__.removeprefix("IMS") + + +class IMSB2B(IMS): + def get_invoice_details(self, invoice): + return { + "bill_no": invoice.inum, + "bill_date": parse_datetime(invoice.idt, day_first=True), + "classification": "B2B", + "doc_type": "Invoice", + } + + def get_category_details(self, invoice): + return { + "inum": invoice.bill_no, + "idt": format_date(invoice.bill_date, "dd-mm-yyyy"), + } + + +class IMSB2BA(IMSB2B): + 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), + "is_amended": True, + "classification": "B2BA", + } + ) + return invoice_details + + def get_category_details(self, invoice): + invoice_details = super().get_category_details(invoice) + invoice_details.update( + { + "oinum": invoice.original_bill_no, + "oidt": format_date(invoice.original_bill_date, "dd-mm-yyyy"), + } + ) + return invoice_details + + +class IMSB2BDN(IMSB2B): + def get_invoice_details(self, invoice): + return { + "bill_no": invoice.nt_num, + "bill_date": parse_datetime(invoice.nt_dt, day_first=True), + "classification": "CDNR", + "doc_type": "Debit Note", + } + + def get_category_details(self, invoice): + return { + "nt_num": invoice.bill_no, + "nt_dt": format_date(invoice.bill_date, "dd-mm-yyyy"), + } + + +class IMSB2BDNA(IMSB2BDN): + 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), + "is_amended": True, + "original_doc_type": "Debit Note", + "classification": "CDNRA", + } + ) + return invoice_details + + def get_category_details(self, invoice): + invoice_details = super().get_category_details(invoice) + invoice_details.update( + { + "ont_num": invoice.original_bill_no, + "ont_dt": format_date(invoice.original_bill_date, "dd-mm-yyyy"), + } + ) + return invoice_details + + +class IMSB2BCN(IMSB2BDN): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "doc_type": "Credit Note", + } + ) + return invoice_details + + +class IMSB2BCNA(IMSB2BDNA): + def get_invoice_details(self, invoice): + invoice_details = super().get_invoice_details(invoice) + invoice_details.update( + { + "doc_type": "Credit Note", + "original_doc_type": "Credit Note", + } + ) + return invoice_details + + def get_category_details(self, invoice): + invoice_details = super().get_category_details(invoice) + invoice_details.update( + { + "ont_num": invoice.original_bill_no, + "ont_dt": format_date(invoice.original_bill_date, "dd-mm-yyyy"), + } + ) + return invoice_details diff --git a/india_compliance/gst_india/utils/gstr_2/test_ims.py b/india_compliance/gst_india/utils/gstr_2/test_ims.py new file mode 100644 index 0000000000..75c542b58e --- /dev/null +++ b/india_compliance/gst_india/utils/gstr_2/test_ims.py @@ -0,0 +1,215 @@ +from datetime import date + +import frappe +from frappe import parse_json, read_file +from frappe.tests import IntegrationTestCase + +from india_compliance.gst_india.utils import get_data_file_path +from india_compliance.gst_india.utils.gstr_2 import save_ims_invoices + + +class TestIMS(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.gstin = "24AAQCA8719H1ZC" + cls.doctype = "GST Inward Supply" + cls.test_data = parse_json(read_file(get_data_file_path("test_ims.json"))) + + save_ims_invoices(cls.gstin, "ALL", cls.test_data) + + def get_doc(self, category, doc_type): + docname = frappe.get_value( + self.doctype, + { + "company_gstin": self.gstin, + "classification": category, + "doc_type": doc_type, + }, + ) + self.assertIsNotNone(docname) + return frappe.get_doc(self.doctype, docname) + + def test_ims_b2b(self): + doc = self.get_doc("B2B", "Invoice") + print(doc.as_dict()) + self.assertDocumentEqual( + { + "bill_date": date(2023, 1, 23), + "bill_no": "b1", + "doc_type": "Invoice", + "supplier_gstin": "24MAYAS0100J1JD", + "supply_type": "Regular", + "classification": "B2B", + "place_of_supply": "24-Gujarat", + "document_value": 1000, + "is_downloaded_from_ims": 1, + "ims_action": "Accepted", + "previous_ims_action": "Accepted", + "is_pending_action_allowed": 1, + "is_supplier_return_filed": 0, + "supplier_return_form": "R1", + "sup_return_period": "012023", + "taxable_value": 100, + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 0, + }, + doc, + ) + + def test_ims_b2ba(self): + doc = self.get_doc("B2BA", "Invoice") + print(doc.as_dict()) + self.assertDocumentEqual( + { + "bill_date": date(2023, 1, 23), + "bill_no": "b1a", + "doc_type": "Invoice", + "supplier_gstin": "24MAYAS0100J1JD", + "supply_type": "Regular", + "classification": "B2BA", + "place_of_supply": "07-Delhi", + "original_bill_no": "ab2", + "original_bill_date": date(2023, 2, 24), + "is_amended": True, + "document_value": 1000, + "is_downloaded_from_ims": 1, + "ims_action": "Accepted", + "previous_ims_action": "Accepted", + "is_pending_action_allowed": 1, + "is_supplier_return_filed": 0, + "supplier_return_form": "R1", + "sup_return_period": "012023", + "taxable_value": 100, + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 0, + }, + doc, + ) + + def test_ims_dn(self): + doc = self.get_doc("CDNR", "Debit Note") + print(doc.as_dict()) + self.assertDocumentEqual( + { + "bill_date": date(2023, 2, 24), + "bill_no": "dn2", + "doc_type": "Debit Note", + "supplier_gstin": "24MAYAS0100J1JD", + "supply_type": "Regular", + "classification": "CDNR", + "place_of_supply": "07-Delhi", + "document_value": 1000.1, + "is_downloaded_from_ims": 1, + "ims_action": "Accepted", + "previous_ims_action": "Accepted", + "is_pending_action_allowed": 1, + "is_supplier_return_filed": 1, + "supplier_return_form": "R1", + "sup_return_period": "012023", + "taxable_value": 1000.1, + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 0, + }, + doc, + ) + + def test_ims_dna(self): + doc = self.get_doc("CDNRA", "Debit Note") + print(doc.as_dict()) + self.assertDocumentEqual( + { + "bill_no": "dna2", + "bill_date": date(2023, 2, 24), + "original_bill_no": "dn2", + "original_bill_date": date(2023, 2, 24), + "doc_type": "Debit Note", + "supplier_gstin": "24MAYAS0100J1JD", + "supply_type": "Regular", + "classification": "CDNRA", + "place_of_supply": "07-Delhi", + "is_amended": True, + "document_value": 1000.1, + "is_downloaded_from_ims": 1, + "ims_action": "Accepted", + "previous_ims_action": "Accepted", + "is_pending_action_allowed": 1, + "is_supplier_return_filed": 1, + "supplier_return_form": "R1", + "sup_return_period": "012023", + "taxable_value": 1000.1, + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 0, + }, + doc, + ) + + def test_ims_cn(self): + doc = self.get_doc("CDNR", "Credit Note") + print(doc.as_dict()) + self.assertDocumentEqual( + { + "bill_date": date(2023, 2, 24), + "bill_no": "cn2", + "doc_type": "Credit Note", + "supplier_gstin": "24MAYAS0100J1JD", + "supply_type": "Regular", + "classification": "CDNR", + "place_of_supply": "07-Delhi", + "document_value": 1000.1, + "is_downloaded_from_ims": 1, + "ims_action": "Accepted", + "previous_ims_action": "Accepted", + "is_pending_action_allowed": 1, + "is_supplier_return_filed": 0, + "supplier_return_form": "R1", + "sup_return_period": "012023", + "taxable_value": 1000.1, + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 0, + }, + doc, + ) + + def test_ims_cna(self): + doc = self.get_doc("CDNRA", "Credit Note") + print(doc.as_dict()) + self.assertDocumentEqual( + { + "bill_no": "cna2", + "bill_date": date(2023, 2, 24), + "original_bill_no": "cn2", + "original_bill_date": date(2023, 2, 24), + "doc_type": "Credit Note", + "supplier_gstin": "24MAYAS0100J1JD", + "supply_type": "Regular", + "classification": "CDNRA", + "place_of_supply": "07-Delhi", + "is_amended": True, + "document_value": 1000.1, + "is_downloaded_from_ims": 1, + "ims_action": "Accepted", + "previous_ims_action": "Accepted", + "is_pending_action_allowed": 1, + "is_supplier_return_filed": 0, + "supplier_return_form": "R1", + "sup_return_period": "012023", + "taxable_value": 1000.1, + "igst": 20, + "cgst": 20, + "sgst": 20, + "cess": 0, + }, + doc, + ) diff --git a/india_compliance/gst_india/utils/gstr_utils.py b/india_compliance/gst_india/utils/gstr_utils.py index 67ae50e802..20a9747f83 100644 --- a/india_compliance/gst_india/utils/gstr_utils.py +++ b/india_compliance/gst_india/utils/gstr_utils.py @@ -6,11 +6,12 @@ TaxpayerBaseAPI, otp_handler, ) -from india_compliance.gst_india.api_classes.taxpayer_returns import ReturnsAPI +from india_compliance.gst_india.api_classes.taxpayer_returns import IMSAPI, 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 create_notification from india_compliance.gst_india.utils.gstr_1.gstr_1_download import ( save_gstr_1_filed_data, save_gstr_1_unfiled_data, @@ -22,6 +23,7 @@ class ReturnType(Enum): GSTR2B = "GSTR2b" GSTR1 = "GSTR1" UnfiledGSTR1 = "Unfiled GSTR1" + IMS = "IMS" @frappe.whitelist() @@ -71,17 +73,30 @@ def download_queued_request(): def _download_queued_request(doc): - from india_compliance.gst_india.utils.gstr_2 import _download_gstr_2a, save_gstr_2b + from india_compliance.gst_india.utils.gstr_2 import ( + _download_gstr_2a, + save_gstr_2b, + save_ims_invoices, + ) GSTR_FUNCTIONS = { ReturnType.GSTR2A.value: _download_gstr_2a, ReturnType.GSTR2B.value: save_gstr_2b, ReturnType.GSTR1.value: save_gstr_1_filed_data, ReturnType.UnfiledGSTR1.value: save_gstr_1_unfiled_data, + ReturnType.IMS.value: save_ims_invoices, + } + + API_CLASS = { + ReturnType.GSTR2A.value: ReturnsAPI, + ReturnType.GSTR2B.value: ReturnsAPI, + ReturnType.GSTR1.value: ReturnsAPI, + ReturnType.UnfiledGSTR1.value: ReturnsAPI, + ReturnType.IMS.value: IMSAPI, } try: - api = ReturnsAPI(doc.gstin) + api = API_CLASS[doc.return_type](doc.gstin) response = api.download_files( doc.return_period, doc.request_id, @@ -108,3 +123,31 @@ def _download_queued_request(doc): frappe.db.set_value("GSTR Import Log", doc.name, "request_id", None) GSTR_FUNCTIONS[doc.return_type](doc.gstin, doc.return_period, response) + + +def publish_action_status_notification( + return_type, return_period, request_type, status_cd, gstin, request_id=None +): + status_message_map = { + "P": f"Success: {return_type} data {request_type} for GSTIN {gstin} and return period {return_period}", + "PE": f"Partial Success: {return_type} data {request_type} for GSTIN {gstin} and return period {return_period}", + "ER": f"Error: {return_type} data {request_type} for GSTIN {gstin} and return period {return_period}", + } + + message_content = { + "subject": status_message_map.get(status_cd), + "body": status_message_map.get(status_cd), + } + + if return_type == "GSTR-1": + document_type = "GSTR-1 Beta" + elif return_type == "IMS": + document_type = "GST Invoice Management System" + + return frappe.enqueue( + create_notification, + queue="long", + message_content=message_content, + document_type=document_type, + request_id=request_id, + ) diff --git a/india_compliance/public/js/components/data_table_manager.js b/india_compliance/public/js/components/data_table_manager.js index b56f13f699..fe88a1eb31 100644 --- a/india_compliance/public/js/components/data_table_manager.js +++ b/india_compliance/public/js/components/data_table_manager.js @@ -25,8 +25,7 @@ india_compliance.DataTableManager = class DataTableManager { refresh(data, columns, noDataMessage) { this.data = data; - if (noDataMessage) - this.datatable.options.noDataMessage = noDataMessage; + if (noDataMessage) this.datatable.options.noDataMessage = noDataMessage; this.datatable.refresh(data, columns); } @@ -81,7 +80,13 @@ india_compliance.DataTableManager = class DataTableManager { value = column._value(value, column, data); } - return frappe.format(value, column, { always_show_decimals: true }, data); + value = frappe.format(value, column, { always_show_decimals: true }, data); + + if (column.post_format) { + value = column._after_format(value, column, data); + } + + return value; }; return { diff --git a/india_compliance/public/js/components/filter_group.js b/india_compliance/public/js/components/filter_group.js index 3ea3b45bc9..7cee192719 100644 --- a/india_compliance/public/js/components/filter_group.js +++ b/india_compliance/public/js/components/filter_group.js @@ -103,6 +103,8 @@ india_compliance.FilterGroup = class FilterGroup extends frappe.ui.FilterGroup { this.filters = this.filters.filter(f => { let f_value = f.get_value(); + if (filter_value.length === 2) f_value = f_value.slice(0, 2); + return !frappe.utils.arrays_equal( f_value.slice(0, 4), filter_value.slice(0, 4) diff --git a/india_compliance/public/js/ims.bundle.js b/india_compliance/public/js/ims.bundle.js new file mode 100644 index 0000000000..ce7feb6f6d --- /dev/null +++ b/india_compliance/public/js/ims.bundle.js @@ -0,0 +1,5 @@ +import "./components/data_table_manager"; +import "./components/number_card"; +import "./components/set_gstin_options"; +import "./components/filter_group"; +import "./reconciliation_components/actions"; diff --git a/india_compliance/public/js/india_compliance.bundle.js b/india_compliance/public/js/india_compliance.bundle.js index 580889b4d8..521667f6bf 100644 --- a/india_compliance/public/js/india_compliance.bundle.js +++ b/india_compliance/public/js/india_compliance.bundle.js @@ -9,3 +9,4 @@ import "./quick_info_popover"; import "./custom_number_card"; import "./taxes_controller"; import "./help_links"; +import "./reconciliation_components/tabs"; diff --git a/india_compliance/public/js/purchase_reconciliation_tool.bundle.js b/india_compliance/public/js/purchase_reconciliation_tool.bundle.js index e236f74221..0dcfc31b55 100644 --- a/india_compliance/public/js/purchase_reconciliation_tool.bundle.js +++ b/india_compliance/public/js/purchase_reconciliation_tool.bundle.js @@ -2,3 +2,4 @@ import "./components/data_table_manager"; import "./components/filter_group"; import "./components/number_card"; import "./components/set_gstin_options"; +import "./reconciliation_components/actions"; diff --git a/india_compliance/public/js/reconciliation_components/actions.js b/india_compliance/public/js/reconciliation_components/actions.js new file mode 100644 index 0000000000..c2b5511371 --- /dev/null +++ b/india_compliance/public/js/reconciliation_components/actions.js @@ -0,0 +1,155 @@ +frappe.provide("reconciliation"); + +Object.assign(reconciliation, { + 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; + }, + + async unlink_documents(frm, selected_rows) { + if (frm.get_active_tab()?.df.fieldname != "invoice_tab") return; + const _class = frm.reconciliation_tabs; + const { invoice_tab } = _class.tabs; + if (!selected_rows) selected_rows = invoice_tab.datatable.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", { + data: selected_rows, + }); + + const unlinked_docs = reconciliation.get_unlinked_docs(selected_rows); + + const new_data = _class.data.filter( + row => + !( + unlinked_docs.has(row.purchase_invoice_name) || + unlinked_docs.has(row.inward_supply_name) + ) + ); + + new_data.push(...r); + _class.refresh(new_data); + reconciliation.after_successful_action(invoice_tab); + }, + + async link_documents( + 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 _class = frm.reconciliation_tabs; + const new_data = _class.data.filter( + row => + !( + row.purchase_invoice_name == purchase_invoice_name || + row.inward_supply_name == inward_supply_name + ) + ); + + new_data.push(...r); + + _class.refresh(new_data); + if (alert) reconciliation.after_successful_action(_class.tabs.invoice_tab); + }, + + async create_new_purchase_invoice(row, company, company_gstin, source_doc) { + 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, + source_doc, + }; + }; + + frappe.new_doc("Purchase Invoice"); + }, + + after_successful_action(tab) { + if (tab) tab.datatable.clear_checked_items(); + frappe.show_alert({ + message: "Action applied successfully", + indicator: "green", + }); + }, +}); diff --git a/india_compliance/public/js/reconciliation_components/tabs.js b/india_compliance/public/js/reconciliation_components/tabs.js new file mode 100644 index 0000000000..c36a3a4da0 --- /dev/null +++ b/india_compliance/public/js/reconciliation_components/tabs.js @@ -0,0 +1,412 @@ +frappe.provide("reconciliation"); + +reconciliation.reconciliation_tabs = class ReconciliationTabs { + constructor(frm, tabs, data_field) { + this.frm = frm; + this.data = []; + this._tabs = tabs; + this.$wrapper = frm.get_field(data_field).$wrapper; + + this.render_tab_group(); + this.setup_filter_button(frm.doctype); + } + + render_data(data) { + this.data = data; + this.filtered_data = data; + + // clear filters + this.filter_group.filter_x_button.click(); + this.render_data_tables(); + } + + 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`].datatable?.refresh(this[`get_${tab}_data`]()); + }); + + this.rendered_data = this.filtered_data; + } + + render_tab_group() { + const fields = this.get_tab_group_fields(); + + this.tab_group = new frappe.ui.FieldGroup({ + fields, + 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]) + ); + } + + get_tab_group_fields() { + return []; + } + + setup_filter_button(doctype) { + this.filter_group = new india_compliance.FilterGroup({ + doctype, + parent: this.$wrapper.find(".form-tabs-list"), + filter_options: { + fieldname: "supplier_name", + filter_fields: this.get_filter_fields(), + }, + on_change: () => { + this.refresh(); + }, + }); + } + + get_filter_fields() { + return []; + } + + 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]] || "" + ) + ); + }); + } + + 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; + } + + render_data_tables() { + this._tabs.forEach(tab => { + this.tabs[`${tab}_tab`].datatable = 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(); + } + + get_supplier_name_gstin(row) { + return ` + ${row.supplier_name} +
+ + ${row.supplier_gstin || ""} + + `; + } +}; + +reconciliation.detail_view_dialog = 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() { + this._set_missing_doctype(); + if (!this.missing_doctype) 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._get_default_date_range(), + onchange: () => this.set_link_options(), + }, + { + fieldtype: "Column Break", + }, + { + label: "Document Type", + fieldtype: "Autocomplete", + fieldname: "doctype", + default: this.missing_doctype, + options: this.doctype_options, + read_only_depends_on: this.doctype_options.length === 1, + + 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(method) { + 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); + } + + _set_missing_doctype() {} + + _get_default_date_range() { + const now = frappe.datetime.now_date(); + return [frappe.datetime.add_months(now, -12), now]; + } + + setup_actions() { + const actions = this._get_custom_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"); + } + + _get_custom_actions() { + return []; + } + + _apply_custom_action(action) {} + + _get_button_css(action) { + return "btn-secondary"; + } + + 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("invoice_detail_comparison", { + 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"); + }); + } +};