diff --git a/india_compliance/gst_india/report/gst_itemised_sales_register/gst_itemised_sales_register.py b/india_compliance/gst_india/report/gst_itemised_sales_register/gst_itemised_sales_register.py
index 2b74dfe76..b05c3d53d 100644
--- a/india_compliance/gst_india/report/gst_itemised_sales_register/gst_itemised_sales_register.py
+++ b/india_compliance/gst_india/report/gst_itemised_sales_register/gst_itemised_sales_register.py
@@ -1,70 +1,30 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
+from frappe import _
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
_execute,
)
+from india_compliance.gst_india.report.gst_sales_register.gst_sales_register import (
+ get_additional_table_columns,
+ get_column_names,
+)
+
def execute(filters=None):
+ additional_table_columns = get_additional_table_columns()
+ additional_table_columns.append(
+ {
+ "fieldtype": "Data",
+ "label": _("HSN Code"),
+ "fieldname": "gst_hsn_code",
+ "width": 120,
+ }
+ )
+
return _execute(
filters,
- additional_table_columns=[
- dict(
- fieldtype="Data",
- label="Billing Address GSTIN",
- fieldname="billing_address_gstin",
- width=140,
- ),
- dict(
- fieldtype="Data",
- label="Company GSTIN",
- fieldname="company_gstin",
- width=120,
- ),
- dict(
- fieldtype="Data",
- label="Place of Supply",
- fieldname="place_of_supply",
- width=120,
- ),
- dict(
- fieldtype="Check",
- label="Is Reverse Charge",
- fieldname="is_reverse_charge",
- width=120,
- ),
- dict(
- fieldtype="Data",
- label="GST Category",
- fieldname="gst_category",
- width=120,
- ),
- dict(
- fieldtype="Check",
- label="Is Export With GST",
- fieldname="is_export_with_gst",
- width=120,
- ),
- dict(
- fieldtype="Data",
- label="E-Commerce GSTIN",
- fieldname="ecommerce_gstin",
- width=130,
- ),
- dict(
- fieldtype="Data", label="HSN Code", fieldname="gst_hsn_code", width=120
- ),
- ],
- additional_query_columns=[
- "billing_address_gstin",
- "company_gstin",
- "place_of_supply",
- "is_reverse_charge",
- "gst_category",
- "is_export_with_gst",
- "ecommerce_gstin",
- "gst_hsn_code",
- ],
+ additional_table_columns,
+ get_column_names(additional_table_columns),
)
diff --git a/india_compliance/gst_india/report/gst_sales_register/gst_sales_register.py b/india_compliance/gst_india/report/gst_sales_register/gst_sales_register.py
index ea24afb39..45589c67d 100644
--- a/india_compliance/gst_india/report/gst_sales_register/gst_sales_register.py
+++ b/india_compliance/gst_india/report/gst_sales_register/gst_sales_register.py
@@ -1,64 +1,84 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
+import frappe
+from frappe import _
+from erpnext.accounts.report.sales_register.sales_register import _execute
-from erpnext.accounts.report.sales_register.sales_register import _execute
+def get_additional_table_columns():
+ overseas_enabled, reverse_charge_enabled = frappe.get_cached_value(
+ "GST Settings",
+ "GST Settings",
+ ("enable_overseas_transactions", "enable_reverse_charge_in_sales"),
+ )
+
+ additional_table_columns = [
+ {
+ "fieldtype": "Data",
+ "label": _("Billing Address GSTIN"),
+ "fieldname": "billing_address_gstin",
+ "width": 140,
+ },
+ {
+ "fieldtype": "Data",
+ "label": _("Company GSTIN"),
+ "fieldname": "company_gstin",
+ "width": 120,
+ },
+ {
+ "fieldtype": "Data",
+ "label": _("Place of Supply"),
+ "fieldname": "place_of_supply",
+ "width": 120,
+ },
+ {
+ "fieldtype": "Data",
+ "label": _("GST Category"),
+ "fieldname": "gst_category",
+ "width": 120,
+ },
+ {
+ "fieldtype": "Data",
+ "label": _("E-Commerce GSTIN"),
+ "fieldname": "ecommerce_gstin",
+ "width": 130,
+ },
+ ]
+
+ if reverse_charge_enabled:
+ additional_table_columns.insert(
+ -2,
+ {
+ "fieldtype": "Check",
+ "label": _("Is Reverse Charge"),
+ "fieldname": "is_reverse_charge",
+ "width": 120,
+ },
+ )
+
+ if overseas_enabled:
+ additional_table_columns.insert(
+ -2,
+ {
+ "fieldtype": "Check",
+ "label": _("Is Export With GST"),
+ "fieldname": "is_export_with_gst",
+ "width": 120,
+ },
+ )
+
+ return additional_table_columns
+
+
+def get_column_names(additional_table_columns):
+ return [column["fieldname"] for column in additional_table_columns]
def execute(filters=None):
+ additional_table_columns = get_additional_table_columns()
+
return _execute(
filters,
- additional_table_columns=[
- dict(
- fieldtype="Data",
- label="Billing Address GSTIN",
- fieldname="billing_address_gstin",
- width=140,
- ),
- dict(
- fieldtype="Data",
- label="Company GSTIN",
- fieldname="company_gstin",
- width=120,
- ),
- dict(
- fieldtype="Data",
- label="Place of Supply",
- fieldname="place_of_supply",
- width=120,
- ),
- dict(
- fieldtype="Check",
- label="Is Reverse Charge",
- fieldname="is_reverse_charge",
- width=120,
- ),
- dict(
- fieldtype="Data",
- label="GST Category",
- fieldname="gst_category",
- width=120,
- ),
- dict(
- fieldtype="Check",
- label="Is Export With GST",
- fieldname="is_export_with_gst",
- width=120,
- ),
- dict(
- fieldtype="Data",
- label="E-Commerce GSTIN",
- fieldname="ecommerce_gstin",
- width=130,
- ),
- ],
- additional_query_columns=[
- "billing_address_gstin",
- "company_gstin",
- "place_of_supply",
- "is_reverse_charge",
- "gst_category",
- "is_export_with_gst",
- "ecommerce_gstin",
- ],
+ additional_table_columns,
+ get_column_names(additional_table_columns),
)
diff --git a/india_compliance/gst_india/report/gstr_1/gstr_1.py b/india_compliance/gst_india/report/gstr_1/gstr_1.py
index 4459ddc87..46199eb3c 100644
--- a/india_compliance/gst_india/report/gstr_1/gstr_1.py
+++ b/india_compliance/gst_india/report/gstr_1/gstr_1.py
@@ -1090,20 +1090,20 @@ def get_advances_json(data, gstin):
for item in items:
itms = {
"rt": item["rate"],
- "ad_amount": flt(item.get("taxable_value")),
- "csamt": flt(item.get("cess_amount")),
+ "ad_amount": flt(item.get("taxable_value"), 2),
+ "csamt": flt(item.get("cess_amount"), 2),
}
if supply_type == "INTRA":
itms.update(
{
- "samt": flt((itms["ad_amount"] * itms["rt"]) / 100),
- "camt": flt((itms["ad_amount"] * itms["rt"]) / 100),
+ "samt": flt((itms["ad_amount"] * itms["rt"]) / 100, 2),
+ "camt": flt((itms["ad_amount"] * itms["rt"]) / 100, 2),
"rt": itms["rt"] * 2,
}
)
else:
- itms.update({"iamt": flt((itms["ad_amount"] * itms["rt"]) / 100)})
+ itms["iamt"] = flt((itms["ad_amount"] * itms["rt"]) / 100, 2)
row["itms"].append(itms)
out.append(row)
@@ -1193,7 +1193,7 @@ def get_cdnr_reg_json(res, gstin):
inv_item = {
"nt_num": invoice[0]["invoice_number"],
"nt_dt": getdate(invoice[0]["posting_date"]).strftime("%d-%m-%Y"),
- "val": abs(flt(invoice[0]["invoice_value"])),
+ "val": abs(flt(invoice[0]["invoice_value"], 2)),
"ntty": invoice[0]["document_type"],
"pos": "%02d" % int(invoice[0]["place_of_supply"].split("-")[0]),
"rchrg": invoice[0]["is_reverse_charge"],
@@ -1221,7 +1221,7 @@ def get_cdnr_unreg_json(res, gstin):
inv_item = {
"nt_num": items[0]["invoice_number"],
"nt_dt": getdate(items[0]["posting_date"]).strftime("%d-%m-%Y"),
- "val": abs(flt(items[0]["invoice_value"])),
+ "val": abs(flt(items[0]["invoice_value"], 2)),
"ntty": items[0]["document_type"],
"pos": "%02d" % int(items[0]["place_of_supply"].split("-")[0]),
"typ": get_invoice_type(items[0]),
diff --git a/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py b/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
index 63a7186e6..20028bac9 100644
--- a/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
+++ b/india_compliance/gst_india/report/hsn_wise_summary_of_outward_supplies/hsn_wise_summary_of_outward_supplies.py
@@ -16,12 +16,9 @@
def execute(filters=None):
- return _execute(filters)
-
-
-def _execute(filters=None):
if not filters:
filters = {}
+
columns = get_columns()
output_gst_accounts = [
@@ -40,25 +37,39 @@ def _execute(filters=None):
data = []
added_item = []
for d in item_list:
- if (d.parent, d.gst_hsn_code, d.item_code) not in added_item:
- row = [d.gst_hsn_code, d.description, d.stock_uom, d.stock_qty]
- total_tax = 0
- tax_rate = 0
- for tax in tax_columns:
- item_tax = itemised_tax.get((d.parent, d.item_code), {}).get(tax, {})
- tax_rate += flt(item_tax.get("tax_rate", 0))
- total_tax += flt(item_tax.get("tax_amount", 0))
-
- row += [tax_rate, d.taxable_value + total_tax, d.taxable_value]
-
- for tax in tax_columns:
- item_tax = itemised_tax.get((d.parent, d.item_code), {}).get(tax, {})
- row += [item_tax.get("tax_amount", 0)]
-
- data.append(row)
- added_item.append((d.parent, d.gst_hsn_code, d.item_code))
+ if (d.parent, d.gst_hsn_code, d.item_code) in added_item:
+ continue
+
+ if d.gst_hsn_code.startswith("99"):
+ # service item doesnt have qty / uom
+ d.stock_qty = 0
+ d.uqc = "NA"
+
+ else:
+ d.uqc = d.get("uqc", "").upper()
+ if d.uqc not in UOMS:
+ d.uqc = "OTH"
+
+ row = [d.gst_hsn_code, d.description, d.uqc, d.stock_qty]
+ total_tax = 0
+ tax_rate = 0
+ for tax in tax_columns:
+ item_tax = itemised_tax.get((d.parent, d.item_code), {}).get(tax, {})
+ tax_rate += flt(item_tax.get("tax_rate", 0))
+ total_tax += flt(item_tax.get("tax_amount", 0))
+
+ row += [tax_rate, d.taxable_value + total_tax, d.taxable_value]
+
+ for tax in tax_columns:
+ item_tax = itemised_tax.get((d.parent, d.item_code), {}).get(tax, {})
+ row += [item_tax.get("tax_amount", 0)]
+
+ data.append(row)
+ added_item.append((d.parent, d.gst_hsn_code, d.item_code))
+
if data:
data = get_merged_data(columns, data) # merge same hsn code data
+
return columns, data
@@ -78,8 +89,8 @@ def get_columns():
"width": 300,
},
{
- "fieldname": "stock_uom",
- "label": _("Stock UOM"),
+ "fieldname": "uqc",
+ "label": _("UQC"),
"fieldtype": "Data",
"width": 100,
},
@@ -138,7 +149,7 @@ def get_items(filters):
f"""
SELECT
`tabSales Invoice Item`.gst_hsn_code,
- `tabSales Invoice Item`.stock_uom,
+ `tabSales Invoice Item`.stock_uom as uqc,
sum(`tabSales Invoice Item`.stock_qty) AS stock_qty,
sum(`tabSales Invoice Item`.taxable_value) AS taxable_value,
sum(`tabSales Invoice Item`.base_price_list_rate) AS base_price_list_rate,
@@ -308,31 +319,28 @@ def download_json_file():
def get_hsn_wise_json_data(filters, report_data):
-
filters = frappe._dict(filters)
gst_accounts = get_gst_accounts_by_type(filters.company, "Output")
data = []
count = 1
for hsn in report_data:
- uom = hsn.get("stock_uom", "").upper()
- if uom not in UOMS:
- uom = "OTH"
-
row = {
"num": count,
"hsn_sc": hsn.get("gst_hsn_code"),
- "desc": hsn.get("description")[:30],
- "uqc": uom,
+ "uqc": hsn.get("uqc"),
"qty": hsn.get("stock_qty"),
"rt": flt(hsn.get("tax_rate"), 2),
- "txval": flt(hsn.get("taxable_amount", 2)),
+ "txval": flt(hsn.get("taxable_amount"), 2),
"iamt": 0.0,
"camt": 0.0,
"samt": 0.0,
"csamt": 0.0,
}
+ if hsn_description := hsn.get("description"):
+ row["desc"] = hsn_description[:30]
+
row["iamt"] += flt(
hsn.get(frappe.scrub(cstr(gst_accounts.get("igst_account"))), 0.0), 2
)
diff --git a/india_compliance/gst_india/setup/__init__.py b/india_compliance/gst_india/setup/__init__.py
index 28ec72239..4d339d626 100644
--- a/india_compliance/gst_india/setup/__init__.py
+++ b/india_compliance/gst_india/setup/__init__.py
@@ -13,7 +13,10 @@
SALES_REVERSE_CHARGE_FIELDS,
)
from india_compliance.gst_india.setup.property_setters import get_property_setters
-from india_compliance.gst_india.utils import get_data_file_path, toggle_custom_fields
+from india_compliance.gst_india.utils import get_data_file_path
+from india_compliance.gst_india.utils.custom_fields import toggle_custom_fields
+
+ITEM_VARIANT_FIELDNAMES = frozenset(("gst_hsn_code", "is_nil_exempt", "is_non_gst"))
def after_install():
@@ -23,21 +26,14 @@ def after_install():
set_default_gst_settings()
set_default_accounts_settings()
create_hsn_codes()
+ add_fields_to_item_variant_settings()
def create_custom_fields():
# Validation ignored for faster creation
# Will not fail if a core field with same name already exists (!)
# Will update a custom field if it already exists
- _create_custom_fields(
- _get_custom_fields_to_create(
- CUSTOM_FIELDS,
- SALES_REVERSE_CHARGE_FIELDS,
- E_INVOICE_FIELDS,
- E_WAYBILL_FIELDS,
- ),
- ignore_validate=True,
- )
+ _create_custom_fields(get_all_custom_fields(), ignore_validate=True)
def create_property_setters():
@@ -99,6 +95,18 @@ def create_hsn_codes():
)
+def add_fields_to_item_variant_settings():
+ settings = frappe.get_doc("Item Variant Settings")
+ fields_to_add = ITEM_VARIANT_FIELDNAMES - {
+ row.field_name for row in settings.fields
+ }
+
+ for fieldname in fields_to_add:
+ settings.append("fields", {"field_name": fieldname})
+
+ settings.save()
+
+
def set_default_gst_settings():
settings = frappe.get_doc("GST Settings")
default_settings = {
@@ -181,16 +189,23 @@ def show_accounts_settings_override_warning():
)
click.secho(
- "This is being set as Billing Address, since that's the correct "
- "address for determining GST applicablility.",
+ (
+ "This is being set as Billing Address, since that's the correct "
+ "address for determining GST applicablility."
+ ),
fg="yellow",
)
-def _get_custom_fields_to_create(*custom_fields_list):
+def get_all_custom_fields():
result = {}
- for custom_fields in custom_fields_list:
+ for custom_fields in (
+ CUSTOM_FIELDS,
+ SALES_REVERSE_CHARGE_FIELDS,
+ E_INVOICE_FIELDS,
+ E_WAYBILL_FIELDS,
+ ):
for doctypes, fields in custom_fields.items():
if isinstance(fields, dict):
fields = [fields]
diff --git a/india_compliance/gst_india/uninstall.py b/india_compliance/gst_india/uninstall.py
new file mode 100644
index 000000000..f24ab940e
--- /dev/null
+++ b/india_compliance/gst_india/uninstall.py
@@ -0,0 +1,36 @@
+import frappe
+
+from india_compliance.gst_india.setup import (
+ ITEM_VARIANT_FIELDNAMES,
+ get_all_custom_fields,
+ get_property_setters,
+)
+from india_compliance.gst_india.utils.custom_fields import delete_custom_fields
+
+
+def before_uninstall():
+ delete_custom_fields(get_all_custom_fields())
+ delete_property_setters()
+ remove_fields_from_item_variant_settings()
+
+
+def delete_property_setters():
+ field_map = {
+ "doctype": "doc_type",
+ "fieldname": "field_name",
+ }
+
+ for property_setter in get_property_setters():
+ for key, fieldname in field_map.items():
+ if key in property_setter:
+ property_setter[fieldname] = property_setter.pop(key)
+
+ frappe.db.delete("Property Setter", property_setter)
+
+
+def remove_fields_from_item_variant_settings():
+ settings = frappe.get_doc("Item Variant Settings")
+ settings.fields = [
+ row for row in settings.fields if row.field_name not in ITEM_VARIANT_FIELDNAMES
+ ]
+ settings.save()
diff --git a/india_compliance/gst_india/utils/__init__.py b/india_compliance/gst_india/utils/__init__.py
index 099da6a09..14bb0e1d5 100644
--- a/india_compliance/gst_india/utils/__init__.py
+++ b/india_compliance/gst_india/utils/__init__.py
@@ -26,7 +26,7 @@
def get_state(state_number):
"""Get state from State Number"""
- state_number = str(state_number)
+ state_number = str(state_number).zfill(2)
for state, code in STATE_NUMBERS.items():
if code == state_number:
@@ -318,37 +318,6 @@ def get_all_gst_accounts(company):
return accounts_list
-def toggle_custom_fields(custom_fields, show):
- """
- Show / hide custom fields
-
- :param custom_fields: a dict like `{'Sales Invoice': [{fieldname: 'test', ...}]}`
- :param show: True to show fields, False to hide
- """
-
- for doctypes, fields in custom_fields.items():
- if isinstance(fields, dict):
- # only one field
- fields = [fields]
-
- if isinstance(doctypes, str):
- # only one doctype
- doctypes = (doctypes,)
-
- for doctype in doctypes:
- frappe.db.set_value(
- "Custom Field",
- {
- "dt": doctype,
- "fieldname": ["in", [field["fieldname"] for field in fields]],
- },
- "hidden",
- int(not show),
- )
-
- frappe.clear_cache(doctype=doctype)
-
-
def parse_datetime(value, day_first=False):
"""Convert IST string to offset-naive system time"""
@@ -407,22 +376,6 @@ def get_titlecase_version(word, all_caps=False, **kwargs):
return word
-def delete_old_fields(fields, doctypes):
- if isinstance(fields, str):
- fields = (fields,)
-
- if isinstance(doctypes, str):
- doctypes = (doctypes,)
-
- frappe.db.delete(
- "Custom Field",
- {
- "fieldname": ("in", fields),
- "dt": ("in", doctypes),
- },
- )
-
-
def is_api_enabled(settings=None):
if not settings:
settings = frappe.get_cached_value(
diff --git a/india_compliance/gst_india/utils/api.py b/india_compliance/gst_india/utils/api.py
index 2efb0d385..8ea61b165 100644
--- a/india_compliance/gst_india/utils/api.py
+++ b/india_compliance/gst_india/utils/api.py
@@ -3,7 +3,8 @@
def enqueue_integration_request(**kwargs):
frappe.enqueue(
- "india_compliance.gst_india.utils.api.create_integration_request", **kwargs
+ "india_compliance.gst_india.utils.api.create_integration_request",
+ **kwargs,
)
diff --git a/india_compliance/gst_india/utils/custom_fields.py b/india_compliance/gst_india/utils/custom_fields.py
new file mode 100644
index 000000000..26adba3e7
--- /dev/null
+++ b/india_compliance/gst_india/utils/custom_fields.py
@@ -0,0 +1,74 @@
+import frappe
+
+
+def toggle_custom_fields(custom_fields, show):
+ """
+ Show / hide custom fields
+
+ :param custom_fields: a dict like `{'Sales Invoice': [{fieldname: 'test', ...}]}`
+ :param show: True to show fields, False to hide
+ """
+
+ for doctypes, fields in custom_fields.items():
+ if isinstance(fields, dict):
+ # only one field
+ fields = [fields]
+
+ if isinstance(doctypes, str):
+ # only one doctype
+ doctypes = (doctypes,)
+
+ for doctype in doctypes:
+ frappe.db.set_value(
+ "Custom Field",
+ {
+ "dt": doctype,
+ "fieldname": ["in", [field["fieldname"] for field in fields]],
+ },
+ "hidden",
+ int(not show),
+ )
+
+ frappe.clear_cache(doctype=doctype)
+
+
+def delete_old_fields(fieldnames, doctypes):
+ if isinstance(fieldnames, str):
+ fields = (fieldnames,)
+
+ if isinstance(doctypes, str):
+ doctypes = (doctypes,)
+
+ frappe.db.delete(
+ "Custom Field",
+ {
+ "fieldname": ("in", fields),
+ "dt": ("in", doctypes),
+ },
+ )
+
+
+def delete_custom_fields(custom_fields):
+ """
+ :param custom_fields: a dict like `{'Sales Invoice': [{fieldname: 'test', ...}]}`
+ """
+
+ for doctypes, fields in custom_fields.items():
+ if isinstance(fields, dict):
+ # only one field
+ fields = [fields]
+
+ if isinstance(doctypes, str):
+ # only one doctype
+ doctypes = (doctypes,)
+
+ for doctype in doctypes:
+ frappe.db.delete(
+ "Custom Field",
+ {
+ "fieldname": ("in", [field["fieldname"] for field in fields]),
+ "dt": doctype,
+ },
+ )
+
+ frappe.clear_cache(doctype=doctype)
diff --git a/india_compliance/gst_india/utils/e_invoice.py b/india_compliance/gst_india/utils/e_invoice.py
index 7ad4e5b6d..c70281ff5 100644
--- a/india_compliance/gst_india/utils/e_invoice.py
+++ b/india_compliance/gst_india/utils/e_invoice.py
@@ -1,3 +1,5 @@
+import json
+
import jwt
import frappe
@@ -22,6 +24,7 @@
ITEM_LIMIT,
)
from india_compliance.gst_india.utils import (
+ is_api_enabled,
load_doc,
parse_datetime,
send_updated_doc,
@@ -37,6 +40,53 @@
)
+@frappe.whitelist()
+def enqueue_bulk_e_invoice_generation(docnames):
+ """
+ Enqueue bulk generation of e-Invoices for the given Sales Invoices.
+ """
+
+ frappe.has_permission("Sales Invoice", "submit", throw=True)
+
+ gst_settings = frappe.get_cached_doc("GST Settings")
+ if not is_api_enabled(gst_settings) or not gst_settings.enable_e_invoice:
+ frappe.throw(_("Please enable e-Invoicing in GST Settings first"))
+
+ docnames = frappe.parse_json(docnames) if docnames.startswith("[") else [docnames]
+ rq_job = frappe.enqueue(
+ "india_compliance.gst_india.utils.e_invoice.generate_e_invoices",
+ queue="long",
+ timeout=len(docnames) * 240, # 4 mins per e-Invoice
+ docnames=docnames,
+ )
+
+ return rq_job.id
+
+
+def generate_e_invoices(docnames):
+ """
+ Bulk generate e-Invoices for the given Sales Invoices.
+ Permission checks are done in the `generate_e_invoice` function.
+ """
+
+ for docname in docnames:
+ try:
+ generate_e_invoice(docname)
+
+ except Exception:
+ frappe.log_error(
+ title=_("e-Invoice generation failed for Sales Invoice {0}").format(
+ docname
+ ),
+ message=frappe.get_traceback(),
+ )
+
+ finally:
+ # each e-Invoice needs to be committed individually
+ # nosemgrep
+ frappe.db.commit()
+
+
@frappe.whitelist()
def generate_e_invoice(docname, throw=True):
doc = load_doc("Sales Invoice", docname, "submit")
@@ -47,7 +97,11 @@ def generate_e_invoice(docname, throw=True):
# Handle Duplicate IRN
if result.InfCd == "DUPIRN":
- result = api.get_e_invoice_by_irn(result.Desc.get("Irn"))
+ response = api.get_e_invoice_by_irn(result.Desc.Irn)
+
+ # Handle error 2283:
+ # IRN details cannot be provided as it is generated more than 2 days ago
+ result = result.Desc if response.error_code == "2283" else response
except frappe.ValidationError as e:
if throw:
@@ -71,9 +125,14 @@ def generate_e_invoice(docname, throw=True):
}
)
- decoded_invoice = frappe.parse_json(
- jwt.decode(result.SignedInvoice, options={"verify_signature": False})["data"]
- )
+ invoice_data = None
+ if result.SignedInvoice:
+ decoded_invoice = json.loads(
+ jwt.decode(result.SignedInvoice, options={"verify_signature": False})[
+ "data"
+ ]
+ )
+ invoice_data = frappe.as_json(decoded_invoice, indent=4)
log_e_invoice(
doc,
@@ -84,7 +143,7 @@ def generate_e_invoice(docname, throw=True):
"acknowledged_on": parse_datetime(result.AckDt),
"signed_invoice": result.SignedInvoice,
"signed_qr_code": result.SignedQRCode,
- "invoice_data": frappe.as_json(decoded_invoice, indent=4),
+ "invoice_data": invoice_data,
},
)
@@ -142,7 +201,12 @@ def cancel_e_invoice(docname, values):
def log_e_invoice(doc, log_data):
- frappe.enqueue(_log_e_invoice, queue="short", at_front=True, log_data=log_data)
+ frappe.enqueue(
+ _log_e_invoice,
+ queue="short",
+ at_front=True,
+ log_data=log_data,
+ )
update_onload(doc, "e_invoice_info", log_data)
@@ -310,7 +374,9 @@ def update_payment_details(self):
credit_days = 0
paid_amount = 0
- if self.doc.due_date:
+ if self.doc.due_date and getdate(self.doc.due_date) > getdate(
+ self.doc.posting_date
+ ):
credit_days = (
getdate(self.doc.due_date) - getdate(self.doc.posting_date)
).days
@@ -398,6 +464,9 @@ def get_invoice_data(self):
self.dispatch_address.update(seller)
self.transaction_details.name = random_string(6).lstrip("0")
+ if frappe.flags.in_test:
+ self.transaction_details.name = "test_invoice_no"
+
# For overseas transactions, dummy GSTIN is not needed
if self.doc.gst_category != "Overseas":
buyer = {
diff --git a/india_compliance/gst_india/utils/e_waybill.py b/india_compliance/gst_india/utils/e_waybill.py
index 5e41478fe..c48b22423 100644
--- a/india_compliance/gst_india/utils/e_waybill.py
+++ b/india_compliance/gst_india/utils/e_waybill.py
@@ -102,7 +102,6 @@ def _generate_e_waybill(doc, throw=True):
indicator="green",
alert=True,
)
-
return send_updated_doc(doc)
@@ -478,6 +477,8 @@ def __init__(self, *args, **kwargs):
def get_data(self, *, with_irn=False):
self.validate_transaction()
self.set_transporter_details()
+ self.set_party_address_details()
+ self.update_distance_if_zero()
if with_irn:
return self.sanitize_data(
@@ -496,7 +497,6 @@ def get_data(self, *, with_irn=False):
self.set_transaction_details()
self.set_item_list()
- self.set_party_address_details()
return self.get_transaction_data()
@@ -529,7 +529,7 @@ def get_update_vehicle_data(self, values):
"fromPlace": dispatch_address.city,
"fromState": dispatch_address.state_number,
"reasonCode": UPDATE_VEHICLE_REASON_CODES[values.reason],
- "reasonRem": self.sanitize_value(values.remark, 3),
+ "reasonRem": self.sanitize_value(values.remark, regex=3),
"transDocNo": self.transaction_details.lr_no,
"transDocDate": self.transaction_details.lr_date,
"transMode": self.transaction_details.mode_of_transport,
@@ -551,7 +551,11 @@ def validate_transaction(self):
super().validate_transaction()
if self.doc.ewaybill:
- frappe.throw(_("e-Waybill already generated for this document"))
+ frappe.throw(
+ _("e-Waybill already generated for {0} {1}").format(
+ _(self.doc.doctype), frappe.bold(self.doc.name)
+ )
+ )
self.validate_applicability()
@@ -769,6 +773,19 @@ def get_address_details(self, *args, **kwargs):
return address_details
+ def update_distance_if_zero(self):
+ """
+ e-Waybill portal doesn't return distance where from and to pincode is same.
+ Hardcode distance to 1 km to simplify and automate this.
+ Accuracy of distance is immaterial and used only for e-Waybill validity determination.
+ """
+
+ if (
+ self.transaction_details.distance == 0
+ and self.dispatch_address.pincode == self.shipping_address.pincode
+ ):
+ self.transaction_details.distance = 1
+
def get_transaction_data(self):
if self.sandbox_mode:
self.transaction_details.update(
diff --git a/india_compliance/gst_india/utils/jinja.py b/india_compliance/gst_india/utils/jinja.py
index d05126a4b..c96089363 100644
--- a/india_compliance/gst_india/utils/jinja.py
+++ b/india_compliance/gst_india/utils/jinja.py
@@ -12,8 +12,35 @@
TRANSPORT_MODES,
TRANSPORT_TYPES,
)
+from india_compliance.gst_india.overrides.transaction import is_inter_state_supply
from india_compliance.gst_india.utils import as_ist
+E_INVOICE_ITEM_FIELDS = {
+ "SlNo": "Sr.",
+ "PrdDesc": "Product Description",
+ "HsnCd": "HSN Code",
+ "Qty": "Qty",
+ "Unit": "UOM",
+ "UnitPrice": "Rate",
+ "Discount": "Discount",
+ "AssAmt": "Taxable Amount",
+ "GstRt": "Tax Rate",
+ "CesRt": "Cess Rate",
+ "TotItemVal": "Total",
+}
+
+E_INVOICE_AMOUNT_FIELDS = {
+ "AssVal": "Taxable Value",
+ "CgstVal": "CGST",
+ "SgstVal": "SGST",
+ "IgstVal": "IGST",
+ "CesVal": "CESS",
+ "Discount": "Discount",
+ "OthChrg": "Other Charges",
+ "RndOffAmt": "Round Off",
+ "TotInvVal": "Total Value",
+}
+
def add_spacing(string, interval):
"""
@@ -82,16 +109,40 @@ def get_ewaybill_barcode(ewaybill):
def get_non_zero_fields(data, fields):
- """Returns a list of fields with non-zero values in order of fields specified"""
+ """Returns a list of fields with non-zero values"""
if isinstance(data, dict):
data = [data]
- non_zero_fields = []
+ non_zero_fields = set()
+
for row in data:
for field in fields:
- if row.get(field, 0) != 0 and field not in non_zero_fields:
- non_zero_fields.append(field)
- continue
+ if field not in non_zero_fields and row.get(field, 0) != 0:
+ non_zero_fields.add(field)
return non_zero_fields
+
+
+def get_fields_to_display(data, field_map, mandatory_fields=None):
+ fields_to_display = get_non_zero_fields(data, field_map)
+ if mandatory_fields:
+ fields_to_display.update(mandatory_fields)
+
+ return {
+ field: label for field, label in field_map.items() if field in fields_to_display
+ }
+
+
+def get_e_invoice_item_fields(data):
+ return get_fields_to_display(data, E_INVOICE_ITEM_FIELDS, {"GstRt"})
+
+
+def get_e_invoice_amount_fields(data, doc):
+ mandatory_fields = set()
+ if is_inter_state_supply(doc):
+ mandatory_fields.add("IgstVal")
+ else:
+ mandatory_fields.update(("CgstVal", "SgstVal"))
+
+ return get_fields_to_display(data, E_INVOICE_AMOUNT_FIELDS, mandatory_fields)
diff --git a/india_compliance/gst_india/utils/test_e_invoice.py b/india_compliance/gst_india/utils/test_e_invoice.py
new file mode 100644
index 000000000..d9e662bf3
--- /dev/null
+++ b/india_compliance/gst_india/utils/test_e_invoice.py
@@ -0,0 +1,456 @@
+import json
+import re
+
+import responses
+from responses import matchers
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import add_to_date, get_datetime, getdate, now_datetime
+from frappe.utils.data import format_date
+
+from india_compliance.gst_india.api_classes.base import BASE_URL
+from india_compliance.gst_india.utils import load_doc
+from india_compliance.gst_india.utils.e_invoice import (
+ EInvoiceData,
+ cancel_e_invoice,
+ generate_e_invoice,
+ validate_e_invoice_applicability,
+ validate_if_e_invoice_can_be_cancelled,
+)
+from india_compliance.gst_india.utils.e_waybill import EWaybillData
+from india_compliance.gst_india.utils.tests import create_sales_invoice
+
+
+class TestEInvoice(FrappeTestCase):
+ @classmethod
+ def setUpClass(cls):
+ frappe.db.set_value(
+ "GST Settings",
+ "GST Settings",
+ {
+ "enable_api": 1,
+ "enable_e_invoice": 1,
+ "auto_generate_e_invoice": 0,
+ "enable_e_waybill": 1,
+ "fetch_e_waybill_data": 0,
+ },
+ )
+ cls.e_invoice_test_data = frappe._dict(
+ frappe.get_file_json(
+ frappe.get_app_path(
+ "india_compliance", "gst_india", "data", "test_e_invoice.json"
+ )
+ )
+ )
+ update_dates_for_test_data(cls.e_invoice_test_data)
+
+ @change_settings("Selling Settings", {"allow_multiple_items": 1})
+ def test_get_data(self):
+ """Validation test for more than 1000 items in sales invoice"""
+ si = create_sales_invoice(do_not_submit=True)
+ item_row = si.get("items")[0]
+
+ for index in range(0, 1000):
+ si.append(
+ "items",
+ {
+ "item_code": item_row.item_code,
+ "qty": item_row.qty,
+ "rate": item_row.rate,
+ },
+ )
+ si.save()
+
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"^(e-Invoice can only be generated.*)$"),
+ EInvoiceData(si).get_data,
+ )
+
+ @responses.activate
+ def test_generate_e_invoice_with_goods_item(self):
+ """Generate test e-Invoice for goods item"""
+ test_data = self.e_invoice_test_data.get("goods_item_with_ewaybill")
+ si = create_sales_invoice(**test_data.get("kwargs"))
+
+ # Assert if request data given in Json
+ self.assertDictEqual(test_data.get("request_data"), EInvoiceData(si).get_data())
+
+ # Mock response for generating irn
+ self._mock_e_invoice_response(data=test_data)
+
+ generate_e_invoice(si.name)
+
+ # Assert if Integration Request Log generated
+ self.assertDocumentEqual(
+ {
+ "output": frappe.as_json(test_data.get("response_data"), indent=4),
+ },
+ frappe.get_doc(
+ "Integration Request",
+ {"reference_doctype": "Sales Invoice", "reference_docname": si.name},
+ ),
+ )
+
+ # Assert if Sales Doc updated
+ self.assertDocumentEqual(
+ {
+ "irn": test_data.get("response_data").get("result").get("Irn"),
+ "ewaybill": test_data.get("response_data").get("result").get("EwbNo"),
+ "einvoice_status": "Generated",
+ },
+ frappe.get_doc("Sales Invoice", si.name),
+ )
+
+ self.assertDocumentEqual(
+ {"name": test_data.get("response_data").get("result").get("Irn")},
+ frappe.get_doc("e-Invoice Log", {"sales_invoice": si.name}),
+ )
+ self.assertDocumentEqual(
+ {"name": test_data.get("response_data").get("result").get("EwbNo")},
+ frappe.get_doc("e-Waybill Log", {"reference_name": si.name}),
+ )
+
+ @responses.activate
+ def test_generate_e_invoice_with_service_item(self):
+ """Generate test e-Invoice for Service Item"""
+ test_data = self.e_invoice_test_data.get("service_item")
+ si = create_sales_invoice(**test_data.get("kwargs"))
+
+ # Assert if request data given in Json
+ self.assertDictEqual(test_data.get("request_data"), EInvoiceData(si).get_data())
+
+ # Mock response for generating irn
+ self._mock_e_invoice_response(data=test_data)
+
+ generate_e_invoice(si.name)
+
+ # Assert if Integration Request Log generated
+ self.assertDocumentEqual(
+ {
+ "output": frappe.as_json(test_data.get("response_data"), indent=4),
+ },
+ frappe.get_doc(
+ "Integration Request",
+ {"reference_doctype": "Sales Invoice", "reference_docname": si.name},
+ ),
+ )
+
+ # Assert if Sales Doc updated
+ self.assertDocumentEqual(
+ {
+ "irn": test_data.get("response_data").get("result").get("Irn"),
+ "einvoice_status": "Generated",
+ },
+ frappe.get_doc("Sales Invoice", si.name),
+ )
+
+ self.assertDocumentEqual(
+ {"name": test_data.get("response_data").get("result").get("Irn")},
+ frappe.get_doc("e-Invoice Log", {"sales_invoice": si.name}),
+ )
+
+ self.assertFalse(
+ frappe.db.get_value("e-Waybill Log", {"reference_name": si.name}, "name")
+ )
+
+ @responses.activate
+ def test_return_e_invoice_with_goods_item(self):
+ """Generate test e-Invoice for returned Sales Invoices"""
+ test_data = self.e_invoice_test_data.get("return_invoice")
+
+ si = create_sales_invoice(
+ customer_address="_Test Registered Customer-Billing",
+ shipping_address_name="_Test Registered Customer-Billing",
+ )
+
+ test_data.get("kwargs").update({"return_against": si.name})
+
+ for data in test_data.get("request_data").get("RefDtls").get("PrecDocDtls"):
+ data.update(
+ {
+ "InvDt": format_date(si.posting_date, "dd/mm/yyyy"),
+ "InvNo": si.name,
+ }
+ )
+
+ return_si = create_sales_invoice(
+ **test_data.get("kwargs"),
+ )
+
+ # Assert if request data given in Json
+ self.assertDictEqual(
+ test_data.get("request_data"),
+ EInvoiceData(frappe.get_doc("Sales Invoice", return_si.name)).get_data(),
+ )
+
+ # Mock response for generating irn
+ self._mock_e_invoice_response(data=test_data)
+
+ generate_e_invoice(return_si.name)
+
+ # Assert if Integration Request Log generated
+ self.assertDocumentEqual(
+ {
+ "output": frappe.as_json(test_data.get("response_data"), indent=4),
+ },
+ frappe.get_doc(
+ "Integration Request",
+ {
+ "reference_doctype": "Sales Invoice",
+ "reference_docname": return_si.name,
+ },
+ ),
+ )
+
+ # Assert if Sales Doc updated
+ self.assertDocumentEqual(
+ {
+ "irn": test_data.get("response_data").get("result").get("Irn"),
+ "einvoice_status": "Generated",
+ },
+ frappe.get_doc("Sales Invoice", return_si.name),
+ )
+
+ self.assertDocumentEqual(
+ {"name": test_data.get("response_data").get("result").get("Irn")},
+ frappe.get_doc("e-Invoice Log", {"sales_invoice": return_si.name}),
+ )
+
+ self.assertFalse(
+ frappe.db.get_value(
+ "e-Waybill Log", {"reference_name": return_si.name}, "name"
+ )
+ )
+
+ @responses.activate
+ def test_debit_note_e_invoice_with_goods_item(self):
+ """Generate test e-Invoice for debit note with zero quantity"""
+ test_data = self.e_invoice_test_data.get("debit_invoice")
+ si = create_sales_invoice(
+ customer_address="_Test Registered Customer-Billing",
+ shipping_address_name="_Test Registered Customer-Billing",
+ )
+
+ test_data.get("kwargs").update({"return_against": si.name})
+ debit_note = create_sales_invoice(**test_data.get("kwargs"), do_not_submit=True)
+
+ debit_note.items[0].qty = 0
+ debit_note.save()
+ debit_note.submit()
+
+ # Assert if request data given in Json
+ self.assertDictEqual(
+ test_data.get("request_data"), EInvoiceData(debit_note).get_data()
+ )
+
+ # Mock response for generating irn
+ self._mock_e_invoice_response(data=test_data)
+
+ generate_e_invoice(debit_note.name)
+
+ # Assert if Integration Request Log generated
+ self.assertDocumentEqual(
+ {
+ "output": frappe.as_json(test_data.get("response_data"), indent=4),
+ },
+ frappe.get_doc(
+ "Integration Request",
+ {
+ "reference_doctype": "Sales Invoice",
+ "reference_docname": debit_note.name,
+ },
+ ),
+ )
+
+ # Assert if Sales Doc updated
+ self.assertDocumentEqual(
+ {
+ "irn": test_data.get("response_data").get("result").get("Irn"),
+ "einvoice_status": "Generated",
+ },
+ frappe.get_doc("Sales Invoice", debit_note.name),
+ )
+
+ self.assertDocumentEqual(
+ {"name": test_data.get("response_data").get("result").get("Irn")},
+ frappe.get_doc("e-Invoice Log", {"sales_invoice": debit_note.name}),
+ )
+
+ self.assertFalse(
+ frappe.db.get_value(
+ "e-Waybill Log", {"reference_name": debit_note.name}, "name"
+ )
+ )
+
+ @responses.activate
+ def test_cancel_e_invoice(self):
+ """Test for generate and cancel e-Invoice
+ - Test function `validate_if_e_invoice_can_be_cancelled`
+ """
+
+ test_data = self.e_invoice_test_data.get("goods_item_with_ewaybill")
+ si = create_sales_invoice(**test_data.get("kwargs"))
+
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"^(IRN not found)$"),
+ validate_if_e_invoice_can_be_cancelled,
+ si,
+ )
+
+ test_data.get("response_data").get("result").update(
+ {"AckDt": str(now_datetime())}
+ )
+
+ # Assert if request data given in Json
+ self.assertDictEqual(test_data.get("request_data"), EInvoiceData(si).get_data())
+
+ # Mock response for generating irn
+ self._mock_e_invoice_response(data=test_data)
+
+ generate_e_invoice(si.name)
+
+ si_doc = load_doc("Sales Invoice", si.name, "cancel")
+ si_doc.get_onload().get("e_invoice_info", {}).update({"acknowledged_on": None})
+
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"^(e-Invoice can only be cancelled.*)$"),
+ validate_if_e_invoice_can_be_cancelled,
+ si_doc,
+ )
+
+ cancelled_doc = self._cancel_e_invoice(si.name)
+
+ self.assertDocumentEqual(
+ {"einvoice_status": "Cancelled", "irn": ""},
+ cancelled_doc,
+ )
+ self.assertDocumentEqual({"ewaybill": ""}, cancelled_doc)
+
+ def test_validate_e_invoice_applicability(self):
+ """Test if e_invoicing is applicable"""
+
+ si = create_sales_invoice(
+ customer="_Test Unregistered Customer",
+ gst_category="Unregistered",
+ do_not_submit=True,
+ )
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"^(e-Invoice is not applicable for invoices.*)$"),
+ validate_e_invoice_applicability,
+ si,
+ )
+
+ si.update(
+ {
+ "gst_category": "Registered Regular",
+ "customer": "_Test Registered Customer",
+ }
+ )
+ si.save(ignore_permissions=True)
+ frappe.db.set_single_value(
+ "GST Settings", "e_invoice_applicable_from", "2045-05-18"
+ )
+
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"^(e-Invoice is not applicable for invoices before.*)$"),
+ validate_e_invoice_applicability,
+ si,
+ )
+
+ frappe.db.set_single_value(
+ "GST Settings", "e_invoice_applicable_from", get_datetime()
+ )
+
+ def _cancel_e_invoice(self, invoice_no):
+ values = frappe._dict(
+ {"reason": "Data Entry Mistake", "remark": "Data Entry Mistake"}
+ )
+ doc = load_doc("Sales Invoice", invoice_no, "cancel")
+
+ # Prepared e_waybill cancel data
+ cancel_e_waybill = self.e_invoice_test_data.get("cancel_e_waybill")
+ cancel_e_waybill.get("response_data").get("result").update(
+ {"ewayBillNo": doc.ewaybill}
+ )
+
+ # Assert for Mock request data
+ self.assertDictEqual(
+ cancel_e_waybill.get("request_data"),
+ EWaybillData(doc).get_data_for_cancellation(values),
+ )
+
+ # Prepared e_invoice cancel data
+ cancel_irn_test_data = self.e_invoice_test_data.get("cancel_e_invoice")
+ cancel_irn_test_data.get("response_data").get("result").update({"Irn": doc.irn})
+
+ # Assert for Mock request data
+ self.assertTrue(
+ cancel_e_waybill.get("request_data"),
+ )
+
+ # Mock response for cancel e_waybill
+ self._mock_e_invoice_response(
+ data=cancel_e_waybill,
+ api="ei/api/ewayapi",
+ )
+
+ # Mock response for cancel e_invoice
+ self._mock_e_invoice_response(
+ data=cancel_irn_test_data,
+ api="ei/api/invoice/cancel",
+ )
+
+ cancel_e_invoice(doc.name, values=values)
+ return frappe.get_doc("Sales Invoice", doc.name)
+
+ def _mock_e_invoice_response(self, data, api="ei/api/invoice"):
+ """Mock response for e-Invoice API"""
+ url = BASE_URL + "/test/" + api
+
+ responses.add(
+ responses.POST,
+ url,
+ body=json.dumps(data.get("response_data")),
+ match=[matchers.json_params_matcher(data.get("request_data"))],
+ status=200,
+ )
+
+
+def update_dates_for_test_data(test_data):
+ """Update test data for e-invoice and e-waybill"""
+ today = format_date(frappe.utils.today(), "dd/mm/yyyy")
+ now = now_datetime().strftime("%Y-%m-%d %H:%M:%S")
+ validity = add_to_date(getdate(), days=1).strftime("%Y-%m-%d %I:%M:%S %p")
+
+ # Update test data for goods_item_with_ewaybill
+ goods_item = test_data.get("goods_item_with_ewaybill")
+ goods_item.get("response_data").get("result").update(
+ {
+ "EwbDt": now,
+ "EwbValidTill": validity,
+ }
+ )
+
+ # Update Document Date in given test data
+ for key in (
+ "goods_item_with_ewaybill",
+ "service_item",
+ "return_invoice",
+ "debit_invoice",
+ ):
+ test_data.get(key).get("request_data").get("DocDtls")["Dt"] = today
+ test_data.get(key).get("response_data").get("result")["AckDt"] = now
+
+ response = test_data.cancel_e_waybill.get("response_data")
+ response.get("result")["cancelDate"] = now_datetime().strftime(
+ "%d/%m/%Y %I:%M:%S %p"
+ )
+
+ response = test_data.cancel_e_invoice.get("response_data")
+ response.get("result")["CancelDate"] = now
diff --git a/india_compliance/gst_india/utils/transaction_data.py b/india_compliance/gst_india/utils/transaction_data.py
index 591e8fa01..ed6de7a1c 100644
--- a/india_compliance/gst_india/utils/transaction_data.py
+++ b/india_compliance/gst_india/utils/transaction_data.py
@@ -2,7 +2,7 @@
import frappe
from frappe import _
-from frappe.utils import format_date, getdate, rounded
+from frappe.utils import format_date, get_link_to_form, getdate, rounded
from india_compliance.gst_india.constants import GST_TAX_TYPES, PINCODE_FORMAT
from india_compliance.gst_india.constants.e_waybill import (
@@ -148,8 +148,10 @@ def set_transporter_details(self):
self.doc.mode_of_transport
),
"vehicle_type": VEHICLE_TYPES.get(self.doc.gst_vehicle_type) or "R",
- "vehicle_no": self.sanitize_value(self.doc.vehicle_no, 1),
- "lr_no": self.sanitize_value(self.doc.lr_no, 2, max_length=15),
+ "vehicle_no": self.sanitize_value(self.doc.vehicle_no, regex=1),
+ "lr_no": self.sanitize_value(
+ self.doc.lr_no, regex=2, max_length=15
+ ),
"lr_date": (
format_date(self.doc.lr_date, self.DATE_FORMAT)
if self.doc.lr_no
@@ -157,7 +159,9 @@ def set_transporter_details(self):
),
"gst_transporter_id": self.doc.gst_transporter_id or "",
"transporter_name": (
- self.sanitize_value(self.doc.transporter_name, 3, max_length=25)
+ self.sanitize_value(
+ self.doc.transporter_name, regex=3, max_length=25
+ )
if self.doc.transporter_name
else ""
),
@@ -213,7 +217,9 @@ def get_all_item_details(self):
"qty": abs(self.rounded(row.qty, 3)),
"taxable_value": abs(self.rounded(row.taxable_value)),
"hsn_code": row.gst_hsn_code,
- "item_name": self.sanitize_value(row.item_name, 3, max_length=300),
+ "item_name": self.sanitize_value(
+ row.item_name, regex=3, max_length=300
+ ),
"uom": uom if uom in UOMS else "OTH",
}
)
@@ -311,16 +317,36 @@ def get_address_details(self, address_name, validate_gstin=False):
self.check_missing_address_fields(address, validate_gstin)
+ error_context = {
+ "reference_doctype": "Address",
+ "reference_name": address.name,
+ }
+
return frappe._dict(
{
"gstin": address.get("gstin") or "URP",
"state_number": address.gst_state_number,
- "address_title": self.sanitize_value(address.address_title, 2),
+ "address_title": self.sanitize_value(
+ address.address_title,
+ regex=2,
+ fieldname="address_title",
+ **error_context,
+ ),
"address_line1": self.sanitize_value(
- address.address_line1, 3, min_length=1
+ address.address_line1,
+ regex=3,
+ min_length=1,
+ fieldname="address_line1",
+ **error_context,
+ ),
+ "address_line2": self.sanitize_value(address.address_line2, regex=3),
+ "city": self.sanitize_value(
+ address.city,
+ regex=3,
+ max_length=50,
+ fieldname="city",
+ **error_context,
),
- "address_line2": self.sanitize_value(address.address_line2, 3),
- "city": self.sanitize_value(address.city, 3, max_length=50),
"pincode": int(address.pincode),
}
)
@@ -392,18 +418,81 @@ def rounded(value, precision=2):
@staticmethod
def sanitize_value(
- value,
+ value: str,
regex=None,
min_length=3,
max_length=100,
truncate=True,
+ *,
+ fieldname=None,
+ reference_doctype=None,
+ reference_name=None,
):
+ """
+ Sanitize value to make it suitable for GST JSON sent for e-Waybill and e-Invoice.
+
+ If fieldname, reference doctype and reference name are present,
+ error will be thrown for invalid values instead of sanitizing them.
+
+ Parameters:
+ ----------
+ @param value: Value to be sanitized
+ @param regex: Regex Key (from REGEX_MAP) to substitute unacceptable characters
+ @param min_length (default: 3): Minimum length of the value that is acceptable
+ @param max_length (default: 100): Maximum length of the value that is acceptable
+ @param truncate (default: True): Truncate the value if it exceeds max_length
+ @param fieldname: Fieldname for which the value is being sanitized
+ @param reference_doctype: Doctype of the document that contains the field
+ @param reference_name: Name of the document that contains the field
+
+ Returns:
+ ----------
+ @return: Sanitized value
+
+ """
+
+ def _throw(message, **format_args):
+ if not (fieldname and reference_doctype and reference_name):
+ return
+
+ message = message.format(
+ field=_(frappe.get_meta(reference_doctype).get_label(fieldname)),
+ **format_args,
+ )
+
+ frappe.throw(
+ _("{reference_doctype} {reference_link}: {message}").format(
+ reference_doctype=_(reference_doctype),
+ reference_link=frappe.bold(
+ get_link_to_form(reference_doctype, reference_name)
+ ),
+ message=message,
+ ),
+ title=_("Invalid Data for GST Upload"),
+ )
+
if not value or len(value) < min_length:
- return
+ return _throw(
+ _("{field} must be at least {min_length} characters long"),
+ min_length=min_length,
+ )
+
+ original_value = value
if regex:
value = re.sub(REGEX_MAP[regex], "", value)
+ if len(value) < min_length:
+ if not original_value.isascii():
+ return _throw(_("{field} must only consist of ASCII characters"))
+
+ return _throw(
+ _("{field} consists of invalid characters: {invalid_chars}"),
+ invalid_chars=frappe.bold(
+ "".join(set(original_value).difference(value))
+ ),
+ )
+
if not truncate and len(value) > max_length:
return
diff --git a/india_compliance/hooks.py b/india_compliance/hooks.py
index 262a54010..071acf88a 100644
--- a/india_compliance/hooks.py
+++ b/india_compliance/hooks.py
@@ -8,12 +8,14 @@
app_color = "grey"
app_email = "hello@indiacompliance.app"
app_license = "GNU General Public License (v3)"
-required_apps = ["erpnext"]
+required_apps = ["frappe/erpnext"]
after_install = "india_compliance.install.after_install"
before_tests = "india_compliance.tests.before_tests"
boot_session = "india_compliance.boot.set_bootinfo"
+before_uninstall = "india_compliance.uninstall.before_uninstall"
+
app_include_js = "gst_india.bundle.js"
doctype_js = {
@@ -157,7 +159,8 @@
"india_compliance.gst_india.utils.jinja.get_transport_type",
"india_compliance.gst_india.utils.jinja.get_transport_mode",
"india_compliance.gst_india.utils.jinja.get_ewaybill_barcode",
- "india_compliance.gst_india.utils.jinja.get_non_zero_fields",
+ "india_compliance.gst_india.utils.jinja.get_e_invoice_item_fields",
+ "india_compliance.gst_india.utils.jinja.get_e_invoice_amount_fields",
],
}
diff --git a/india_compliance/income_tax_india/setup.py b/india_compliance/income_tax_india/setup.py
index a09c7c656..3ca11115c 100644
--- a/india_compliance/income_tax_india/setup.py
+++ b/india_compliance/income_tax_india/setup.py
@@ -4,4 +4,4 @@
def after_install():
- create_custom_fields(CUSTOM_FIELDS, update=True)
+ create_custom_fields(CUSTOM_FIELDS, ignore_validate=True)
diff --git a/india_compliance/income_tax_india/uninstall.py b/india_compliance/income_tax_india/uninstall.py
new file mode 100644
index 000000000..230852591
--- /dev/null
+++ b/india_compliance/income_tax_india/uninstall.py
@@ -0,0 +1,6 @@
+from india_compliance.gst_india.utils.custom_fields import delete_custom_fields
+from india_compliance.income_tax_india.constants.custom_fields import CUSTOM_FIELDS
+
+
+def before_uninstall():
+ delete_custom_fields(CUSTOM_FIELDS)
diff --git a/india_compliance/patches.txt b/india_compliance/patches.txt
index 3eadb659a..81b6124a4 100644
--- a/india_compliance/patches.txt
+++ b/india_compliance/patches.txt
@@ -3,8 +3,9 @@
[post_model_sync]
india_compliance.patches.v14.set_default_for_overridden_accounts_setting
-execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #2
+execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #5
execute:from india_compliance.gst_india.setup import create_property_setters; create_property_setters()
india_compliance.patches.post_install.update_custom_role_for_e_invoice_summary
india_compliance.patches.v14.remove_ecommerce_gstin_from_purchase_invoice
india_compliance.patches.v14.set_sandbox_mode_in_gst_settings
+execute:from india_compliance.gst_india.setup import add_fields_to_item_variant_settings; add_fields_to_item_variant_settings()
diff --git a/india_compliance/patches/post_install/migrate_e_invoice_settings_to_gst_settings.py b/india_compliance/patches/post_install/migrate_e_invoice_settings_to_gst_settings.py
index a000b44ca..244fe7f3d 100644
--- a/india_compliance/patches/post_install/migrate_e_invoice_settings_to_gst_settings.py
+++ b/india_compliance/patches/post_install/migrate_e_invoice_settings_to_gst_settings.py
@@ -5,7 +5,7 @@
from frappe.utils.password import decrypt
from india_compliance.gst_india.constants.custom_fields import E_INVOICE_FIELDS
-from india_compliance.gst_india.utils import toggle_custom_fields
+from india_compliance.gst_india.utils.custom_fields import toggle_custom_fields
def execute():
@@ -39,8 +39,10 @@ def execute():
if sbool(old_settings.enable):
toggle_custom_fields(E_INVOICE_FIELDS, True)
click.secho(
- "Your e-Invoice Settings have been migrated to GST Settings."
- " Please enable the e-Invoice API in GST Settings manually.\n",
+ (
+ "Your e-Invoice Settings have been migrated to GST Settings."
+ " Please enable the e-Invoice API in GST Settings manually.\n"
+ ),
fg="yellow",
)
diff --git a/india_compliance/patches/post_install/remove_old_fields.py b/india_compliance/patches/post_install/remove_old_fields.py
index d3d31af7a..25ff2c237 100644
--- a/india_compliance/patches/post_install/remove_old_fields.py
+++ b/india_compliance/patches/post_install/remove_old_fields.py
@@ -1,4 +1,4 @@
-from india_compliance.gst_india.utils import delete_old_fields
+from india_compliance.gst_india.utils.custom_fields import delete_old_fields
def execute():
diff --git a/india_compliance/patches/post_install/set_default_gst_settings.py b/india_compliance/patches/post_install/set_default_gst_settings.py
index de094e1f0..6c287aa22 100644
--- a/india_compliance/patches/post_install/set_default_gst_settings.py
+++ b/india_compliance/patches/post_install/set_default_gst_settings.py
@@ -4,7 +4,7 @@
from india_compliance.gst_india.constants.custom_fields import (
SALES_REVERSE_CHARGE_FIELDS,
)
-from india_compliance.gst_india.utils import toggle_custom_fields
+from india_compliance.gst_india.utils.custom_fields import toggle_custom_fields
# Enable setting only if transaction exists in last 3 years.
POSTING_DATE_CONDITION = {
diff --git a/india_compliance/patches/post_install/set_gst_category.py b/india_compliance/patches/post_install/set_gst_category.py
index f0fb5d45a..50440d7d9 100644
--- a/india_compliance/patches/post_install/set_gst_category.py
+++ b/india_compliance/patches/post_install/set_gst_category.py
@@ -1,6 +1,6 @@
import frappe
-from india_compliance.gst_india.utils import delete_old_fields
+from india_compliance.gst_india.utils.custom_fields import delete_old_fields
def execute():
diff --git a/india_compliance/patches/post_install/setup_custom_fields_for_gst.py b/india_compliance/patches/post_install/setup_custom_fields_for_gst.py
index 9f5884560..51d4db1f7 100644
--- a/india_compliance/patches/post_install/setup_custom_fields_for_gst.py
+++ b/india_compliance/patches/post_install/setup_custom_fields_for_gst.py
@@ -1,6 +1,6 @@
import frappe
-from india_compliance.gst_india.utils import delete_old_fields
+from india_compliance.gst_india.utils.custom_fields import delete_old_fields
def execute():
diff --git a/india_compliance/patches/post_install/update_e_invoice_fields_and_logs.py b/india_compliance/patches/post_install/update_e_invoice_fields_and_logs.py
index 97c9b57cb..cea3efb95 100644
--- a/india_compliance/patches/post_install/update_e_invoice_fields_and_logs.py
+++ b/india_compliance/patches/post_install/update_e_invoice_fields_and_logs.py
@@ -1,6 +1,7 @@
import frappe
from india_compliance.gst_india.utils import parse_datetime
+from india_compliance.gst_india.utils.custom_fields import delete_custom_fields
user = None
@@ -317,32 +318,3 @@ def delete_e_invoice_fields():
]
}
delete_custom_fields(FIELDS_TO_DELETE)
-
-
-### Helper Function
-
-
-def delete_custom_fields(custom_fields):
- """
- :param custom_fields: a dict like `{'Sales Invoice': [{fieldname: 'test', ...}]}`
- """
-
- for doctypes, fields in custom_fields.items():
- if isinstance(fields, dict):
- # only one field
- fields = [fields]
-
- if isinstance(doctypes, str):
- # only one doctype
- doctypes = (doctypes,)
-
- for doctype in doctypes:
- frappe.db.delete(
- "Custom Field",
- {
- "fieldname": ("in", [field["fieldname"] for field in fields]),
- "dt": doctype,
- },
- )
-
- frappe.clear_cache(doctype=doctype)
diff --git a/india_compliance/patches/post_install/update_reverse_charge_and_export_type.py b/india_compliance/patches/post_install/update_reverse_charge_and_export_type.py
index 33764deab..c3174bcb0 100644
--- a/india_compliance/patches/post_install/update_reverse_charge_and_export_type.py
+++ b/india_compliance/patches/post_install/update_reverse_charge_and_export_type.py
@@ -1,6 +1,6 @@
import frappe
-from india_compliance.gst_india.utils import delete_old_fields
+from india_compliance.gst_india.utils.custom_fields import delete_old_fields
DOCTYPES = ("Purchase Invoice", "Sales Invoice")
diff --git a/india_compliance/patches/v14/remove_ecommerce_gstin_from_purchase_invoice.py b/india_compliance/patches/v14/remove_ecommerce_gstin_from_purchase_invoice.py
index eb3538ea1..95b7b2b5c 100644
--- a/india_compliance/patches/v14/remove_ecommerce_gstin_from_purchase_invoice.py
+++ b/india_compliance/patches/v14/remove_ecommerce_gstin_from_purchase_invoice.py
@@ -1,4 +1,4 @@
-from india_compliance.gst_india.utils import delete_old_fields
+from india_compliance.gst_india.utils.custom_fields import delete_old_fields
def execute():
diff --git a/india_compliance/public/js/quick_entry.js b/india_compliance/public/js/quick_entry.js
index f5042e83b..7ccfd2214 100644
--- a/india_compliance/public/js/quick_entry.js
+++ b/india_compliance/public/js/quick_entry.js
@@ -256,15 +256,16 @@ class AddressQuickEntryForm extends GSTQuickEntryForm {
get_default_party() {
const doc = cur_frm && cur_frm.doc;
- if (!doc) return;
-
- const { doctype, name } = doc;
- if (in_list(frappe.boot.gst_party_types, doctype))
- return { party_type: doctype, party: name };
-
- const party_type = ic.get_party_type(doctype);
- const party = doc[party_type.toLowerCase()];
- return { party_type, party };
+ if (
+ doc &&
+ frappe.dynamic_link &&
+ frappe.dynamic_link.doc === doc
+ ) {
+ return {
+ party_type: frappe.dynamic_link.doctype,
+ party: frappe.dynamic_link.doc[frappe.dynamic_link.fieldname]
+ };
+ }
}
}
diff --git a/india_compliance/public/js/transaction.js b/india_compliance/public/js/transaction.js
index 9d457bf1d..93cbb36bb 100644
--- a/india_compliance/public/js/transaction.js
+++ b/india_compliance/public/js/transaction.js
@@ -40,29 +40,35 @@ function fetch_gst_details(doctype) {
async function update_gst_details(frm) {
if (frm.__gst_update_triggered || frm.updating_party_details || !frm.doc.company) return;
- const party_type = ic.get_party_type(frm.doc.doctype).toLowerCase();
- if (!frm.doc[party_type]) return;
+ const party = frm.doc[ic.get_party_fieldname(frm.doc.doctype)];
+ if (!party) return;
frm.__gst_update_triggered = true;
+
// wait for GSTINs to get fetched
await frappe.after_ajax().then(() => frm.__gst_update_triggered = false);
- const party_fields = ["tax_category", "gst_category", "company_gstin", party_type];
+ const party_details = {};
+
+ // fieldname may be "party_name" for Quotation, but "customer" is expected by get_gst_details
+ party_details[ic.get_party_type(frm.doc.doctype).toLowerCase()] = party;
+
+ const fieldnames_to_set = ["tax_category", "gst_category", "company_gstin"];
if (in_list(frappe.boot.sales_doctypes, frm.doc.doctype)) {
- party_fields.push(
+ fieldnames_to_set.push(
"customer_address",
"billing_address_gstin",
"is_export_with_gst",
"is_reverse_charge"
);
} else {
- party_fields.push("supplier_address", "supplier_gstin");
+ fieldnames_to_set.push("supplier_address", "supplier_gstin");
}
- const party_details = Object.fromEntries(
- party_fields.map(field => [field, frm.doc[field]])
- );
+ for (const fieldname of fieldnames_to_set) {
+ party_details[fieldname] = frm.doc[fieldname];
+ }
frappe.call({
method: "india_compliance.gst_india.overrides.transaction.get_gst_details",
diff --git a/india_compliance/public/js/utils.js b/india_compliance/public/js/utils.js
index a5ce2b393..86e1c927a 100644
--- a/india_compliance/public/js/utils.js
+++ b/india_compliance/public/js/utils.js
@@ -39,6 +39,11 @@ Object.assign(ic, {
return in_list(frappe.boot.sales_doctypes, doctype) ? "Customer" : "Supplier";
},
+ get_party_fieldname(doctype) {
+ if (doctype == "Quotation") return "party_name";
+ return ic.get_party_type(doctype).toLowerCase();
+ },
+
set_state_options(frm) {
const state_field = frm.get_field("state");
const country = frm.get_field("country").value;
diff --git a/india_compliance/tests/__init__.py b/india_compliance/tests/__init__.py
index a91d8b05d..c86cb8a0c 100644
--- a/india_compliance/tests/__init__.py
+++ b/india_compliance/tests/__init__.py
@@ -1,3 +1,5 @@
+from functools import partial
+
import frappe
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
from frappe.test_runner import make_test_objects
@@ -38,6 +40,7 @@ def before_tests():
frappe.flags.country = "India"
frappe.flags.skip_test_records = True
+ frappe.enqueue = partial(frappe.enqueue, now=True)
def set_default_settings_for_tests():
diff --git a/india_compliance/tests/test_records.json b/india_compliance/tests/test_records.json
index b6c82b8bc..dd82d1495 100644
--- a/india_compliance/tests/test_records.json
+++ b/india_compliance/tests/test_records.json
@@ -89,6 +89,32 @@
"income_account": "Sales - _TIRC"
}
]
+ },
+ {
+ "description": "_Test Service Item",
+ "doctype": "Item",
+ "item_code": "_Test Service Item",
+ "item_name": "_Test Service Item",
+ "valuation_rate": 100,
+ "item_group": "Services",
+ "gst_hsn_code": "999900",
+ "uoms": [
+ {
+ "conversion_factor": 1,
+ "uom": "Nos",
+ "name": "_Test Service Item"
+ }
+ ],
+ "item_defaults": [
+ {
+ "name": "_Test Service Item",
+ "company": "_Test Indian Registered Company",
+ "default_warehouse": "Stores - _TIRC",
+ "buying_cost_center": "Main - _TIRC",
+ "selling_cost_center": "Main - _TIRC",
+ "income_account": "Service - _TIRC"
+ }
+ ]
}
],
"Customer": [
@@ -123,21 +149,21 @@
"gst_category": "Registered Regular"
},
{
- "name":"_Test Registered Composition Supplier",
+ "name": "_Test Registered Composition Supplier",
"supplier_name": "_Test Registered Composition Supplier",
"supplier_type": "Individual",
"gstin": "33AAAAR6720M1ZG",
"gst_category": "Registered Composition"
},
{
- "name":"_Test Registered InterState Supplier",
+ "name": "_Test Registered InterState Supplier",
"supplier_name": "_Test Registered InterState Supplier",
"supplier_type": "Individual",
"gstin": "33AAAAR6720M1ZG",
"gst_category": "Registered Regular"
},
{
- "name":"_Test Unregistered Supplier",
+ "name": "_Test Unregistered Supplier",
"supplier_name": "_Test Unregistered Supplier",
"supplier_type": "Individual",
"gstin": "",
@@ -198,7 +224,10 @@
"is_primary_address": 1,
"is_shipping_address": 1,
"links": [
- { "link_doctype": "Customer", "link_name": "_Test Registered Customer" }
+ {
+ "link_doctype": "Customer",
+ "link_name": "_Test Registered Customer"
+ }
]
},
{
@@ -212,7 +241,10 @@
"gstin": "24AANCA4892J1Z8",
"gst_category": "SEZ",
"links": [
- { "link_doctype": "Customer", "link_name": "_Test Registered Customer" }
+ {
+ "link_doctype": "Customer",
+ "link_name": "_Test Registered Customer"
+ }
]
},
{
@@ -247,7 +279,10 @@
"is_primary_address": 1,
"is_shipping_address": 1,
"links": [
- { "link_doctype": "Supplier", "link_name": "_Test Registered Supplier" }
+ {
+ "link_doctype": "Supplier",
+ "link_name": "_Test Registered Supplier"
+ }
]
},
{
@@ -261,7 +296,10 @@
"gstin": "",
"gst_category": "Overseas",
"links": [
- { "link_doctype": "Supplier", "link_name": "_Test Registered Supplier" }
+ {
+ "link_doctype": "Supplier",
+ "link_name": "_Test Registered Supplier"
+ }
]
},
{
@@ -327,7 +365,5 @@
}
]
}
-
]
-
}
\ No newline at end of file
diff --git a/india_compliance/uninstall.py b/india_compliance/uninstall.py
new file mode 100644
index 000000000..bf35019db
--- /dev/null
+++ b/india_compliance/uninstall.py
@@ -0,0 +1,27 @@
+import click
+
+from india_compliance.gst_india.constants import BUG_REPORT_URL
+from india_compliance.gst_india.uninstall import before_uninstall as remove_gst
+from india_compliance.income_tax_india.uninstall import (
+ before_uninstall as remove_income_tax,
+)
+
+
+def before_uninstall():
+ try:
+ print("Removing Income Tax customizations...")
+ remove_income_tax()
+
+ print("Removing GST customizations...")
+ remove_gst()
+
+ except Exception as e:
+ click.secho(
+ (
+ "Removing customizations for India Compliance failed due to an error."
+ " Please try again or"
+ f" report the issue on {BUG_REPORT_URL} if not resolved."
+ ),
+ fg="bright_red",
+ )
+ raise e
|