diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8bf768db2..532961297 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,4 +56,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 9feb95a17..915748cd7 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -106,7 +106,7 @@ jobs: run: cat ~/frappe-bench/bench_start.log || true - name: Upload coverage data - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage path: /home/runner/frappe-bench/sites/coverage.xml @@ -120,7 +120,7 @@ jobs: uses: actions/checkout@v4 - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Upload coverage data uses: codecov/codecov-action@v3 diff --git a/india_compliance/boot.py b/india_compliance/boot.py index 4cf699477..3b28dba71 100644 --- a/india_compliance/boot.py +++ b/india_compliance/boot.py @@ -26,6 +26,7 @@ def set_bootinfo(bootinfo): bootinfo["ic_api_enabled_from_conf"] = bool(frappe.conf.ic_api_secret) set_trigger_for_audit_trail_notification(bootinfo) + set_trigger_for_item_tax_template_notification(bootinfo) def set_trigger_for_audit_trail_notification(bootinfo): @@ -39,3 +40,12 @@ def set_trigger_for_audit_trail_notification(bootinfo): return bootinfo["needs_audit_trail_notification"] = True + + +def set_trigger_for_item_tax_template_notification(bootinfo): + if not bootinfo.sysdefaults or not cint( + bootinfo.sysdefaults.get("needs_item_tax_template_notification", 0) + ): + return + + bootinfo["needs_item_tax_template_notification"] = True diff --git a/india_compliance/exceptions.py b/india_compliance/exceptions.py index a403d6121..9f1ae8315 100644 --- a/india_compliance/exceptions.py +++ b/india_compliance/exceptions.py @@ -1,4 +1,8 @@ -class GatewayTimeoutError(Exception): +class GSPServerError(Exception): + def __init__(self, message="GSP/GST server is down", *args, **kwargs): + super().__init__(message, *args, **kwargs) + + +class GatewayTimeoutError(GSPServerError): def __init__(self, message="The server took too long to respond", *args, **kwargs): - self.message = message - super().__init__(self.message, *args, **kwargs) + super().__init__(message, *args, **kwargs) diff --git a/india_compliance/gst_india/api_classes/base.py b/india_compliance/gst_india/api_classes/base.py index fb1b6c90c..f0e19205c 100644 --- a/india_compliance/gst_india/api_classes/base.py +++ b/india_compliance/gst_india/api_classes/base.py @@ -6,7 +6,7 @@ from frappe import _ from frappe.utils import sbool -from india_compliance.exceptions import GatewayTimeoutError +from india_compliance.exceptions import GatewayTimeoutError, GSPServerError from india_compliance.gst_india.utils import is_api_enabled from india_compliance.gst_india.utils.api import enqueue_integration_request @@ -187,6 +187,9 @@ def handle_error_response(self, response_json): if isinstance(success_value, str): success_value = sbool(success_value) + if not success_value: + self.handle_server_error(response_json) + if not success_value and not self.is_ignored_error(response_json): frappe.throw( response_json.get("message") @@ -195,6 +198,18 @@ def handle_error_response(self, response_json): title=_("API Request Failed"), ) + def handle_server_error(self, response_json): + error_message_list = [ + "GSPGSTDOWN", + "GSPERR300", + "Connection reset", + "No route to host", + ] + + for error in error_message_list: + if error in response_json.get("message"): + raise GSPServerError + def is_ignored_error(self, response_json): # Override in subclass, return truthy value to stop frappe.throw pass diff --git a/india_compliance/gst_india/client_scripts/company.js b/india_compliance/gst_india/client_scripts/company.js index cff619929..25663d16b 100644 --- a/india_compliance/gst_india/client_scripts/company.js +++ b/india_compliance/gst_india/client_scripts/company.js @@ -26,7 +26,7 @@ frappe.ui.form.on(DOCTYPE, { frappe.call({ method: "india_compliance.gst_india.overrides.company.make_default_tax_templates", - args: { company: frm.doc.name }, + args: { company: frm.doc.name, gst_rate: frm.doc.default_gst_rate}, callback: function () { frappe.msgprint(__("Default Tax Templates created")); }, diff --git a/india_compliance/gst_india/client_scripts/e_invoice_actions.js b/india_compliance/gst_india/client_scripts/e_invoice_actions.js index 5412f92e1..96f70eb2e 100644 --- a/india_compliance/gst_india/client_scripts/e_invoice_actions.js +++ b/india_compliance/gst_india/client_scripts/e_invoice_actions.js @@ -222,7 +222,9 @@ function is_e_invoice_applicable(frm) { frm.doc.company_gstin != frm.doc.billing_address_gstin && (frm.doc.place_of_supply === "96-Other Countries" || frm.doc.billing_address_gstin) && - !frm.doc.items[0].is_non_gst && + frm.doc.items.some(item => + ["Taxable", "Zero-Rated"].includes(item.gst_treatment) + ) && is_valid_e_invoice_applicability_date(frm) ); } diff --git a/india_compliance/gst_india/client_scripts/item_tax_template.js b/india_compliance/gst_india/client_scripts/item_tax_template.js new file mode 100644 index 000000000..12528d87f --- /dev/null +++ b/india_compliance/gst_india/client_scripts/item_tax_template.js @@ -0,0 +1,95 @@ +frappe.ui.form.on("Item Tax Template", { + refresh: show_missing_accounts_banner, + fetch_gst_accounts: fetch_and_update_missing_gst_accounts, + async gst_rate(frm) { + if (frm.doc.gst_rate === null) return; + + await Promise.all( + frm.doc.taxes.map(async row => { + const tax_rate = await get_tax_rate_for_account(frm, row.tax_type); + if (tax_rate == null) return; + + row.tax_rate = tax_rate; + }) + ); + + frm.refresh_field("taxes"); + }, +}); + +async function show_missing_accounts_banner(frm) { + if (frm.doc.gst_treatment === "Non-GST" || frm.doc.__islocal) return; + + const missing_accounts = await get_missing_gst_accounts(frm); + if (!missing_accounts) return; + + // show banner + frm.dashboard.add_comment( + __(`Missing GST Accounts: {0}`, [missing_accounts.join(", ")]), + "orange", + true + ); +} + +async function fetch_and_update_missing_gst_accounts(frm) { + const missing_accounts = await get_missing_gst_accounts(frm); + if (!missing_accounts) return; + + // cleanup existing empty rows + frm.doc.taxes = frm.doc.taxes.filter(row => row.tax_type); + + // add missing rows + await Promise.all( + missing_accounts.map(async account => { + const tax_rate = await get_tax_rate_for_account(frm, account); + frm.add_child("taxes", { tax_type: account, tax_rate: tax_rate }); + }) + ); + + frm.refresh_field("taxes"); +} + +async function get_tax_rate_for_account(frm, account) { + const gst_rate = frm.doc.gst_rate; + if (!gst_rate) return 0; + + const gst_accounts = await get_gst_accounts(frm); + if (!gst_accounts) return null; + + const [_, intra_state_accounts, inter_state_accounts] = gst_accounts; + + if (intra_state_accounts.includes(account)) return gst_rate / 2; + else if (inter_state_accounts.includes(account)) return gst_rate; + else return null; +} + +async function get_missing_gst_accounts(frm) { + let gst_accounts = await get_gst_accounts(frm); + if (!gst_accounts) return; + + const all_gst_accounts = gst_accounts[0]; + const template_accounts = frm.doc.taxes.map(t => t.tax_type); + const missing_accounts = all_gst_accounts.filter( + a => a && !template_accounts.includes(a) + ); + + if (missing_accounts.length) return missing_accounts; +} + +async function get_gst_accounts(frm) { + const company = frm.doc.company; + + // cache company gst accounts + if (!frm._company_gst_accounts?.[company]) { + frm._company_gst_accounts = frm._company_gst_accounts || {}; + const { message } = await frappe.call({ + method: "india_compliance.gst_india.overrides.transaction.get_valid_gst_accounts", + args: { company: company }, + }); + + frm._company_gst_accounts[company] = message; + } + + if (!frm._company_gst_accounts[company]) return; + return frm._company_gst_accounts[company]; +} diff --git a/india_compliance/gst_india/client_scripts/sales_invoice.js b/india_compliance/gst_india/client_scripts/sales_invoice.js index d0c155138..d05296f79 100644 --- a/india_compliance/gst_india/client_scripts/sales_invoice.js +++ b/india_compliance/gst_india/client_scripts/sales_invoice.js @@ -83,8 +83,11 @@ function is_gst_invoice(frm) { frm.doc.is_opening != "Yes" && frm.doc.company_gstin && frm.doc.company_gstin != frm.doc.billing_address_gstin && - !frm.doc.items.some(item => item.is_non_gst) && - !frm.doc.items.every(item => item.is_nil_exempt); + !frm.doc.items.some(item => item.gst_treatment == "Non-GST") && + !frm.doc.items.every( + item => + item.gst_treatment == "Nil-Rated" || item.gst_treatment == "Exempted" + ); if (frm.doc.place_of_supply === "96-Other Countries") { return gst_invoice_conditions && frm.doc.is_export_with_gst; diff --git a/india_compliance/gst_india/constants/custom_fields.py b/india_compliance/gst_india/constants/custom_fields.py index ec075efe7..f3d075138 100644 --- a/india_compliance/gst_india/constants/custom_fields.py +++ b/india_compliance/gst_india/constants/custom_fields.py @@ -2,6 +2,7 @@ from india_compliance.gst_india.constants import ( GST_CATEGORIES, + GST_TAX_RATES, PORT_CODES, STATE_NUMBERS, ) @@ -53,6 +54,17 @@ "insert_after": "parent_company", }, *party_fields[1:], + { + "fieldname": "default_gst_rate", + "label": "Default GST Rate", + "fieldtype": "Select", + "options": "\n".join(str(f) for f in GST_TAX_RATES), + "description": "Sales / Purchase Taxes and Charges Template will be created based on this GST Rate", + "default": "18.0", + "depends_on": "eval:doc.country == 'India' && doc.__islocal", + "insert_after": "country", + "translatable": 0, + }, { "fieldname": "default_customs_expense_account", "label": "Default Customs Duty Expense Account", @@ -268,9 +280,22 @@ "translatable": 0, } ], - # Transaction Item Fields + # Transaction Item: Tax Fields + "Material Request Item": [ + { + "fieldname": "gst_hsn_code", + "label": "HSN/SAC", + "fieldtype": "Data", + "fetch_from": "item_code.gst_hsn_code", + "insert_after": "description", + "allow_on_submit": 1, + "print_hide": 1, + "fetch_if_empty": 1, + "translatable": 0, + }, + ], + # Taxable Value and GST Details ( - "Material Request Item", "Supplier Quotation Item", "Purchase Order Item", "Purchase Receipt Item", @@ -293,38 +318,135 @@ "translatable": 0, }, { - "fieldname": "is_nil_exempt", - "label": "Is Nil Rated or Exempted", - "fieldtype": "Check", - "fetch_from": "item_code.is_nil_exempt", - "insert_after": "gst_hsn_code", - "print_hide": 1, - }, - { - "fieldname": "is_non_gst", - "label": "Is Non GST", - "fieldtype": "Check", - "fetch_from": "item_code.is_non_gst", - "insert_after": "is_nil_exempt", + "fieldname": "gst_treatment", + "label": "GST Treatment", + "fieldtype": "Autocomplete", + "options": "Taxable\nZero-Rated\nNil-Rated\nExempted\nNon-GST", + "fetch_from": "item_tax_template.gst_treatment", + "fetch_if_empty": 1, + "insert_after": "item_tax_template", "print_hide": 1, + "read_only": 1, + "translatable": 0, + "no_copy": 1, }, - ], - # Taxable Value - ( - "Delivery Note Item", - "Sales Invoice Item", - "POS Invoice Item", - "Purchase Invoice Item", - "Purchase Receipt Item", - ): [ { "fieldname": "taxable_value", "label": "Taxable Value", "fieldtype": "Currency", "insert_after": "base_net_amount", - "hidden": 1, "options": "Company:company:default_currency", + "read_only": 1, + "translatable": 0, + "no_copy": 1, "print_hide": 1, + "hidden": 0, + }, + { + "fieldtype": "Section Break", + "label": "GST Details", + "insert_after": "taxable_value", + "fieldname": "gst_details_section", + "collapsible": 1, + }, + { + "fieldname": "igst_rate", + "label": "IGST Rate", + "fieldtype": "Float", + "insert_after": "gst_details_section", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "cgst_rate", + "label": "CGST Rate", + "fieldtype": "Float", + "insert_after": "igst_rate", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "sgst_rate", + "label": "SGST Rate", + "fieldtype": "Float", + "insert_after": "cgst_rate", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "cess_rate", + "label": "CESS Rate", + "fieldtype": "Float", + "insert_after": "sgst_rate", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "cess_non_advol_rate", + "label": "CESS Non Advol Rate", + "fieldtype": "Float", + "insert_after": "cess_rate", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldtype": "Column Break", + "insert_after": "cess_non_advol_rate", + "fieldname": "cb_gst_details", + }, + { + "fieldname": "igst_amount", + "label": "IGST Amount", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "cb_gst_details", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "cgst_amount", + "label": "CGST Amount", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "igst_amount", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "sgst_amount", + "label": "SGST Amount", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "cgst_amount", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "cess_amount", + "label": "CESS Amount", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "sgst_amount", + "read_only": 1, + "translatable": 0, + "no_copy": 1, + }, + { + "fieldname": "cess_non_advol_amount", + "label": "CESS Non Advol Amount", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "cess_amount", + "read_only": 1, + "translatable": 0, "no_copy": 1, }, ], @@ -339,7 +461,7 @@ "label": "Is Ineligible for Input Tax Credit", "fieldtype": "Check", "fetch_from": "item_code.is_ineligible_for_itc", - "insert_after": "is_nil_exempt", + "insert_after": "gst_hsn_code", "fetch_if_empty": 1, "print_hide": 1, }, @@ -674,22 +796,35 @@ "description": "You can search code by the description of the category.", }, { - "fieldname": "is_nil_exempt", - "label": "Is Nil Rated or Exempted", + "fieldname": "is_ineligible_for_itc", + "label": "Is Ineligible for Input Tax Credit", "fieldtype": "Check", - "insert_after": "gst_hsn_code", + "insert_after": "item_tax_section_break", }, + ], + "Item Tax Template": [ { - "fieldname": "is_non_gst", - "label": "Is Non GST", - "fieldtype": "Check", - "insert_after": "is_nil_exempt", + "fieldname": "gst_treatment", + "label": "GST Treatment", + "fieldtype": "Autocomplete", + "default": "Taxable", + "options": "Taxable\nNil-Rated\nExempted\nNon-GST", + "insert_after": "column_break_3", + "translatable": 0, }, { - "fieldname": "is_ineligible_for_itc", - "label": "Is Ineligible for Input Tax Credit", - "fieldtype": "Check", - "insert_after": "item_tax_section_break", + "fieldname": "gst_rate", + "label": "GST Rate", + "fieldtype": "Float", + "insert_after": "gst_treatment", + "depends_on": "eval:doc.gst_treatment == 'Taxable'", + "translatable": 0, + }, + { + "fieldname": "fetch_gst_accounts", + "label": "Fetch GST Accounts", + "fieldtype": "Button", + "insert_after": "section_break_5", }, ], } diff --git a/india_compliance/gst_india/data/tax_defaults.json b/india_compliance/gst_india/data/tax_defaults.json index 31f12c846..c8f9c263b 100644 --- a/india_compliance/gst_india/data/tax_defaults.json +++ b/india_compliance/gst_india/data/tax_defaults.json @@ -33,6 +33,8 @@ "item_tax_templates": [ { "title": "GST 18%", + "gst_rate": 18, + "gst_treatment": "Taxable", "taxes": [ { "tax_type": { @@ -95,6 +97,8 @@ }, { "title": "GST 5%", + "gst_rate": 5, + "gst_treatment": "Taxable", "taxes": [ { "tax_type": { @@ -157,6 +161,8 @@ }, { "title": "GST 12%", + "gst_rate": 12, + "gst_treatment": "Taxable", "taxes": [ { "tax_type": { @@ -219,6 +225,8 @@ }, { "title": "GST 28%", + "gst_rate": 28, + "gst_treatment": "Taxable", "taxes": [ { "tax_type": { @@ -278,6 +286,198 @@ } } ] + }, + { + "title": "Nil-Rated", + "gst_rate": 0, + "gst_treatment": "Nil-Rated", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 0 + } + } + ] + }, + { + "title": "Exempted", + "gst_rate": 0, + "gst_treatment": "Exempted", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 0 + } + } + ] + }, + { + "title": "Non-GST", + "gst_rate": 0, + "gst_treatment": "Non-GST", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 0 + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 0 + } + } + ] } ], "sales_tax_templates": [ diff --git a/india_compliance/gst_india/data/test_e_invoice.json b/india_compliance/gst_india/data/test_e_invoice.json index 61089bbec..256b7aef1 100644 --- a/india_compliance/gst_india/data/test_e_invoice.json +++ b/india_compliance/gst_india/data/test_e_invoice.json @@ -207,6 +207,111 @@ ] } }, + "nil_exempted_item": { + "kwargs": { + "item_code": "_Test Nil Rated Item", + "customer_address": "_Test Registered Customer-Billing", + "shipping_address_name": "_Test Registered Customer-Billing", + "rate":100 + }, + "request_data": { + "BuyerDtls": { + "Addr1": "Test Address - 3", + "Gstin": "36AMBPG7773M002", + "LglNm": "_Test Registered Customer", + "Loc": "Test City", + "Pin": 500055, + "Pos": "02", + "Stcd": "36", + "TrdNm": "_Test Registered Customer" + }, + "DocDtls": { + "Dt": "18/12/2023", + "No": "test_invoice_no", + "Typ": "INV" + }, + "ItemList": [ + { + "SlNo": "2", + "PrdDesc": "Test Trading Goods 1", + "IsServc": "N", + "HsnCd": "61149090", + "Unit": "NOS", + "Qty": 1.0, + "UnitPrice": 10.0, + "TotAmt": 10.0, + "Discount": 0, + "AssAmt": 10, + "GstRt": 12.0, + "IgstAmt": 0, + "CgstAmt": 0.6, + "SgstAmt": 0.6, + "CesRt": 0, + "CesAmt": 0, + "CesNonAdvlAmt": 0, + "TotItemVal": 11.2 + } + ], + "PayDtls": { + "CrDay": 0, + "PaidAmt": 0, + "PaymtDue": 111.0 + }, + "SellerDtls": { + "Addr1": "Test Address - 1", + "Gstin": "02AMBPG7773M002", + "LglNm": "_Test Indian Registered Company", + "Loc": "Test City", + "Pin": 171302, + "Stcd": "02", + "TrdNm": "_Test Indian Registered Company" + }, + "TranDtls": { + "RegRev": "N", + "SupTyp": "B2B", + "TaxSch": "GST" + }, + "ValDtls": { + "AssVal": 10.0, + "CesVal": 0, + "CgstVal": 0.6, + "Discount": 0.0, + "IgstVal": 0, + "OthChrg": 100.0, + "RndOffAmt": -0.2, + "SgstVal": 0.6, + "TotInvVal": 111.0 + }, + "Version": "1.1" + }, + "response_data": { + "success": true, + "message": "IRN generated successfully", + "result": { + "AckNo": 232210036754863, + "AckDt": "2022-09-17 16:26:00", + "Irn": "68fb4fab44aee99fb23292478c4bd838e664837c9f1b04e3d9134ffed0b40b60", + "SignedInvoice": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiQWNrTm9cIjoyMzIyMTAwMzY3NTQ4NjMsXCJBY2tEdFwiOlwiMjAyMi0wOS0xNyAxNjoyNjowMFwiLFwiSXJuXCI6XCI2OGZiNGZhYjQ0YWVlOTlmYjIzMjkyNDc4YzRiZDgzOGU2NjQ4MzdjOWYxYjA0ZTNkOTEzNGZmZWQwYjQwYjYwXCIsXCJWZXJzaW9uXCI6XCIxLjFcIixcIlRyYW5EdGxzXCI6e1wiVGF4U2NoXCI6XCJHU1RcIixcIlN1cFR5cFwiOlwiQjJCXCIsXCJSZWdSZXZcIjpcIk5cIn0sXCJEb2NEdGxzXCI6e1wiVHlwXCI6XCJJTlZcIixcIk5vXCI6XCJnMnF4aFlcIixcIkR0XCI6XCIxNy8wOS8yMDIyXCJ9LFwiU2VsbGVyRHRsc1wiOntcIkdzdGluXCI6XCIwMUFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJfVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJUcmRObVwiOlwiVGVzdCBJbmRpYW4gUmVnaXN0ZXJlZCBDb21wYW55XCIsXCJBZGRyMVwiOlwiVGVzdCBBZGRyZXNzIC0gMVwiLFwiTG9jXCI6XCJUZXN0IENpdHlcIixcIlBpblwiOjE5MzUwMSxcIlN0Y2RcIjpcIjAxXCJ9LFwiQnV5ZXJEdGxzXCI6e1wiR3N0aW5cIjpcIjM2QU1CUEc3NzczTTAwMlwiLFwiTGdsTm1cIjpcIl9UZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlBvc1wiOlwiMDFcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJEaXNwRHRsc1wiOntcIk5tXCI6XCJUZXN0IEluZGlhbiBSZWdpc3RlcmVkIENvbXBhbnlcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAxXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6MTkzNTAxLFwiU3RjZFwiOlwiMDFcIn0sXCJTaGlwRHRsc1wiOntcIkdzdGluXCI6XCIzNkFNQlBHNzc3M00wMDJcIixcIkxnbE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIlRyZE5tXCI6XCJUZXN0IFJlZ2lzdGVyZWQgQ3VzdG9tZXJcIixcIkFkZHIxXCI6XCJUZXN0IEFkZHJlc3MgLSAzXCIsXCJMb2NcIjpcIlRlc3QgQ2l0eVwiLFwiUGluXCI6NTAwMDU1LFwiU3RjZFwiOlwiMzZcIn0sXCJJdGVtTGlzdFwiOlt7XCJJdGVtTm9cIjowLFwiU2xOb1wiOlwiMVwiLFwiSXNTZXJ2Y1wiOlwiWVwiLFwiUHJkRGVzY1wiOlwiVGVzdCBTZXJ2aWNlIEl0ZW1cIixcIkhzbkNkXCI6XCI5OTU0MTFcIixcIlF0eVwiOjEuMCxcIlVuaXRcIjpcIk5PU1wiLFwiVW5pdFByaWNlXCI6MTAwLjAsXCJUb3RBbXRcIjoxMDAuMCxcIkRpc2NvdW50XCI6MCxcIkFzc0FtdFwiOjEwMC4wLFwiR3N0UnRcIjowLjAsXCJJZ3N0QW10XCI6MCxcIkNnc3RBbXRcIjowLFwiU2dzdEFtdFwiOjAsXCJDZXNSdFwiOjAsXCJDZXNBbXRcIjowLFwiQ2VzTm9uQWR2bEFtdFwiOjAsXCJUb3RJdGVtVmFsXCI6MTAwLjB9XSxcIlZhbER0bHNcIjp7XCJBc3NWYWxcIjoxMDAuMCxcIkNnc3RWYWxcIjowLFwiU2dzdFZhbFwiOjAsXCJJZ3N0VmFsXCI6MCxcIkNlc1ZhbFwiOjAsXCJEaXNjb3VudFwiOjAsXCJPdGhDaHJnXCI6MC4wLFwiUm5kT2ZmQW10XCI6MC4wLFwiVG90SW52VmFsXCI6MTAwLjB9LFwiUGF5RHRsc1wiOntcIkNyRGF5XCI6MCxcIlBhaWRBbXRcIjowLFwiUGF5bXREdWVcIjoxMDAuMH0sXCJFd2JEdGxzXCI6e1wiRGlzdGFuY2VcIjowfX0iLCJpc3MiOiJOSUMifQ.rcfXkciqDJypX-xCqaUU3xAk2gccHK_qBD_FBIUsEr-SyWVs4LStgXwQEWUhTYnEfcGGm_sWX15ewC0jn9iWVFCNNFnjKc8vsFQqnbzvi-bnr6CWkjRXOxVqQTfis6PbtTrblojBq2hhBaT1B_ZlgLePi5qFNWnxxaHItjYtBEeBzW5JxzXWQTqESrBy02iLQgQMOexmQ6jKGdUR3tRRG5MVB7QfXbL9BpQr-DXbHhbDGllT2S_xyj9SBsN6ICeluCG-ZJdHE_kCoIxc8iY_nXneb2PsBciij14cHb96h4ddNZtapxTPTh4CumVDmAgLBSVsBTugp8vm0L-jd7n3dg", + "SignedQRCode": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVEQzU3REUxMzU4QjMwMEJBOUY3OTM0MEE2Njk2ODMxRjNDODUwNDciLCJ0eXAiOiJKV1QiLCJ4NXQiOiI3Y1Y5NFRXTE1BdXA5NU5BcG1sb01mUElVRWMifQ.eyJkYXRhIjoie1wiU2VsbGVyR3N0aW5cIjpcIjAxQU1CUEc3NzczTTAwMlwiLFwiQnV5ZXJHc3RpblwiOlwiMzZBTUJQRzc3NzNNMDAyXCIsXCJEb2NOb1wiOlwiZzJxeGhZXCIsXCJEb2NUeXBcIjpcIklOVlwiLFwiRG9jRHRcIjpcIjE3LzA5LzIwMjJcIixcIlRvdEludlZhbFwiOjEwMC4wLFwiSXRlbUNudFwiOjEsXCJNYWluSHNuQ29kZVwiOlwiOTk1NDExXCIsXCJJcm5cIjpcIjY4ZmI0ZmFiNDRhZWU5OWZiMjMyOTI0NzhjNGJkODM4ZTY2NDgzN2M5ZjFiMDRlM2Q5MTM0ZmZlZDBiNDBiNjBcIixcIklybkR0XCI6XCIyMDIyLTA5LTE3IDE2OjI2OjAwXCJ9IiwiaXNzIjoiTklDIn0.A99BPXfKiGSNjnEcqmxc7RGutWakaeW0NMan9oC5yMw6zAoTNcVc34GtQKV7iajBZQhyiFrNwn5n6QtYOXafpitHcI_yrUWSojQBPPpPlslqj4hbnbCy7kmOZZ8mOKISHJrsIZJxpjRlSquIzfDN4aP1aT_qHDqwFqyA8RyJM-id5EpDaTrUFK12HwjKfAXHn4shEUBBgEWrHYOKK6VdpCNi6F_5I5bbJRivrvUJxMLjk3Ux9fyylqnEeyE2NThs9hFuV9EgoVzGE3FhfPZsooToAuG_npYEv3f6Q9KbOw3pNQ3NkqFwvmFjfNJLXdbxZIPe9fe9F1c-CRrIoNo_9w", + "Status": "ACT", + "EwbNo": null, + "EwbDt": null, + "EwbValidTill": null, + "Remarks": null + }, + "info": [ + { + "InfCd": "EWBERR", + "Desc": [ + { + "ErrorCode": "4019", + "ErrorMessage": "Provide Transporter ID in order to generate Part A of e-Way Bill" + } + ] + } + ] + } + }, "return_invoice": { "kwargs": { "qty": -1, diff --git a/india_compliance/gst_india/doctype/gstr_3b_report/gstr_3b_report.py b/india_compliance/gst_india/doctype/gstr_3b_report/gstr_3b_report.py index d8c15a311..ff6840605 100644 --- a/india_compliance/gst_india/doctype/gstr_3b_report/gstr_3b_report.py +++ b/india_compliance/gst_india/doctype/gstr_3b_report/gstr_3b_report.py @@ -220,11 +220,12 @@ def _get_tax_amount(account_type): def get_inward_nil_exempt(self, state): inward_nil_exempt = frappe.db.sql( """ - SELECT p.place_of_supply, p.supplier_address,i.taxable_value, i.is_nil_exempt, i.is_non_gst + SELECT p.place_of_supply, p.supplier_address, + i.taxable_value, i.gst_treatment FROM `tabPurchase Invoice` p , `tabPurchase Invoice Item` i WHERE p.docstatus = 1 and p.name = i.parent and p.is_opening = 'No' - and (i.is_nil_exempt = 1 or i.is_non_gst = 1 or p.gst_category = 'Registered Composition') and + and (i.gst_treatment != 'Taxable' or p.gst_category = 'Registered Composition') and month(p.posting_date) = %s and year(p.posting_date) = %s and p.company = %s and p.company_gstin = %s """, @@ -244,26 +245,21 @@ def get_inward_nil_exempt(self, state): d.place_of_supply = "00-" + cstr(state) supplier_state = address_state_map.get(d.supplier_address) or state + is_intra_state = cstr(supplier_state) == cstr( + d.place_of_supply.split("-")[1] + ) amount = flt(d.taxable_value, 2) - if ( - d.is_nil_exempt == 1 - or d.get("gst_category") == "Registered Composition" - ) and cstr(supplier_state) == cstr(d.place_of_supply.split("-")[1]): - inward_nil_exempt_details["gst"]["intra"] += amount - elif ( - d.is_nil_exempt == 1 - or d.get("gst_category") == "Registered Composition" - ) and cstr(supplier_state) != cstr(d.place_of_supply.split("-")[1]): - inward_nil_exempt_details["gst"]["inter"] += amount - elif d.is_non_gst == 1 and cstr(supplier_state) == cstr( - d.place_of_supply.split("-")[1] - ): - inward_nil_exempt_details["non_gst"]["intra"] += amount - elif d.is_non_gst == 1 and cstr(supplier_state) != cstr( - d.place_of_supply.split("-")[1] - ): - inward_nil_exempt_details["non_gst"]["inter"] += amount + if d.gst_treatment != "Non-GST": + if is_intra_state: + inward_nil_exempt_details["gst"]["intra"] += amount + else: + inward_nil_exempt_details["gst"]["inter"] += amount + else: + if is_intra_state: + inward_nil_exempt_details["non_gst"]["intra"] += amount + else: + inward_nil_exempt_details["non_gst"]["inter"] += amount return inward_nil_exempt_details @@ -300,7 +296,7 @@ def get_outward_tax_invoices(self, doctype, reverse_charge=None): def get_outward_items(self, doctype): self.invoice_items = frappe._dict() - self.is_nil_exempt = [] + self.is_nil_or_exempt = [] self.is_non_gst = [] if not self.invoice_map: @@ -310,7 +306,7 @@ def get_outward_items(self, doctype): f""" SELECT item_code, parent, taxable_value, item_tax_rate, - is_nil_exempt, is_non_gst + gst_treatment FROM `tab{doctype} Item` WHERE parent in ({", ".join(["%s"] * len(self.invoice_map))}) @@ -323,10 +319,16 @@ def get_outward_items(self, doctype): self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) - if d.is_nil_exempt and d.item_code not in self.is_nil_exempt: - self.is_nil_exempt.append(d.item_code) + is_nil_rated = d.gst_treatment == "Nil-Rated" + is_exempted = d.gst_treatment == "Exempted" + is_non_gst = d.gst_treatment == "Non-GST" - if d.is_non_gst and d.item_code not in self.is_non_gst: + if ( + is_nil_rated or is_exempted + ) and d.item_code not in self.is_nil_or_exempt: + self.is_nil_or_exempt.append(d.item_code) + + if is_non_gst and d.item_code not in self.is_non_gst: self.is_non_gst.append(d.item_code) def get_outward_tax_details(self, doctype): @@ -372,7 +374,9 @@ def get_outward_tax_details(self, doctype): if not ( cgst_or_sgst or account in self.account_heads.get("iamt") - or (item_code in self.is_non_gst + self.is_nil_exempt) + or ( + item_code in self.is_non_gst + self.is_nil_or_exempt + ) ): continue @@ -411,7 +415,7 @@ def get_outward_tax_details(self, doctype): else: for item in items.keys(): if ( - item in self.is_nil_exempt + self.is_non_gst + item in self.is_nil_or_exempt + self.is_non_gst and item not in self.items_based_on_tax_rate.get(invoice, {}).get(0, []) ): @@ -433,7 +437,7 @@ def set_outward_taxable_supplies(self): for rate, items in items_based_on_rate.items(): for item_code, taxable_value in self.invoice_items.get(inv).items(): if item_code in items: - if item_code in self.is_nil_exempt: + if item_code in self.is_nil_or_exempt: self.report_dict["sup_details"]["osup_nil_exmp"][ "txval" ] += taxable_value diff --git a/india_compliance/gst_india/doctype/gstr_3b_report/test_gstr_3b_report.py b/india_compliance/gst_india/doctype/gstr_3b_report/test_gstr_3b_report.py index 1f425c164..73b01f2ca 100644 --- a/india_compliance/gst_india/doctype/gstr_3b_report/test_gstr_3b_report.py +++ b/india_compliance/gst_india/doctype/gstr_3b_report/test_gstr_3b_report.py @@ -55,7 +55,6 @@ def test_gstr_3b_report(self): ).insert() output = json.loads(report.json_output) - self.assertEqual(output["sup_details"]["osup_det"]["iamt"], 18) self.assertEqual(output["sup_details"]["osup_det"]["txval"], 300) self.assertEqual(output["sup_details"]["isup_rev"]["txval"], 100) @@ -82,7 +81,7 @@ def test_gst_rounding(self): def create_sales_invoices(): create_sales_invoice(is_in_state=True) - create_sales_invoice(is_nil_exempt=True) + create_sales_invoice(item_code="_Test Nil Rated Item") create_sales_invoice( customer="_Test Registered Composition Customer", is_out_state=True, diff --git a/india_compliance/gst_india/overrides/company.py b/india_compliance/gst_india/overrides/company.py index 63a267d58..321109dd2 100644 --- a/india_compliance/gst_india/overrides/company.py +++ b/india_compliance/gst_india/overrides/company.py @@ -1,4 +1,5 @@ import frappe +from frappe.utils import flt from erpnext.setup.setup_wizard.operations.taxes_setup import from_detailed_data from india_compliance.gst_india.utils import get_data_file_path @@ -22,11 +23,14 @@ def make_company_fixtures(doc, method=None): if not frappe.flags.country_change or doc.country != "India": return - create_company_fixtures(doc.name) + create_company_fixtures(doc.name, doc.default_gst_rate) -def create_company_fixtures(company): - make_default_tax_templates(company) +def create_company_fixtures(company, gst_rate=None): + if not frappe.flags.in_setup_wizard: + # Manual Trigger in Setup Wizard with custom rate + make_default_tax_templates(company, gst_rate) + make_default_customs_accounts(company) make_default_gst_expense_accounts(company) @@ -57,14 +61,44 @@ def make_default_gst_expense_accounts(company): @frappe.whitelist() -def make_default_tax_templates(company: str): +def make_default_tax_templates(company: str, gst_rate=None): frappe.has_permission("Company", ptype="write", doc=company, throw=True) - default_taxes = frappe.get_file_json(get_data_file_path("tax_defaults.json")) + default_taxes = get_tax_defaults(gst_rate) from_detailed_data(company, default_taxes) update_gst_settings(company) +def get_tax_defaults(gst_rate=None): + if not gst_rate: + gst_rate = 18 + + default_taxes = frappe.get_file_json(get_data_file_path("tax_defaults.json")) + + gst_rate = flt(gst_rate, 3) + if gst_rate == 18: + return default_taxes + + return modify_tax_defaults(default_taxes, gst_rate) + + +def modify_tax_defaults(default_taxes, gst_rate): + # Identifying new_rate based on existing rate + for template_type in ("sales_tax_templates", "purchase_tax_templates"): + template = default_taxes["chart_of_accounts"]["*"][template_type] + for tax in template: + for row in tax.get("taxes"): + rate = ( + gst_rate + if row["account_head"]["tax_rate"] == 18 + else flt(gst_rate / 2, 3) + ) + + row["account_head"]["tax_rate"] = rate + + return default_taxes + + def update_gst_settings(company): # Will only add default GST accounts if present input_account_names = ["Input Tax CGST", "Input Tax SGST", "Input Tax IGST"] diff --git a/india_compliance/gst_india/overrides/item_tax_template.py b/india_compliance/gst_india/overrides/item_tax_template.py new file mode 100644 index 000000000..37d55cd33 --- /dev/null +++ b/india_compliance/gst_india/overrides/item_tax_template.py @@ -0,0 +1,63 @@ +import frappe +from frappe import _ +from frappe.utils import rounded + +from india_compliance.gst_india.overrides.transaction import get_valid_accounts + + +def validate(doc, method=None): + validate_zero_tax_options(doc) + validate_tax_rates(doc) + + +def validate_zero_tax_options(doc): + if doc.gst_treatment != "Taxable": + doc.gst_rate = 0 + return + + if doc.gst_rate == 0: + frappe.throw( + _("GST Rate cannot be zero for Taxable GST Treatment"), + title=_("Invalid GST Rate"), + ) + + +def validate_tax_rates(doc): + if doc.gst_rate < 0 or doc.gst_rate > 100: + frappe.throw( + _("GST Rate should be between 0 and 100"), title=_("Invalid GST Rate") + ) + + if not doc.taxes: + return + + __, intra_state_accounts, inter_state_accounts = get_valid_accounts( + doc.company, for_sales=True, for_purchase=True, throw=False + ) + + if not intra_state_accounts and not inter_state_accounts: + return + + invalid_tax_rates = {} + for row in doc.taxes: + # check intra state + if row.tax_type in intra_state_accounts and doc.gst_rate != row.tax_rate * 2: + invalid_tax_rates[row.idx] = doc.gst_rate / 2 + + # check inter state + elif row.tax_type in inter_state_accounts and doc.gst_rate != row.tax_rate: + invalid_tax_rates[row.idx] = doc.gst_rate + + if not invalid_tax_rates: + return + + # throw + message = ( + "Plese make sure account tax rates are in sync with GST rate mentioned." + " Following rows have inconsistant tax rates:

" + ) + + for idx, tax_rate in invalid_tax_rates.items(): + message += f"Row #{idx} - should be {rounded(tax_rate, 2)}%
" + + frappe.throw(_(message), title=_("Invalid Tax Rates")) diff --git a/india_compliance/gst_india/overrides/purchase_invoice.py b/india_compliance/gst_india/overrides/purchase_invoice.py index d85ae04fd..62ef22905 100644 --- a/india_compliance/gst_india/overrides/purchase_invoice.py +++ b/india_compliance/gst_india/overrides/purchase_invoice.py @@ -65,7 +65,7 @@ def is_b2b_invoice(doc): or doc.gst_category in ["Registered Composition", "Unregistered", "Overseas"] or doc.supplier_gstin == doc.company_gstin or doc.is_opening == "Yes" - or any(row for row in doc.items if row.is_non_gst == 1) + or any(row for row in doc.items if row.gst_treatment == "Non-GST") ) diff --git a/india_compliance/gst_india/overrides/test_company.py b/india_compliance/gst_india/overrides/test_company.py index 72a5e04cf..c2fe84047 100644 --- a/india_compliance/gst_india/overrides/test_company.py +++ b/india_compliance/gst_india/overrides/test_company.py @@ -1,6 +1,8 @@ import frappe from frappe.tests.utils import FrappeTestCase +from india_compliance.gst_india.overrides.company import get_tax_defaults + class TestCompanyFixtures(FrappeTestCase): @classmethod @@ -31,3 +33,18 @@ def tearDownClass(cls): def test_tax_defaults_setup(self): # Check for tax category creations. self.assertTrue(frappe.db.exists("Tax Category", "Reverse Charge In-State")) + + def test_get_tax_defaults(self): + gst_rate = 12 + default_taxes = get_tax_defaults(gst_rate) + + for template_type in ("sales_tax_templates", "purchase_tax_templates"): + template = default_taxes["chart_of_accounts"]["*"][template_type] + for tax in template: + for row in tax.get("taxes"): + expected_rate = ( + gst_rate + if "IGST" in row["account_head"]["account_name"] + else gst_rate / 2 + ) + self.assertEqual(row["account_head"]["tax_rate"], expected_rate) diff --git a/india_compliance/gst_india/overrides/test_item_tax_template.py b/india_compliance/gst_india/overrides/test_item_tax_template.py new file mode 100644 index 000000000..5bab7593c --- /dev/null +++ b/india_compliance/gst_india/overrides/test_item_tax_template.py @@ -0,0 +1,109 @@ +import re + +import frappe +from frappe.tests.utils import FrappeTestCase + +from india_compliance.gst_india.overrides.transaction import get_valid_accounts + +# Creation of Item tax template for indian and foreign company +# Validation of GST Rate + + +class TestTransaction(FrappeTestCase): + def test_item_tax_template_for_foreign_company(self): + doc = create_item_tax_template( + company="_Test Foreign Company", gst_rate=0, gst_treatment="Exempt" + ) + self.assertTrue(doc.gst_rate == 0) + self.assertTrue(doc.gst_treatment == "Exempt") + + def test_item_tax_template_for_indian_company(self): + doc = create_item_tax_template() + self.assertTrue(doc.gst_rate == 18) + self.assertTrue(doc.gst_treatment == "Taxable") + + def test_validate_zero_tax_options(self): + doc = create_item_tax_template(gst_rate=0, do_not_save=True) + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile(r"^(GST Rate cannot be zero for.*)$"), + doc.insert, + ) + + def test_validate_tax_rates(self): + doc = create_item_tax_template(do_not_save=True) + doc.gst_rate = 110 + + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile(r"^(GST Rate should be between 0 and 100)$"), + doc.insert, + ) + + doc.gst_rate = 18 + doc.taxes[1].tax_rate = 2 + + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile(r"^(Plese make sure account tax rates.*)$"), + doc.save, + ) + + +def create_item_tax_template(**data): + doc = frappe.new_doc("Item Tax Template") + gst_rate = data.get("gst_rate") if data.get("gst_rate") is not None else 18 + doc.update( + { + "company": data.get("company") or "_Test Indian Registered Company", + "title": frappe.generate_hash("", 10), + "gst_treatment": data.get("gst_treatment") or "Taxable", + "gst_rate": gst_rate, + } + ) + + if data.get("taxes"): + doc.extend("taxes", data.get("taxes")) + + return save_item_tax_template(doc, data) + + __, intra_state_accounts, inter_state_accounts = get_valid_accounts( + doc.company, for_sales=True, for_purchase=True, throw=False + ) + + if not intra_state_accounts and not inter_state_accounts: + intra_state_accounts = frappe.get_all( + "Account", + filters={ + "company": doc.company, + "account_type": "Tax", + }, + pluck="name", + ) + + for account in intra_state_accounts: + doc.append( + "taxes", + { + "tax_type": account, + "tax_rate": gst_rate / 2, + }, + ) + + for account in inter_state_accounts: + doc.append( + "taxes", + { + "tax_type": account, + "tax_rate": gst_rate, + }, + ) + + return save_item_tax_template(doc, data) + + +def save_item_tax_template(doc, data): + if data.get("do_not_save"): + return doc + + return doc.insert() diff --git a/india_compliance/gst_india/overrides/test_transaction.py b/india_compliance/gst_india/overrides/test_transaction.py index d169fb862..cb48f7320 100644 --- a/india_compliance/gst_india/overrides/test_transaction.py +++ b/india_compliance/gst_india/overrides/test_transaction.py @@ -3,12 +3,14 @@ from parameterized import parameterized_class import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import today from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return +from erpnext.accounts.party import _get_party_details from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice from india_compliance.gst_india.constants import SALES_DOCTYPES -from india_compliance.gst_india.overrides.transaction import DOCTYPES_WITH_TAXABLE_VALUE +from india_compliance.gst_india.overrides.transaction import DOCTYPES_WITH_GST_DETAIL from india_compliance.gst_india.utils.tests import ( _append_taxes, append_item, @@ -36,6 +38,7 @@ class TestTransaction(FrappeTestCase): def setUpClass(cls): frappe.db.savepoint("before_test_transaction") cls.is_sales_doctype = cls.doctype in SALES_DOCTYPES + create_cess_accounts() @classmethod def tearDownClass(cls): @@ -301,7 +304,7 @@ def test_gst_category_without_gstin(self): frappe.db.set_value("Address", address, "gstin", gstin) def test_taxable_value_with_charges(self): - if self.doctype not in DOCTYPES_WITH_TAXABLE_VALUE: + if self.doctype not in DOCTYPES_WITH_GST_DETAIL: return doc = create_transaction(**self.transaction_details, do_not_save=True) @@ -327,7 +330,7 @@ def test_taxable_value_with_charges(self): self.assertDocumentEqual({"taxable_value": 120}, doc.items[0]) # 100 + 20 def test_taxable_value_with_charges_after_tax(self): - if self.doctype not in DOCTYPES_WITH_TAXABLE_VALUE: + if self.doctype not in DOCTYPES_WITH_GST_DETAIL: return doc = create_transaction( @@ -469,6 +472,105 @@ def test_purchase_from_unregistered_supplier(self): doc.insert, ) + def test_invalid_charge_type_as_actual(self): + doc = create_transaction(**self.transaction_details, do_not_save=True) + _append_taxes(doc, ["CGST", "SGST"], charge_type="Actual", tax_amount=9) + + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile( + r"^(.*Charge Type is set to Actual. However, this would not compute item taxes.*)$" + ), + doc.save, + ) + + def test_invalid_charge_type_for_cess_non_advol(self): + doc = create_transaction(**self.transaction_details, do_not_save=True) + _append_taxes(doc, ["CGST", "SGST"], charge_type="On Item Quantity") + + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile(r"^(.*as it is not a Cess Non Advol Account.*)$"), + doc.save, + ) + + doc = create_transaction(**self.transaction_details, do_not_save=True) + _append_taxes(doc, ["CGST", "SGST", "Cess Non Advol"]) + + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile(r"^(.*as it is a Cess Non Advol Account.*)$"), + doc.save, + ) + + def test_gst_details_set_correctly(self): + doc = create_transaction( + **self.transaction_details, rate=200, is_in_state=True, do_not_save=True + ) + _append_taxes(doc, "Cess Non Advol", charge_type="On Item Quantity", rate=20) + doc.insert() + self.assertDocumentEqual( + { + "gst_treatment": "Taxable", + "igst_rate": 0, + "cgst_rate": 9, + "sgst_rate": 9, + "cess_non_advol_rate": 20, + "igst_amount": 0, + "cgst_amount": 18, + "sgst_amount": 18, + "cess_non_advol_amount": 20, + }, + doc.items[0], + ) + + # test non gst treatment + doc = create_transaction( + **self.transaction_details, item_code="_Test Non GST Item" + ) + self.assertDocumentEqual( + {"gst_treatment": "Non-GST"}, + doc.items[0], + ) + + @change_settings("GST Settings", {"enable_overseas_transactions": 1}) + def test_gst_treatment_for_exports(self): + if not self.is_sales_doctype: + return + + doc = create_transaction( + **self.transaction_details, + is_in_state=True, + ) + self.assertEqual(doc.items[0].gst_treatment, "Taxable") + + # Update Customer after it's already set + doc_details = { + **self.transaction_details, + "customer": "_Test Foreign Customer", + "party_name": "_Test Foreign Customer", + } + doc = create_transaction(**doc_details, do_not_submit=True) + self.assertEqual(doc.items[0].gst_treatment, "Zero-Rated") + + party_field = "party_name" if self.doctype == "Quotation" else "customer" + + customer = "_Test Registered Customer" + doc.update( + { + party_field: customer, + **_get_party_details( + party=customer, + company=doc.company, + posting_date=today(), + doctype=doc.doctype, + ), + } + ) + doc.selling_price_list = "Standard Selling" + doc.save() + self.assertEqual(doc.items[0].gst_treatment, "Taxable") + def test_purchase_with_different_place_of_supply(self): if self.is_sales_doctype: return @@ -649,3 +751,47 @@ def test_copy_e_waybill_fields_from_si_to_return(self): si_return = make_sales_return(si.name) self.assertEqual(si_return.vehicle_no, None) + + +def create_cess_accounts(): + input_cess_non_advol_account = create_tax_accounts("Input Tax Cess Non Advol") + output_cess_non_advol_account = create_tax_accounts("Output Tax Cess Non Advol") + input_cess_account = create_tax_accounts("Input Tax Cess") + output_cess_account = create_tax_accounts("Output Tax Cess") + + settings = frappe.get_doc("GST Settings") + for row in settings.gst_accounts: + if row.company != "_Test Indian Registered Company": + continue + + if row.account_type == "Input": + row.cess_account = input_cess_account.name + row.cess_non_advol_account = input_cess_non_advol_account.name + + if row.account_type == "Output": + row.cess_account = output_cess_account.name + row.cess_non_advol_account = output_cess_non_advol_account.name + + settings.save() + + +def create_tax_accounts(account_name): + defaults = { + "company": "_Test Indian Registered Company", + "doctype": "Account", + "account_type": "Tax", + "is_group": 0, + } + + if "Input" in account_name: + parent_account = "Tax Assets - _TIRC" + else: + parent_account = "Duties and Taxes - _TIRC" + + return frappe.get_doc( + { + "account_name": account_name, + "parent_account": parent_account, + **defaults, + } + ).save() diff --git a/india_compliance/gst_india/overrides/test_transaction_data.py b/india_compliance/gst_india/overrides/test_transaction_data.py index 2f0ba5b64..bafc3de3c 100644 --- a/india_compliance/gst_india/overrides/test_transaction_data.py +++ b/india_compliance/gst_india/overrides/test_transaction_data.py @@ -152,6 +152,8 @@ def test_set_transaction_details(self): "party_name": "_Test Registered Customer", "date": format_date(frappe.utils.today(), "dd/mm/yyyy"), "total": 100.0, + "total_taxable_value": 100.0, + "total_non_taxable_value": 0.0, "rounding_adjustment": 0.0, "grand_total": 100.0, "grand_total_in_foreign_currency": "", @@ -193,6 +195,8 @@ def test_set_transaction_details_with_other_charges(self): "party_name": "_Test Registered Customer", "date": format_date(frappe.utils.today(), "dd/mm/yyyy"), "total": 100.0, + "total_taxable_value": 100.0, + "total_non_taxable_value": 0.0, "rounding_adjustment": -0.18, "grand_total": 119.0, "grand_total_in_foreign_currency": "", @@ -255,6 +259,7 @@ def test_get_all_item_details(self): "cess_non_advol_rate": 0, "tax_rate": 0.0, "total_value": 100.0, + "gst_treatment": "Taxable", } ], ) @@ -287,6 +292,7 @@ def test_get_all_item_details(self): "cess_non_advol_rate": 0, "tax_rate": 18.0, "total_value": 236.0, + "gst_treatment": "Taxable", } ], ) diff --git a/india_compliance/gst_india/overrides/transaction.py b/india_compliance/gst_india/overrides/transaction.py index 2069b27e5..5f95e865e 100644 --- a/india_compliance/gst_india/overrides/transaction.py +++ b/india_compliance/gst_india/overrides/transaction.py @@ -10,7 +10,11 @@ get_itemised_taxable_amount, ) -from india_compliance.gst_india.constants import SALES_DOCTYPES, STATE_NUMBERS +from india_compliance.gst_india.constants import ( + GST_TAX_TYPES, + SALES_DOCTYPES, + STATE_NUMBERS, +) from india_compliance.gst_india.constants.custom_fields import E_WAYBILL_INV_FIELDS from india_compliance.gst_india.doctype.gstin.gstin import ( _validate_gstin_info, @@ -18,6 +22,7 @@ ) from india_compliance.gst_india.utils import ( get_all_gst_accounts, + get_gst_accounts_by_tax_type, get_gst_accounts_by_type, get_hsn_settings, get_place_of_supply, @@ -30,9 +35,13 @@ get_tax_withholding_accounts, ) -DOCTYPES_WITH_TAXABLE_VALUE = { +DOCTYPES_WITH_GST_DETAIL = { + "Supplier Quotation", + "Purchase Order", "Purchase Receipt", "Purchase Invoice", + "Quotation", + "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice", @@ -40,7 +49,7 @@ def update_taxable_values(doc, valid_accounts): - if doc.doctype not in DOCTYPES_WITH_TAXABLE_VALUE: + if doc.doctype not in DOCTYPES_WITH_GST_DETAIL: return total_charges = 0 @@ -119,7 +128,7 @@ def get_tds_amount(doc): def is_indian_registered_company(doc): - if not doc.company_gstin: + if not doc.get("company_gstin"): country, gst_category = frappe.get_cached_value( "Company", doc.company, ("country", "gst_category") ) @@ -152,24 +161,34 @@ def validate_mandatory_fields(doc, fields, error_message=None): ) -def get_valid_accounts(company, is_sales_transaction=False): +@frappe.whitelist() +def get_valid_gst_accounts(company): + frappe.has_permission("Item Tax Template", "read", throw=True) + return get_valid_accounts(company, for_sales=True, for_purchase=True, throw=False) + + +def get_valid_accounts(company, *, for_sales=False, for_purchase=False, throw=True): all_valid_accounts = [] intra_state_accounts = [] inter_state_accounts = [] - def add_to_valid_accounts(account_type): - accounts = get_gst_accounts_by_type(company, account_type) + account_types = [] + if for_sales: + account_types.append("Output") + + if for_purchase: + account_types.extend(["Input", "Reverse Charge"]) + + for account_type in account_types: + accounts = get_gst_accounts_by_type(company, account_type, throw=throw) + if not accounts: + continue + all_valid_accounts.extend(accounts.values()) intra_state_accounts.append(accounts.cgst_account) intra_state_accounts.append(accounts.sgst_account) inter_state_accounts.append(accounts.igst_account) - if is_sales_transaction: - add_to_valid_accounts("Output") - else: - add_to_valid_accounts("Input") - add_to_valid_accounts("Reverse Charge") - return all_valid_accounts, intra_state_accounts, inter_state_accounts @@ -182,7 +201,6 @@ def validate_gst_accounts(doc, is_sales_transaction=False): - SEZ / Inter-State supplies should not have CGST or SGST account - Intra-State supplies should not have IGST account """ - if not doc.taxes: return @@ -211,7 +229,12 @@ def _throw(message, title=None): frappe.throw(message, title=title or _("Invalid GST Account")) all_valid_accounts, intra_state_accounts, inter_state_accounts = get_valid_accounts( - doc.company, is_sales_transaction + doc.company, + for_sales=is_sales_transaction, + for_purchase=not is_sales_transaction, + ) + cess_non_advol_accounts = get_gst_accounts_by_tax_type( + doc.company, "cess_non_advol" ) # Company GSTIN = Party GSTIN @@ -231,7 +254,6 @@ def _throw(message, title=None): ) # Sales / Purchase Validations - if is_sales_transaction: if is_export_without_payment_of_gst(doc) and ( idx := _get_matched_idx(rows_to_validate, all_valid_accounts) @@ -290,6 +312,18 @@ def _throw(message, title=None): for row in rows_to_validate: account_head = row.account_head + if row.charge_type == "Actual": + item_tax_detail = frappe.parse_json(row.item_wise_tax_detail) + for tax_rate, tax_amount in item_tax_detail.values(): + if tax_amount and not tax_rate: + _throw( + _( + "Tax Row #{0}: Charge Type is set to Actual. However, this would" + " not compute item taxes, and your further reporting will be affected." + ).format(row.idx), + title=_("Invalid Charge Type"), + ) + if account_head not in all_valid_accounts: _throw( _("{0} is not a valid GST account for this transaction").format( @@ -326,8 +360,32 @@ def _throw(message, title=None): if row.charge_type == "On Previous Row Total": previous_row_references.add(row.row_id) + if ( + row.charge_type == "On Item Quantity" + and account_head not in cess_non_advol_accounts + ): + _throw( + _( + "Row #{0}: Charge Type cannot be On Item Quantity" + " as it is not a Cess Non Advol Account" + ).format(row.idx), + title=_("Invalid Charge Type"), + ) + + if ( + row.charge_type != "On Item Quantity" + and account_head in cess_non_advol_accounts + ): + _throw( + _( + "Row #{0}: Charge Type must be On Item Quantity" + " as it is a Cess Non Advol Account" + ).format(row.idx), + title=_("Invalid Charge Type"), + ) + + used_accounts = set(row.account_head for row in rows_to_validate) if not is_inter_state: - used_accounts = set(row.account_head for row in rows_to_validate) if used_accounts and not set(intra_state_accounts[:2]).issubset(used_accounts): _throw( _( @@ -346,6 +404,22 @@ def _throw(message, title=None): title=_("Invalid Reference Row"), ) + for row in doc.get("items") or []: + if not row.item_tax_template: + continue + + for account in used_accounts: + if account in row.item_tax_rate: + continue + + frappe.msgprint( + _( + "Item Row #{0}: GST Account {1} is missing in Item Tax Template {2}" + ).format(row.idx, bold(account), bold(row.item_tax_template)), + title=_("Invalid Item Tax Template"), + indicator="orange", + ) + return all_valid_accounts @@ -376,7 +450,7 @@ def validate_items(doc): for row in doc.items: # Collect data to validate that non-GST items are not used with GST items - if row.is_non_gst: + if row.gst_treatment == "Non-GST": non_gst_items.append(row.idx) continue @@ -875,7 +949,231 @@ def is_export_without_payment_of_gst(doc): return is_overseas_doc(doc) and not doc.is_export_with_gst +class ItemGSTDetails: + def get(self, docs, doctype, company): + """ + Return Item GST Details for a list of documents + """ + self.set_gst_accounts(doctype, company) + response = frappe._dict() + for doc in docs: + self.doc = doc + if not doc.get("items") or not doc.get("taxes"): + continue + + self.update_item_count() + self.set_item_wise_tax_details() + self.set_tax_amount_precisions(doctype) + + for item in doc.get("items"): + response.setdefault(item.name, frappe._dict()).update( + self.get_item_tax_detail(item) + ) + + return response + + def update(self, doc): + """ + Update Item GST Details for a single document + """ + self.doc = doc + if not self.doc.get("items") or not self.doc.get("taxes"): + return + + self.set_gst_accounts(doc.doctype, doc.company) + self.update_item_count() + self.set_item_wise_tax_details() + self.set_tax_amount_precisions(doc.doctype) + self.update_item_tax_details() + + def set_gst_accounts(self, doctype, company): + if doctype in SALES_DOCTYPES: + account_type = "Output" + else: + account_type = "Input" + + gst_account_map = get_gst_accounts_by_type(company, account_type, throw=False) + self.gst_account_map = {v: k for k, v in gst_account_map.items()} + + def update_item_count(self): + self.item_count = frappe._dict() + for item in self.doc.get("items"): + key = item.item_code or item.item_name + self.item_count.setdefault(key, 0) + self.item_count[key] += 1 + + def set_item_wise_tax_details(self): + """ + Item Tax Details complied + Example: + { + "Item Code 1": { + "count": 2, + "cgst_rate": 9, + "cgst_amount": 18, + "sgst_rate": 9, + "sgst_amount": 18, + ... + }, + ... + } + + Possible Exceptions Handled: + - There could be more than one row for same account + - Item count added to handle rounding errors + """ + tax_details = frappe._dict() + item_defaults = frappe._dict(count=0) + + for row in GST_TAX_TYPES: + item_defaults.update({f"{row}_rate": 0, f"{row}_amount": 0}) + + for row in self.doc.taxes: + if ( + not row.tax_amount + or not row.item_wise_tax_detail + or row.account_head not in self.gst_account_map + ): + continue + + account_type = self.gst_account_map[row.account_head] + tax = account_type[:-8] + + old = frappe.parse_json(row.item_wise_tax_detail) + + # update item taxes + for item_name in set(old.keys()): + item_taxes = tax_details.setdefault(item_name, item_defaults.copy()) + + item_taxes["count"] = self.item_count[item_name] + + tax_rate, tax_amount = old[item_name] + + # cases when charge type == "Actual" + if tax_amount and not tax_rate: + continue + + item_taxes[f"{tax}_rate"] = tax_rate + item_taxes[f"{tax}_amount"] += tax_amount + + self.item_tax_details = tax_details + + def update_item_tax_details(self): + for item in self.doc.get("items"): + item.update(self.get_item_tax_detail(item)) + + def get_item_tax_detail(self, item): + """ + - get item_tax_detail as it is if + - only one row exists for same item + - it is the last item + + - If count is greater than 1, + - Manually calculate tax_amount for item + - Reduce item_tax_detail with + - tax_amount + - count + """ + item_key = item.item_code or item.item_name + item_tax_detail = self.item_tax_details.get(item_key) + if not item_tax_detail: + return {} + + if item_tax_detail.count == 1: + return item_tax_detail + + # Handle rounding errors + response = item_tax_detail.copy() + for tax in GST_TAX_TYPES: + if (tax_rate := item_tax_detail[f"{tax}_rate"]) == 0: + continue + + tax_amount_field = f"{tax}_amount" + precision = self.precision.get(tax_amount_field) + + multiplier = item.qty if tax == "cess_non_advol" else item.taxable_value + tax_amount = flt(tax_rate * multiplier, precision) + tax_amount = max(tax_amount, item_tax_detail[tax_amount_field]) + + item_tax_detail[tax_amount_field] -= tax_amount + item_tax_detail["count"] -= 1 + + response.update({tax_amount_field: tax_amount}) + + return response + + def set_tax_amount_precisions(self, doctype): + item_doctype = f"{doctype} Item" + meta = frappe.get_meta(item_doctype) + + self.precision = frappe._dict() + + for tax_type in GST_TAX_TYPES: + field = f"{tax_type}_amount" + if not meta.has_field(field): + continue + + precision = meta.get_field(field).precision + self.precision.update({field: precision}) + + +def set_gst_treatment_for_item(doc): + is_overseas = is_overseas_doc(doc) + is_sales_transaction = doc.doctype in SALES_DOCTYPES + + default_gst_treatment = "Taxable" + gst_accounts = get_all_gst_accounts(doc.company) + + for row in doc.taxes: + if row.charge_type in ("Actual", "On Item Quantity"): + continue + + if row.account_head not in gst_accounts: + continue + + if row.rate == 0: + default_gst_treatment = "Nil-Rated" + + break + + item_templates = set() + gst_treatments = set() + gst_treatment_map = {} + + for item in doc.items: + item_templates.add(item.item_tax_template) + gst_treatments.add(item.gst_treatment) + + if "Zero-Rated" in gst_treatments and not is_overseas: + # doc changed from overseas to local sale post validate + _gst_treatments = frappe.get_all( + "Item Tax Template", + filters={"name": ("in", item_templates)}, + fields=["name", "gst_treatment"], + ) + gst_treatment_map = {row.name: row.gst_treatment for row in _gst_treatments} + + for item in doc.items: + if not item.gst_treatment or not item.item_tax_template: + item.gst_treatment = default_gst_treatment + + if not is_sales_transaction: + continue + + if is_overseas: + # IGST sec 16(2) - ITC can be claimed for exempt supply + item.gst_treatment = "Zero-Rated" + + elif item.gst_treatment == "Zero-Rated": + item.gst_treatment = ( + gst_treatment_map.get(item.item_tax_template) or default_gst_treatment + ) + + def set_reverse_charge_as_per_gst_settings(doc): + if doc.doctype in SALES_DOCTYPES: + return + gst_settings = frappe.get_cached_value( "GST Settings", "GST Settings", @@ -980,6 +1278,13 @@ def before_validate(doc, method=None): set_reverse_charge_as_per_gst_settings(doc) +def update_gst_details(doc, method=None): + if doc.doctype in DOCTYPES_WITH_GST_DETAIL: + ItemGSTDetails().update(doc) + + set_gst_treatment_for_item(doc) + + def after_mapping(target_doc, method=None, source_doc=None): # Copy e-Waybill fields only from DN to SI if not source_doc or source_doc.doctype not in ( diff --git a/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py b/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py index 1d260843b..b90f4d78d 100644 --- a/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py +++ b/india_compliance/gst_india/report/e_invoice_summary/e_invoice_summary.py @@ -177,7 +177,7 @@ def get_cancelled_active_e_invoice_query(filters, sales_invoice, query): def e_invoice_conditions(e_invoice_applicability_date): sales_invoice = frappe.qb.DocType("Sales Invoice") - sub_query = validate_sales_invoice_item() + taxable_invoices = validate_sales_invoice_item() conditions = [] conditions.append(sales_invoice.posting_date >= e_invoice_applicability_date) @@ -190,7 +190,7 @@ def e_invoice_conditions(e_invoice_applicability_date): | (Coalesce(sales_invoice.billing_address_gstin, "") != "") ) ) - conditions.append(sales_invoice.name.notin(sub_query)) + conditions.append(sales_invoice.name.isin(taxable_invoices)) return reduce(lambda a, b: a & b, conditions) @@ -198,14 +198,15 @@ def e_invoice_conditions(e_invoice_applicability_date): def validate_sales_invoice_item(): sales_invoice_item = frappe.qb.DocType("Sales Invoice Item") - sub_query = ( + taxable_invoices = ( frappe.qb.from_(sales_invoice_item) .select(sales_invoice_item.parent) - .where(sales_invoice_item.is_non_gst == 1) .where(sales_invoice_item.parenttype == "Sales Invoice") + .where(sales_invoice_item.gst_treatment.isin(["Taxable", "Zero-Rated"])) .distinct() ) - return sub_query + + return taxable_invoices def get_columns(filters=None): 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 4593972ca..e2956b5b7 100644 --- a/india_compliance/gst_india/report/gstr_1/gstr_1.py +++ b/india_compliance/gst_india/report/gstr_1/gstr_1.py @@ -400,8 +400,8 @@ def get_invoice_items(self): items = frappe.db.sql( """ - select item_code, item_name, parent, taxable_value, item_tax_rate, is_nil_exempt, - is_non_gst from `tab%s Item` + select item_code, item_name, parent, taxable_value, item_tax_rate, gst_treatment + from `tab%s Item` where parent in (%s) """ % (self.doctype, ", ".join(["%s"] * len(self.invoices))), @@ -414,14 +414,16 @@ def get_invoice_items(self): self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, 0.0) self.invoice_items[d.parent][d.item_code] += d.get("taxable_value", 0) - if d.is_nil_exempt: - self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0]) - if d.item_tax_rate: - self.nil_exempt_non_gst[d.parent][0] += d.get("taxable_value", 0) - else: - self.nil_exempt_non_gst[d.parent][1] += d.get("taxable_value", 0) - elif d.is_non_gst: - self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0]) + is_nil_rated = d.gst_treatment == "Nil-Rated" + is_exempted = d.gst_treatment == "Exempted" + is_non_gst = d.gst_treatment == "Non-GST" + + self.nil_exempt_non_gst.setdefault(d.parent, [0.0, 0.0, 0.0]) + if is_nil_rated: + self.nil_exempt_non_gst[d.parent][0] += d.get("taxable_value", 0) + elif is_exempted: + self.nil_exempt_non_gst[d.parent][1] += d.get("taxable_value", 0) + elif is_non_gst: self.nil_exempt_non_gst[d.parent][2] += d.get("taxable_value", 0) def get_items_based_on_tax_rate(self): @@ -1165,7 +1167,7 @@ def get_query(self): .else_(0) .as_("same_gstin_billing"), self.sales_invoice.is_opening, - self.sales_invoice_item.is_non_gst, + self.sales_invoice_item.gst_treatment, ) .where(self.sales_invoice.company == self.filters.company) .where( @@ -1301,7 +1303,7 @@ def seperate_data_by_nature_of_document(self, data): nature_of_document["Excluded from Report (Same GSTIN Billing)"].append( doc ) - elif doc.is_non_gst: + elif doc.gst_treatment == "Non-GST": nature_of_document["Excluded from Report (Has Non GST Item)"].append( doc ) diff --git a/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py b/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py index ca317aa77..d1f7dab00 100644 --- a/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py +++ b/india_compliance/gst_india/report/gstr_3b_details/gstr_3b_details.py @@ -344,12 +344,12 @@ def get_data(self): taxable_value = invoice.taxable_value if ( - invoice.is_nil_exempt == 1 + invoice.gst_treatment in ["Nil-Rated", "Exempted"] or invoice.get("gst_category") == "Registered Composition" ): nature_of_supply = "Composition Scheme, Exempted, Nil Rated" - elif invoice.is_non_gst == 1: + elif invoice.gst_treatment == "Non-GST": nature_of_supply = "Non GST Supply" if supplier_state == place_of_supply: @@ -390,8 +390,7 @@ def get_inward_nil_exempt(self): purchase_invoice.place_of_supply, purchase_invoice.supplier_address, Sum(purchase_invoice_item.taxable_value).as_("taxable_value"), - purchase_invoice_item.is_nil_exempt, - purchase_invoice_item.is_non_gst, + purchase_invoice_item.gst_treatment, purchase_invoice.supplier_gstin, purchase_invoice.supplier_address, ) @@ -400,8 +399,7 @@ def get_inward_nil_exempt(self): & (purchase_invoice.is_opening == "No") & (purchase_invoice.name == purchase_invoice_item.parent) & ( - (purchase_invoice_item.is_nil_exempt == 1) - | (purchase_invoice_item.is_non_gst == 1) + (purchase_invoice_item.gst_treatment != "Taxable") | (purchase_invoice.gst_category == "Registered Composition") ) & (purchase_invoice.posting_date[self.from_date : self.to_date]) diff --git a/india_compliance/gst_india/setup/__init__.py b/india_compliance/gst_india/setup/__init__.py index 332279c16..bedf0b5a2 100644 --- a/india_compliance/gst_india/setup/__init__.py +++ b/india_compliance/gst_india/setup/__init__.py @@ -21,7 +21,7 @@ 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")) +ITEM_VARIANT_FIELDNAMES = frozenset(("gst_hsn_code",)) def after_install(): diff --git a/india_compliance/gst_india/utils/__init__.py b/india_compliance/gst_india/utils/__init__.py index 509f92b17..108de7cc9 100644 --- a/india_compliance/gst_india/utils/__init__.py +++ b/india_compliance/gst_india/utils/__init__.py @@ -471,6 +471,48 @@ def get_gst_accounts_by_type(company, account_type, throw=True): ) +def get_gst_accounts_by_tax_type(company, tax_type, throw=True): + """ + :param company: Company to get GST Accounts for + :param tax_type: Tax Type to get GST Accounts for eg: "cgst" + + Returns a list of accounts: + """ + if not company: + frappe.throw(_("Please set Company first")) + + tax_type = tax_type.lower() + field = f"{tax_type}_account" + + if field not in GST_ACCOUNT_FIELDS: + frappe.throw(_("Invalid Tax Type")) + + settings = frappe.get_cached_doc("GST Settings", "GST Settings") + accounts_list = [] + + has_account_settings = False + for row in settings.gst_accounts: + if row.company != company: + continue + + has_account_settings = True + if gst_account := row.get(field): + accounts_list.append(gst_account) + + if accounts_list: + return accounts_list + + if has_account_settings or not throw: + return accounts_list + + frappe.throw( + _( + "Could not retrieve GST Accounts of type {0} from GST Settings for" + " Company {1}" + ).format(frappe.bold(tax_type), frappe.bold(company)), + ) + + @frappe.whitelist() def get_all_gst_accounts(company): """ @@ -759,3 +801,8 @@ def tar_gz_bytes_to_data(tar_gz_bytes: bytes) -> str | None: break return data + + +@frappe.whitelist(methods=["POST"]) +def disable_item_tax_template_notification(): + frappe.defaults.clear_user_default("needs_item_tax_template_notification") diff --git a/india_compliance/gst_india/utils/e_invoice.py b/india_compliance/gst_india/utils/e_invoice.py index 71b08de00..25674ce3c 100644 --- a/india_compliance/gst_india/utils/e_invoice.py +++ b/india_compliance/gst_india/utils/e_invoice.py @@ -13,7 +13,7 @@ random_string, ) -from india_compliance.exceptions import GatewayTimeoutError +from india_compliance.exceptions import GatewayTimeoutError, GSPServerError from india_compliance.gst_india.api_classes.e_invoice import EInvoiceAPI from india_compliance.gst_india.constants import ( CURRENCY_CODES, @@ -43,10 +43,7 @@ _cancel_e_waybill, log_and_process_e_waybill_generation, ) -from india_compliance.gst_india.utils.transaction_data import ( - GSTTransactionData, - validate_non_gst_items, -) +from india_compliance.gst_india.utils.transaction_data import GSTTransactionData @frappe.whitelist() @@ -90,7 +87,7 @@ def log_error(): try: generate_e_invoice(docname, throw=False, force=force) - except GatewayTimeoutError: + except GSPServerError: frappe.db.set_value( "Sales Invoice", {"name": ("in", docnames), "irn": ("is", "not set")}, @@ -165,27 +162,9 @@ def generate_e_invoice(docname, throw=True, force=False): result = api.generate_irn(data) - except GatewayTimeoutError as e: - einvoice_status = "Failed" - - if settings.enable_retry_e_invoice_generation: - einvoice_status = "Auto-Retry" - settings.db_set( - "is_retry_e_invoice_generation_pending", 1, update_modified=False - ) - - doc.db_set({"einvoice_status": einvoice_status}, commit=True) - - frappe.msgprint( - _( - "Government services are currently slow, resulting in a Gateway Timeout error." - " We apologize for the inconvenience caused. Your e-invoice generation will be automatically retried every 5 minutes." - ), - _("Warning"), - indicator="yellow", - ) - - raise e + except GSPServerError as e: + handle_server_errors(settings, doc, e) + return except frappe.ValidationError as e: doc.db_set({"einvoice_status": "Failed"}) @@ -370,7 +349,8 @@ def _throw(error): ) ) - if not validate_non_gst_items(doc, throw=throw): + if not validate_taxable_item(doc, throw=throw): + # e-Invoice not required for invoice wih all nill-rated/exempted items. return if not (doc.place_of_supply == "96-Other Countries" or doc.billing_address_gstin): @@ -408,6 +388,26 @@ def validate_hsn_codes_for_e_invoice(doc): ) +def validate_taxable_item(doc, throw=True): + """ + Validates that the document contains at least one GST taxable item. + + If all items are Nil-Rated or Exempted and throw is True, it raises an exception. + Otherwise, it simply returns False. + + """ + # Check if there is at least one taxable item in the document + if any(item.gst_treatment in ("Taxable", "Zero-Rated") for item in doc.items): + return True + + if not throw: + return + + frappe.throw( + _("e-Invoice is not applicable for invoice with only Nil-Rated/Exempted items"), + ) + + def get_e_invoice_applicability_date(doc, settings=None, throw=True): if not settings: settings = frappe.get_cached_doc("GST Settings") @@ -471,15 +471,63 @@ def get_e_invoice_info(doc): ) +def handle_server_errors(settings, doc, error): + error_message = "Government services are currently slow/down. We apologize for the inconvenience caused." + + error_message_title = { + GatewayTimeoutError: _("Gateway Timeout Error"), + GSPServerError: _("GSP/GST Server Down"), + } + + einvoice_status = "Failed" + + if settings.enable_retry_e_invoice_generation: + einvoice_status = "Auto-Retry" + settings.db_set( + "is_retry_e_invoice_generation_pending", 1, update_modified=False + ) + error_message += ( + " Your e-invoice generation will be automatically retried every 5 minutes." + ) + else: + error_message += " Please try again after some time." + + doc.db_set({"einvoice_status": einvoice_status}, commit=True) + + frappe.msgprint( + msg=_(error_message), + title=error_message_title.get(type(error)), + indicator="yellow", + ) + + class EInvoiceData(GSTTransactionData): def get_data(self): self.validate_transaction() self.set_transaction_details() self.set_item_list() + self.update_other_charges() self.set_transporter_details() self.set_party_address_details() return self.sanitize_data(self.get_invoice_data()) + def set_item_list(self): + self.item_list = [] + + for item_details in self.get_all_item_details(): + if item_details.get("gst_treatment") not in ("Taxable", "Zero-Rated"): + continue + + self.item_list.append(self.get_item_data(item_details)) + + def update_other_charges(self): + """ + Non Taxable Value should be added to other charges. + """ + self.transaction_details.other_charges += ( + self.transaction_details.total_non_taxable_value + ) + def validate_transaction(self): super().validate_transaction() validate_e_invoice_applicability(self.doc, self.settings) @@ -734,7 +782,7 @@ def get_invoice_data(self): }, "ItemList": self.item_list, "ValDtls": { - "AssVal": self.transaction_details.total, + "AssVal": self.transaction_details.total_taxable_value, "CgstVal": self.transaction_details.total_cgst_amount, "SgstVal": self.transaction_details.total_sgst_amount, "IgstVal": self.transaction_details.total_igst_amount, diff --git a/india_compliance/gst_india/utils/test_e_invoice.py b/india_compliance/gst_india/utils/test_e_invoice.py index ef35c9ba7..6c24d43b7 100644 --- a/india_compliance/gst_india/utils/test_e_invoice.py +++ b/india_compliance/gst_india/utils/test_e_invoice.py @@ -294,6 +294,65 @@ def test_generate_e_invoice_with_service_item(self): frappe.db.get_value("e-Waybill Log", {"reference_name": si.name}, "name") ) + @responses.activate + def test_generate_e_invoice_with_nil_exempted_item(self): + """Generate test e-Invoice for nil/exempted items Item""" + + test_data = self.e_invoice_test_data.get("nil_exempted_item") + si = create_sales_invoice( + **test_data.get("kwargs"), do_not_submit=True, is_in_state=True + ) + + append_item( + si, + frappe._dict( + rate=10, + item_tax_template="GST 12% - _TIRC", + uom="Nos", + gst_hsn_code="61149090", + gst_treatment="Taxable", + ), + ) + si.save() + si.submit() + + # 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_credit_note_e_invoice_with_goods_item(self): """Generate test e-Invoice for returned Sales Invoices""" @@ -557,6 +616,36 @@ def test_validate_e_invoice_applicability(self): ) si.irn = "" + + si.items = [] + append_item( + si, + frappe._dict( + item_code="_Test Nil Rated Item", + item_name="_Test Nil Rated Item", + gst_hsn_code="61149090", + gst_treatment="Nil-Rated", + ), + ) + self.assertRaisesRegex( + frappe.exceptions.ValidationError, + re.compile( + r"^(e-Invoice is not applicable for invoice with only Nil-Rated/Exempted items*)$" + ), + validate_e_invoice_applicability, + si, + ) + + append_item( + si, + frappe._dict( + rate=10, + item_tax_template="GST 12% - _TIRC", + uom="Nos", + gst_hsn_code="61149090", + gst_treatment="Taxable", + ), + ) frappe.db.set_single_value("GST Settings", "enable_e_invoice", 0) self.assertRaisesRegex( diff --git a/india_compliance/gst_india/utils/test_e_waybill.py b/india_compliance/gst_india/utils/test_e_waybill.py index 8269cfa8f..c5eacb47c 100644 --- a/india_compliance/gst_india/utils/test_e_waybill.py +++ b/india_compliance/gst_india/utils/test_e_waybill.py @@ -389,6 +389,7 @@ def test_get_all_item_details(self): "cess_non_advol_rate": 0, "tax_rate": 0.0, "total_value": 100.0, + "gst_treatment": "Taxable", } ], EWaybillData(si).get_all_item_details(), @@ -480,7 +481,7 @@ def test_validate_applicability(self): {"gst_transporter_id": "05AAACG2140A1ZL", "mode_of_transport": "Road"} ) - si.items[0].is_non_gst = 1 + si.items[0].gst_treatment = "Non-GST" self.assertRaisesRegex( frappe.exceptions.ValidationError, @@ -488,7 +489,7 @@ def test_validate_applicability(self): EWaybillData(si).validate_applicability, ) - si.items[0].is_non_gst = 0 + si.items[0].gst_treatment = "Taxable" si.update( { "company_gstin": "05AAACG2115R1ZN", diff --git a/india_compliance/gst_india/utils/tests.py b/india_compliance/gst_india/utils/tests.py index ea4d73d7a..25ea522a1 100644 --- a/india_compliance/gst_india/utils/tests.py +++ b/india_compliance/gst_india/utils/tests.py @@ -106,9 +106,8 @@ def append_item(transaction, data=None, company_abbr="_TIRC"): "uom": data.uom, "rate": data.rate or 100, "cost_center": f"Main - {company_abbr}", - "is_nil_exempt": data.is_nil_exempt, - "is_non_gst": data.is_non_gst, "item_tax_template": data.item_tax_template, + "gst_treatment": data.gst_treatment, "gst_hsn_code": data.gst_hsn_code, "warehouse": f"Stores - {company_abbr}", "expense_account": f"Cost of Goods Sold - {company_abbr}", @@ -123,6 +122,7 @@ def _append_taxes( rate=9, charge_type="On Net Total", row_id=None, + tax_amount=None, ): if isinstance(accounts, str): accounts = [accounts] @@ -145,6 +145,9 @@ def _append_taxes( "cost_center": f"Main - {company_abbr}", } + if tax_amount: + tax["tax_amount"] = tax_amount + if account.endswith("RCM"): tax["add_deduct_tax"] = "Deduct" diff --git a/india_compliance/gst_india/utils/transaction_data.py b/india_compliance/gst_india/utils/transaction_data.py index ce6f7b8f6..4633c1aaa 100644 --- a/india_compliance/gst_india/utils/transaction_data.py +++ b/india_compliance/gst_india/utils/transaction_data.py @@ -65,6 +65,15 @@ def set_transaction_details(self): else "base_rounded_total" ) + total = 0 + total_taxable_value = 0 + + for row in self.doc.items: + total += row.taxable_value + + if row.gst_treatment in ("Taxable", "Zero-Rated"): + total_taxable_value += row.taxable_value + self.transaction_details.update( { "company_name": self.sanitize_value(self.doc.company), @@ -75,8 +84,10 @@ def set_transaction_details(self): ) ), "date": format_date(self.doc.posting_date, self.DATE_FORMAT), - "total": abs( - self.rounded(sum(row.taxable_value for row in self.doc.items)) + "total": abs(self.rounded(total)), + "total_taxable_value": abs(self.rounded(total_taxable_value)), + "total_non_taxable_value": abs( + self.rounded(total - total_taxable_value) ), "rounding_adjustment": rounding_adjustment, "grand_total": abs(self.rounded(self.doc.get(grand_total_fieldname))), @@ -270,6 +281,7 @@ def get_all_item_details(self): row.item_name, regex=3, max_length=300 ), "uom": get_gst_uom(row.uom, self.settings), + "gst_treatment": row.gst_treatment, } ) self.update_item_details(item_details, row) @@ -581,7 +593,7 @@ def _throw(message, **format_args): def validate_non_gst_items(doc, throw=True): - if doc.items[0].is_non_gst: + if doc.items[0].gst_treatment == "Non-GST": if not throw: return diff --git a/india_compliance/hooks.py b/india_compliance/hooks.py index 7d297a2e0..56cb60a42 100644 --- a/india_compliance/hooks.py +++ b/india_compliance/hooks.py @@ -37,6 +37,7 @@ "gst_india/client_scripts/delivery_note.js", ], "Item": "gst_india/client_scripts/item.js", + "Item Tax Template": "gst_india/client_scripts/item_tax_template.js", "Expense Claim": [ "gst_india/client_scripts/journal_entry.js", "gst_india/client_scripts/expense_claim.js", @@ -94,6 +95,8 @@ "india_compliance.gst_india.overrides.transaction.ignore_logs_on_trash" ), "onload": "india_compliance.gst_india.overrides.delivery_note.onload", + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", "validate": ( "india_compliance.gst_india.overrides.transaction.validate_transaction" ), @@ -106,6 +109,9 @@ "validate": "india_compliance.gst_india.overrides.gl_entry.validate", }, "Item": {"validate": "india_compliance.gst_india.overrides.item.validate"}, + "Item Tax Template": { + "validate": "india_compliance.gst_india.overrides.item_tax_template.validate" + }, "Journal Entry": { "validate": "india_compliance.gst_india.overrides.journal_entry.validate", }, @@ -121,7 +127,11 @@ "before_validate": ( "india_compliance.gst_india.overrides.transaction.before_validate" ), - "before_submit": "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": [ + "india_compliance.gst_india.overrides.transaction.update_gst_details", + "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", + ], "before_gl_preview": "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", "before_sl_preview": "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", "after_mapping": "india_compliance.gst_india.overrides.transaction.after_mapping", @@ -133,6 +143,8 @@ "before_validate": ( "india_compliance.gst_india.overrides.transaction.before_validate" ), + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", }, "Purchase Receipt": { "validate": ( @@ -141,7 +153,11 @@ "before_validate": ( "india_compliance.gst_india.overrides.transaction.before_validate" ), - "before_submit": "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": [ + "india_compliance.gst_india.overrides.transaction.update_gst_details", + "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", + ], "before_gl_preview": "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", "before_sl_preview": "india_compliance.gst_india.overrides.ineligible_itc.update_valuation_rate", }, @@ -151,6 +167,8 @@ ), "onload": "india_compliance.gst_india.overrides.sales_invoice.onload", "validate": "india_compliance.gst_india.overrides.sales_invoice.validate", + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", "on_submit": "india_compliance.gst_india.overrides.sales_invoice.on_submit", "on_update_after_submit": ( "india_compliance.gst_india.overrides.sales_invoice.on_update_after_submit" @@ -162,6 +180,8 @@ "validate": ( "india_compliance.gst_india.overrides.transaction.validate_transaction" ), + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", }, "Supplier": { "validate": [ @@ -185,16 +205,25 @@ "validate": ( "india_compliance.gst_india.overrides.transaction.validate_transaction" ), + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", }, "Quotation": { "validate": ( "india_compliance.gst_india.overrides.transaction.validate_transaction" ), + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", }, "Supplier Quotation": { + "before_validate": ( + "india_compliance.gst_india.overrides.transaction.before_validate" + ), "validate": ( "india_compliance.gst_india.overrides.transaction.validate_transaction" ), + "before_save": "india_compliance.gst_india.overrides.transaction.update_gst_details", + "before_submit": "india_compliance.gst_india.overrides.transaction.update_gst_details", }, "Accounts Settings": { "validate": "india_compliance.audit_trail.overrides.accounts_settings.validate" diff --git a/india_compliance/install.py b/india_compliance/install.py index f42686b0d..e8dcdcd71 100644 --- a/india_compliance/install.py +++ b/india_compliance/install.py @@ -39,6 +39,7 @@ "update_company_gstin", "update_payment_entry_fields", "update_itc_classification_field", + "improve_item_tax_template", "update_vehicle_no_field_in_purchase_receipt", ) diff --git a/india_compliance/patches.txt b/india_compliance/patches.txt index 1d52624b9..1f2fd77b9 100644 --- a/india_compliance/patches.txt +++ b/india_compliance/patches.txt @@ -3,8 +3,8 @@ execute:import frappe; frappe.delete_doc_if_exists("DocType", "GSTIN") [post_model_sync] india_compliance.patches.v14.set_default_for_overridden_accounts_setting -execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #38 -execute:from india_compliance.gst_india.setup import create_property_setters; create_property_setters() #5 +execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #40 +execute:from india_compliance.gst_india.setup import create_property_setters; create_property_setters() #6 india_compliance.patches.post_install.remove_old_fields india_compliance.patches.post_install.update_company_gstin india_compliance.patches.post_install.update_custom_role_for_e_invoice_summary @@ -34,4 +34,5 @@ india_compliance.patches.post_install.update_company_fixtures #1 india_compliance.patches.post_install.update_itc_classification_field india_compliance.patches.v14.update_purchase_reco_email_template india_compliance.patches.v14.delete_purchase_receipt_standard_custom_fields -india_compliance.patches.post_install.update_vehicle_no_field_in_purchase_receipt \ No newline at end of file +india_compliance.patches.post_install.improve_item_tax_template +india_compliance.patches.post_install.update_vehicle_no_field_in_purchase_receipt diff --git a/india_compliance/patches/post_install/improve_item_tax_template.py b/india_compliance/patches/post_install/improve_item_tax_template.py new file mode 100644 index 000000000..60d96caf1 --- /dev/null +++ b/india_compliance/patches/post_install/improve_item_tax_template.py @@ -0,0 +1,440 @@ +import click + +import frappe +import frappe.defaults +from frappe.query_builder import Case +from frappe.query_builder.functions import IfNull +from frappe.utils import get_datetime, random_string +from frappe.utils.user import get_users_with_role + +from india_compliance.gst_india.constants import GST_TAX_TYPES, SALES_DOCTYPES +from india_compliance.gst_india.overrides.transaction import ( + ItemGSTDetails, + get_valid_accounts, +) +from india_compliance.gst_india.utils import ( + get_all_gst_accounts, + get_gst_accounts_by_type, +) +from india_compliance.patches.post_install.update_e_invoice_fields_and_logs import ( + delete_custom_fields, +) + +TRANSACTION_DOCTYPES = ( + "Material Request Item", + "Supplier Quotation Item", + "Purchase Order Item", + "Purchase Receipt Item", + "Purchase Invoice Item", + "Quotation Item", + "Sales Order Item", + "Delivery Note Item", + "Sales Invoice Item", + "POS Invoice Item", +) + +FIELDS_TO_DELETE = { + "Item": [ + {"fieldname": "is_nil_exempt"}, + {"fieldname": "is_non_gst"}, + ], + TRANSACTION_DOCTYPES: [ + {"fieldname": "is_nil_exempt"}, + {"fieldname": "is_non_gst"}, + ], +} + +NEW_TEMPLATES = { + "is_nil_rated": "Nil-Rated", + "is_exempted": "Exempted", + "is_non_gst": "Non-GST", +} + + +def execute(): + companies = get_indian_companies() + templates = create_or_update_item_tax_templates(companies) + update_items_with_templates(templates) + + update_gst_treatment_for_transactions() + update_gst_details_for_transactions(companies) + + remove_old_item_variant_settings() + delete_custom_fields(FIELDS_TO_DELETE) + + +def get_indian_companies(): + return frappe.get_all("Company", filters={"country": "India"}, pluck="name") + + +def create_or_update_item_tax_templates(companies): + if not companies: + return {} + + DOCTYPE = "Item Tax Template" + item_templates = frappe.get_all(DOCTYPE, pluck="name") + companies_with_templates = set() + companies_gst_accounts = frappe._dict() + + # update tax rates + for template_name in item_templates: + doc = frappe.get_doc(DOCTYPE, template_name) + if doc.company not in companies: + continue + + gst_accounts = get_all_gst_accounts(doc.company) + if not gst_accounts or not doc.taxes: + continue + + gst_rates = set() + companies_with_templates.add(doc.company) + companies_gst_accounts[doc.company] = gst_accounts + _, intra_state_accounts, inter_state_accounts = get_valid_accounts( + doc.company, for_sales=True, for_purchase=True + ) + + for row in doc.taxes: + if row.tax_type in intra_state_accounts: + gst_rates.add(row.tax_rate * 2) + elif row.tax_type in inter_state_accounts: + gst_rates.add(row.tax_rate) + + if len(gst_rates) != 1: + continue + + doc.gst_rate = next(iter(gst_rates)) + + if doc.gst_treatment != "Taxable": + # Cases where patch is run again + continue + + elif doc.gst_rate > 0: + doc.gst_treatment = "Taxable" + + elif doc.gst_rate == 0: + doc.gst_treatment = "Nil-Rated" + + doc.save() + + # create new templates for nil rated, exempted, non gst + templates = {} + for company in companies_with_templates: + gst_accounts = [ + {"tax_type": account, "tax_rate": 0} + for account in companies_gst_accounts[company] + ] + + for new_template in NEW_TEMPLATES.values(): + if template_name := frappe.db.get_value( + DOCTYPE, {"company": company, "gst_treatment": new_template} + ): + templates.setdefault(new_template, []).append(template_name) + continue + + doc = frappe.get_doc( + { + "doctype": DOCTYPE, + "title": new_template, + "company": company, + "gst_treatment": new_template, + "tax_rate": 0, + } + ) + + doc.extend("taxes", gst_accounts) + doc.insert(ignore_if_duplicate=True) + templates.setdefault(new_template, []).append(doc.name) + + return templates + + +def update_items_with_templates(templates): + "Disclaimer: No specific way to differentate between nil and exempted. Hence all transactions are updated to nil" + + if not templates: + return + + table = frappe.qb.DocType("Item") + item_list = ( + frappe.qb.from_(table) + .select("name", "is_nil_exempt", "is_non_gst") + .where((table.is_nil_exempt == 1) | (table.is_non_gst == 1)) + .run(as_dict=True) + ) + + items = [item.name for item in item_list] + all_templates = [] + for category_templates in templates.values(): + all_templates.extend(category_templates) + + # Don't update for existing templates + item_templates = frappe.get_all( + "Item Tax", + fields=["parent as item", "item_tax_template"], + filters={ + "parenttype": "Item", + "parent": ["in", items], + "item_tax_template": ["in", all_templates], + }, + ) + + item_wise_templates = frappe._dict() + for item in item_templates: + item_wise_templates.setdefault(item.item, set()).add(item.item_tax_template) + + fields = ( + "name", + "parent", + "parentfield", + "parenttype", + "item_tax_template", + "owner", + "modified_by", + "creation", + "modified", + ) + + values = [] + time = get_datetime() + + def extend_tax_template(item, templates): + for template in templates: + if template in item_wise_templates.get(item.name, []): + continue + + values.append( + [ + random_string(10), + item.name, + "taxes", + "Item", + template, + "Administrator", + "Administrator", + time, + time, + ] + ) + + for item in item_list: + if item.is_nil_exempt: + extend_tax_template(item, templates["Nil-Rated"]) + continue + + if item.is_non_gst: + extend_tax_template(item, templates["Non-GST"]) + + frappe.db.bulk_insert("Item Tax", fields=fields, values=values) + + +def remove_old_item_variant_settings(): + item_variant = frappe.get_single("Item Variant Settings") + for field in reversed(item_variant.fields): + if field.field_name in ("is_nil_exempt", "is_non_gst"): + item_variant.fields.remove(field) + + item_variant.save() + + +###### Updating Transactions ############################################## +def update_gst_treatment_for_transactions(): + "Disclaimer: No specific way to differentate between nil and exempted. Hence all transactions are updated to nil" + + for item_doctype in TRANSACTION_DOCTYPES: + # GST Treatment is not required in Material Request Item + if item_doctype == "Material Request Item": + continue + + table = frappe.qb.DocType(item_doctype) + query = frappe.qb.update(table) + + ( + query.set( + table.gst_treatment, + Case() + .when(table.is_nil_exempt == 1, "Nil-Rated") + .when(table.is_non_gst == 1, "Non-GST") + .else_("Taxable"), + ) + .where(IfNull(table.gst_treatment, "") == "") + .run() + ) + + doctype = item_doctype.replace(" Item", "") + if doctype not in SALES_DOCTYPES: + continue + + doc = frappe.qb.DocType(doctype) + + ( + query.join(doc) + .on(doc.name == table.parent) + .set(table.gst_treatment, "Zero-Rated") + .where( + (doc.gst_category == "SEZ") + | ( + (doc.gst_category == "Overseas") + & (doc.place_of_supply == "96-Other Countries") + ) + ) + .run() + ) + + click.secho( + "Nil Rated items are differentiated from Exempted for GST (configrable from Item Tax Template).", + color="yellow", + ) + click.secho( + "All transactions that were marked as Nil or Exempt, are now marked as Nil Rated.", + color="red", + ) + + for user in get_users_with_role("Accounts Manager"): + frappe.defaults.set_user_default( + "needs_item_tax_template_notification", 1, user=user + ) + + +def update_gst_details_for_transactions(companies): + for company in companies: + gst_accounts = [] + for account_type in ["Input", "Output"]: + gst_accounts.extend( + get_gst_accounts_by_type(company, account_type).values() + ) + + if not gst_accounts: + continue + + for doctype in ("Sales Invoice", "Purchase Invoice"): + is_sales_doctype = doctype in SALES_DOCTYPES + docs = get_docs_with_gst_accounts(doctype, gst_accounts) + if not docs: + continue + + chunk_size = 5000 + total_docs = len(docs) + + for i in range(0, total_docs, chunk_size): + chunk = docs[i : i + chunk_size] + + taxes = get_taxes_for_docs(chunk, doctype, is_sales_doctype) + items = get_items_for_docs(chunk, doctype) + complied_docs = compile_docs(taxes, items) + + if not complied_docs: + continue + + gst_details = ItemGSTDetails().get( + complied_docs.values(), doctype, company + ) + + build_query_and_update_gst_details(gst_details, doctype) + + +def get_docs_with_gst_accounts(doctype, gst_accounts): + gl_entry = frappe.qb.DocType("GL Entry") + + return ( + frappe.qb.from_(gl_entry) + .select("voucher_no") + .where(gl_entry.voucher_type == doctype) + .where(gl_entry.account.isin(gst_accounts)) + .where(gl_entry.is_cancelled == 0) + .groupby("voucher_no") + .run(pluck=True) + ) + + +def get_taxes_for_docs(docs, doctype, is_sales_doctype): + taxes_doctype = ( + "Sales Taxes and Charges" if is_sales_doctype else "Purchase Taxes and Charges" + ) + taxes = frappe.qb.DocType(taxes_doctype) + return ( + frappe.qb.from_(taxes) + .select( + taxes.tax_amount, + taxes.account_head, + taxes.parent, + taxes.item_wise_tax_detail, + ) + .where(taxes.parenttype == doctype) + .where(taxes.parent.isin(docs)) + .run(as_dict=True) + ) + + +def get_items_for_docs(docs, doctype): + item_doctype = f"{doctype} Item" + item = frappe.qb.DocType(item_doctype) + return ( + frappe.qb.from_(item) + .select( + item.name, + item.parent, + item.item_code, + item.item_name, + item.qty, + item.taxable_value, + ) + .where(item.parenttype == doctype) + .where(item.parent.isin(docs)) + .run(as_dict=True) + ) + + +def compile_docs(taxes, items): + """ + Complie docs, so that each one could be accessed as if it's a single doc. + """ + response = frappe._dict() + + for tax in taxes: + doc = response.setdefault(tax.parent, frappe._dict({"taxes": [], "items": []})) + doc.get("taxes").append(tax) + + for item in items: + doc = response.setdefault(item.parent, frappe._dict({"taxes": [], "items": []})) + doc.get("items").append(item) + + return response + + +def build_query_and_update_gst_details(gst_details, doctype): + transaction_item = frappe.qb.DocType(f"{doctype} Item") + + update_query = frappe.qb.update(transaction_item) + + # Initialize CASE queries + conditions = frappe._dict() + conditions_available_for = set() + + for tax in GST_TAX_TYPES: + for field in (f"{tax}_rate", f"{tax}_amount"): + conditions[field] = Case() + + # Update item conditions (WHEN) + for item_name, row in gst_details.items(): + for field in conditions: + if not row[field]: + continue + + conditions[field] = conditions[field].when( + transaction_item.name == item_name, row[field] + ) + conditions_available_for.add(field) + + # Update queries + for field in conditions: + # Atleast one WHEN condition required for Case queries + if field not in conditions_available_for: + continue + + # ELSE + conditions[field] = conditions[field].else_(transaction_item[field]) + update_query = update_query.set(transaction_item[field], conditions[field]) + + update_query = update_query.where( + transaction_item.name.isin(list(gst_details.keys())) + ).run() diff --git a/india_compliance/public/js/india_compliance.bundle.js b/india_compliance/public/js/india_compliance.bundle.js index f4e34791c..e384acdca 100644 --- a/india_compliance/public/js/india_compliance.bundle.js +++ b/india_compliance/public/js/india_compliance.bundle.js @@ -2,4 +2,5 @@ import "./utils"; import "./quick_entry"; import "./transaction"; import "./audit_trail_notification"; +import "./item_tax_template_notification"; import "./quick_info_popover"; diff --git a/india_compliance/public/js/item_tax_template_notification.js b/india_compliance/public/js/item_tax_template_notification.js new file mode 100644 index 000000000..a48247f7d --- /dev/null +++ b/india_compliance/public/js/item_tax_template_notification.js @@ -0,0 +1,45 @@ +// TODO: Update documentation links +$(document).on("app_ready", async function () { + if (!frappe.boot.needs_item_tax_template_notification) return; + + // let other processes finish + await new Promise(resolve => setTimeout(resolve, 700)); + const d = frappe.msgprint({ + title: __("🚨 Important: Changes to Item Tax Template"), + indicator: "orange", + message: __( + `Dear India Compliance User, +

+ + We are pleased to inform you about a recent update on how Item Tax Templates are + maintained in India Compliance App. +

+ + Migration Guide: + Migrating Item Tax Template +

+ + Breaking Change: + + + Note: + If the above assumptions are not valid for your organization, please update item tax templates + accordingly for your items. + ` + ), + }); + + d.onhide = () => { + frappe.xcall( + "india_compliance.gst_india.utils.disable_item_tax_template_notification" + ); + }; +}); diff --git a/india_compliance/public/js/setup_wizard.js b/india_compliance/public/js/setup_wizard.js index ad489cc22..2e4ccd1b7 100644 --- a/india_compliance/public/js/setup_wizard.js +++ b/india_compliance/public/js/setup_wizard.js @@ -16,6 +16,14 @@ function update_erpnext_slides_settings() { slide.fields.splice(_index, 0, company_gstin_field); + slide.fields.splice(4, 0, { + fieldname: "default_gst_rate", + fieldtype: "Select", + label: __("Default GST Rate"), + options: [0.0, 0.1, 0.25, 1.0, 1.5, 3.0, 5.0, 6.0, 7.5, 12.0, 18.0, 28.0], + default: 18.0, + }); + slide.fields.push({ fieldname: "enable_audit_trail", fieldtype: "Check", diff --git a/india_compliance/setup_wizard.py b/india_compliance/setup_wizard.py index b4f6dc63f..e425178cc 100644 --- a/india_compliance/setup_wizard.py +++ b/india_compliance/setup_wizard.py @@ -2,6 +2,7 @@ from frappe import _ from india_compliance.audit_trail.utils import enable_audit_trail +from india_compliance.gst_india.overrides.company import make_default_tax_templates from india_compliance.gst_india.overrides.party import validate_pan from india_compliance.gst_india.utils import guess_gst_category, is_api_enabled from india_compliance.gst_india.utils.gstin_info import get_gstin_info @@ -36,6 +37,17 @@ def get_setup_wizard_stages(params=None): } ], }, + { + "status": _("Wrapping up"), + "fail_msg": _("Failed to Create Tax Template"), + "tasks": [ + { + "fn": setup_tax_template, + "args": params, + "fail_msg": _("Failed to Create Tax Template"), + } + ], + }, ] return stages @@ -96,3 +108,16 @@ def can_fetch_gstin_info(): return is_api_enabled() and not frappe.get_cached_value( "GST Settings", None, "sandbox_mode" ) + + +def setup_tax_template(params): + if not (params.company_name and frappe.db.exists("Company", params.company_name)): + return + + if not params.default_gst_rate: + params.default_gst_rate = "18.0" + + make_default_tax_templates(params.company_name, params.default_gst_rate) + frappe.db.set_value( + "Company", params.company_name, "default_gst_rate", params.default_gst_rate + ) diff --git a/india_compliance/tests/test_records.json b/india_compliance/tests/test_records.json index 8da187440..d06d889fd 100644 --- a/india_compliance/tests/test_records.json +++ b/india_compliance/tests/test_records.json @@ -68,7 +68,6 @@ "is_stock_item": 1, "item_code": "_Test Non GST Item", "item_name": "_Test Non GST Item", - "is_non_gst": 1, "valuation_rate": 100, "gst_hsn_code": "27131100", "uoms": [ @@ -88,6 +87,38 @@ "selling_cost_center": "Main - _TIRC", "income_account": "Sales - _TIRC" } + ], + "taxes": [ + { + "item_tax_template": "Non-GST - _TIRC" + } + ] + }, + { + "description": "_Test Nil Rated Item", + "doctype": "Item", + "is_stock_item": 1, + "item_code": "_Test Nil Rated Item", + "item_name": "_Test Nil Rated Item", + "valuation_rate": 100, + "gst_hsn_code": "61149090", + "item_defaults": [ + { + "company": "_Test Indian Registered Company", + "default_warehouse": "Stores - _TIRC", + "expense_account": "Cost of Goods Sold - _TIRC", + "buying_cost_center": "Main - _TIRC", + "selling_cost_center": "Main - _TIRC", + "income_account": "Sales - _TIRC" + } + ], + "taxes": [ + { + "item_tax_template": "Nil-Rated - _TIRC" + }, + { + "item_tax_template": "Nil-Rated - _TIUC" + } ] }, {