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 `
`;
+ })
+ .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": "\n\t{{ __(\"Generate to view the data\") }}
"
+ },
+ {
+ "depends_on": "eval: doc.data_state === \"unavailable\"",
+ "fieldname": "no_invoice_data",
+ "fieldtype": "HTML",
+ "options": "\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 @@
+
+
+
+
+ |
+ 2A / 2B |
+ Purchase |
+
+
+
+
+ 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) }} |
+ {% else %}
+ - |
+ {% endif %}
+
+ {% if purchase.name %}
+ {{ frappe.utils.get_form_link(purchase.doctype,
+ purchase.name, true)}} |
+ {% else %}
+ - |
+ {% endif %}
+
+
+ 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 || '-' }} |
+
+ {% if purchase.cgst || inward_supply.cgst %}
+
+ CGST
+ | {{ inward_supply.cgst || '-' }} |
+ {{ purchase.cgst || '-' }} |
+
+ {% endif %}
+ {% if purchase.sgst || inward_supply.sgst %}
+
+ SGST
+ | {{ inward_supply.sgst || '-' }} |
+ {{ purchase.sgst || '-' }} |
+
+ {% endif %}
+ {% if purchase.igst || inward_supply.igst %}
+
+ IGST
+ | {{ inward_supply.igst || '-' }} |
+ {{ purchase.igst || '-' }} |
+
+ {% endif %}
+ {% if purchase.cess || inward_supply.cess %}
+
+ CESS
+ | {{ inward_supply.cess || '-' }} |
+ {{ purchase.cess || '-' }} |
+
+ {% endif %}
+
+ 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");
+ });
+ }
+};