diff --git a/india_compliance/gst_india/constants/__init__.py b/india_compliance/gst_india/constants/__init__.py index 50dd072ec8..2c7c726774 100644 --- a/india_compliance/gst_india/constants/__init__.py +++ b/india_compliance/gst_india/constants/__init__.py @@ -61,6 +61,7 @@ ) TAXABLE_GST_TREATMENTS = ("Taxable", "Zero-Rated") +IGNORED_GST_TREATMENT = "Ignored for GST" STATE_NUMBERS = { diff --git a/india_compliance/gst_india/constants/custom_fields.py b/india_compliance/gst_india/constants/custom_fields.py index 2757fd8b0b..d2b624c423 100644 --- a/india_compliance/gst_india/constants/custom_fields.py +++ b/india_compliance/gst_india/constants/custom_fields.py @@ -812,7 +812,7 @@ "fieldname": "gst_treatment", "label": "GST Treatment", "fieldtype": "Autocomplete", - "options": "Taxable\nZero-Rated\nNil-Rated\nExempted\nNon-GST", + "options": "Taxable\nZero-Rated\nNil-Rated\nExempted\nNon-GST\nIgnored for GST", "fetch_from": "item_tax_template.gst_treatment", "fetch_if_empty": 1, "insert_after": "item_tax_template", @@ -1293,7 +1293,7 @@ "label": "GST Treatment", "fieldtype": "Autocomplete", "default": "Taxable", - "options": "Taxable\nNil-Rated\nExempted\nNon-GST", + "options": "Taxable\nNil-Rated\nExempted\nNon-GST\nIgnored for GST", "insert_after": "column_break_3", "translatable": 0, }, 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 998c53018e..701b092848 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 @@ -227,6 +227,40 @@ def test_itc_reversal_journal_entry_is_included_in_gstr_3b(self): self.assertEqual(output["itc_elg"]["itc_net"]["camt"], -9.0) self.assertEqual(output["itc_elg"]["itc_net"]["samt"], -9.0) + def test_ignored_items_excluded_from_itc(self): + """ + Test that Purchase Invoice items marked as 'Ignored for GST' are excluded + from GSTR-3B ITC calculations. + """ + # Create a purchase invoice with mixed items + pi = create_purchase_invoice( + is_in_state=True, + do_not_submit=True, + items=[ + { + "item_code": "_Test Trading Goods 1", + "qty": 1.0, + "rate": 100.0, + }, + { + "item_code": "_Test Trading Goods 1", + "qty": 1.0, + "rate": 200.0, + }, + ], + ) + + # Mark second item as ignored for GST + pi.items[1].gst_treatment = "Ignored for GST" + pi.save() + pi.submit() + + # The ignored item should not contribute to ITC + # Only first item's GST (9 CGST + 9 SGST) should be eligible + self.assertEqual(pi.items[0].cgst_amount, 9.0) + self.assertEqual(pi.items[1].cgst_amount, 0.0) # Ignored item + self.assertEqual(pi.items[1].taxable_value, 0.0) # Ignored item + def create_sales_invoices(): create_sales_invoice(is_in_state=True) diff --git a/india_compliance/gst_india/overrides/test_transaction.py b/india_compliance/gst_india/overrides/test_transaction.py index aa9dfc9dfd..c85fbfd9e4 100644 --- a/india_compliance/gst_india/overrides/test_transaction.py +++ b/india_compliance/gst_india/overrides/test_transaction.py @@ -170,6 +170,125 @@ def test_non_taxable_items_with_tax(self): doc, ) + def test_ignored_gst_treatment_resets_values(self): + """ + Test that items marked as 'Ignored for GST' have their taxable value, + GST rates, and amounts reset to zero. + """ + doc = create_transaction( + **self.transaction_details, + is_in_state=True, + do_not_submit=True, + ) + + # Mark item as ignored for GST + doc.items[0].gst_treatment = "Ignored for GST" + doc.save() + + # All GST values should be reset to zero + self.assertDocumentEqual( + { + "gst_treatment": "Ignored for GST", + "taxable_value": 0, + "igst_rate": 0, + "cgst_rate": 0, + "sgst_rate": 0, + "igst_amount": 0, + "cgst_amount": 0, + "sgst_amount": 0, + }, + doc.items[0], + ) + + def test_ignored_gst_treatment_skips_validation(self): + """ + Test that items with 'Ignored for GST' treatment do not trigger + the validation error for non-taxable items with tax. + """ + doc = create_transaction( + **self.transaction_details, + is_in_state=True, + item_tax_template="GST 28% - _TIRC", + do_not_submit=True, + ) + + # Mark item as ignored for GST - should not throw validation error + doc.items[0].gst_treatment = "Ignored for GST" + + # This should NOT raise an error unlike Nil-Rated + validate_item_tax_template(doc) + + def test_transaction_with_mixed_taxable_and_ignored_items(self): + """ + Test workflow with both taxable items and ignored items in the same transaction. + """ + doc = create_transaction( + **self.transaction_details, + is_in_state=True, + do_not_save=True, + ) + + # Add a second item + append_item(doc, frappe._dict(rate=200)) + doc.insert() + + # Mark the second item as ignored and save again + doc.items[1].gst_treatment = "Ignored for GST" + doc.save() + + # First item should have normal GST calculation + self.assertEqual(doc.items[0].gst_treatment, "Taxable") + self.assertEqual(doc.items[0].taxable_value, 100) + self.assertEqual(doc.items[0].cgst_rate, 9) + self.assertEqual(doc.items[0].sgst_rate, 9) + + # Second item should have all values as zero + self.assertDocumentEqual( + { + "gst_treatment": "Ignored for GST", + "taxable_value": 0, + "cgst_rate": 0, + "sgst_rate": 0, + "cgst_amount": 0, + "sgst_amount": 0, + }, + doc.items[1], + ) + + def test_taxable_value_hook(self): + """ + Test that the india_compliance_get_item_taxable_value hook allows + custom apps to override taxable value calculation. + """ + custom_taxable_value = 50.0 + + def custom_hook(doc, item): + # Override taxable value for testing + return custom_taxable_value + + # Register the hook temporarily + frappe.get_hooks.clear() + + # Add custom hook + frappe.local.hooks = frappe._dict() + frappe.local.hooks["india_compliance_get_item_taxable_value"] = [custom_hook] + + try: + doc = create_transaction( + **self.transaction_details, + is_in_state=True, + do_not_submit=True, + ) + doc.save() + + # The hook should have overridden the taxable value + self.assertEqual(doc.items[0].taxable_value, custom_taxable_value) + finally: + # Restore original hooks + if hasattr(frappe.local, "hooks"): + del frappe.local.hooks + frappe.get_hooks.clear() + def test_validate_item_tax_template(self): item_tax_template = frappe.get_doc("Item Tax Template", "GST 28% - _TIRC") tax_accounts = item_tax_template.get("taxes") diff --git a/india_compliance/gst_india/overrides/transaction.py b/india_compliance/gst_india/overrides/transaction.py index 3e8334149c..d4b7ea37b8 100644 --- a/india_compliance/gst_india/overrides/transaction.py +++ b/india_compliance/gst_india/overrides/transaction.py @@ -12,6 +12,7 @@ GST_RCM_TAX_TYPES, GST_REFUND_TAX_TYPES, GST_TAX_TYPES, + IGNORED_GST_TREATMENT, SALES_DOCTYPES, STATE_NUMBERS, SUBCONTRACTING_DOCTYPES, @@ -134,6 +135,14 @@ def update_taxable_values(doc): if apportioned_charges != total_charges: item.taxable_value += total_charges - apportioned_charges + # Allow custom apps to override taxable value calculation + for item in doc.items: + for method in frappe.get_hooks("india_compliance_get_item_taxable_value"): + custom_value = frappe.call(method, doc=doc, item=item) + if custom_value is not None: + item.taxable_value = custom_value + break + def validate_item_wise_tax_detail(doc): if doc.doctype not in DOCTYPES_WITH_GST_DETAIL: @@ -693,6 +702,10 @@ def _validate_hsn_codes(doc, valid_hsn_length, throw=False, message=None): rows_with_invalid_hsn = [] for item in doc.items: + # Skip HSN validation for items marked as ignored for GST + if item.gst_treatment == IGNORED_GST_TREATMENT: + continue + item.gst_hsn_code = (item.gst_hsn_code or "").replace(" ", "") if not (hsn_code := item.get("gst_hsn_code")): @@ -1178,8 +1191,23 @@ def update(self, doc): self.set_temp_item_wise_tax_detail_object() self.set_item_name_wise_tax_details() + self.reset_ignored_items() self.validate_item_gst_details() + def reset_ignored_items(self): + """ + Reset taxable_value, tax rates, and tax amounts to zero for items marked as 'Ignored for GST'. + This ensures these items don't contribute to GST totals in statutory reports. + """ + for item in self.doc.get("items"): + if item.gst_treatment != IGNORED_GST_TREATMENT: + continue + + item.taxable_value = 0 + for tax_type in GST_TAX_TYPES: + item.set(f"{tax_type}_rate", 0) + item.set(f"{tax_type}_amount", 0) + def get_item_defaults(self): item_defaults = frappe._dict(count=0) @@ -1475,6 +1503,10 @@ def set_default_treatment(self): default_treatment = self.get_default_treatment() for item in self.doc.items: + # Preserve "Ignored for GST" treatment if already set + if item.gst_treatment == IGNORED_GST_TREATMENT: + continue + item.gst_treatment = self.gst_treatment_map.get(item.item_tax_template) if not item.gst_treatment or not item.item_tax_template: @@ -1726,6 +1758,10 @@ def validate_item_tax_template(doc): if item.taxable_value == 0: continue + # Skip items marked as ignored for GST + if item.gst_treatment == IGNORED_GST_TREATMENT: + continue + if item.gst_treatment == "Zero-Rated" and not doc.get("is_export_with_gst"): continue diff --git a/india_compliance/gst_india/utils/gstr3b/gstr3b_data.py b/india_compliance/gst_india/utils/gstr3b/gstr3b_data.py index 387915c471..4b5435da9c 100644 --- a/india_compliance/gst_india/utils/gstr3b/gstr3b_data.py +++ b/india_compliance/gst_india/utils/gstr3b/gstr3b_data.py @@ -3,7 +3,10 @@ from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import IfNull, Sum -from india_compliance.gst_india.constants import GST_TAX_TYPES +from india_compliance.gst_india.constants import ( + GST_TAX_TYPES, + IGNORED_GST_TREATMENT, +) from india_compliance.gst_india.overrides.transaction import is_inter_state_supply from india_compliance.gst_india.utils import get_full_gst_uom from india_compliance.gst_india.utils.gstr_1 import GSTR1_SubCategory @@ -204,6 +207,8 @@ def get_base_purchase_query(self): .where((self.PI.is_opening == "No")) .where(self.PI.company_gstin != IfNull(self.PI.supplier_gstin, "")) .where(IfNull(self.PI.itc_classification, "") != "Import Of Goods") + # Exclude items marked as ignored for GST + .where(IfNull(self.PI_ITEM.gst_treatment, "") != IGNORED_GST_TREATMENT) ) return self.get_query_with_common_filters(query, self.PI) diff --git a/india_compliance/gst_india/utils/gstr_1/gstr_1_data.py b/india_compliance/gst_india/utils/gstr_1/gstr_1_data.py index 9784ab2ef9..b728556841 100644 --- a/india_compliance/gst_india/utils/gstr_1/gstr_1_data.py +++ b/india_compliance/gst_india/utils/gstr_1/gstr_1_data.py @@ -10,7 +10,10 @@ from frappe.query_builder.functions import Date, IfNull, Sum from frappe.utils import cint, flt, getdate -from india_compliance.gst_india.constants import GST_REFUND_TAX_TYPES +from india_compliance.gst_india.constants import ( + GST_REFUND_TAX_TYPES, + IGNORED_GST_TREATMENT, +) from india_compliance.gst_india.utils import ( get_escaped_name, get_full_gst_uom, @@ -139,6 +142,8 @@ def get_base_query(self): .where(self.si.docstatus == 1) .where(self.si.is_opening != "Yes") .where(IfNull(self.si.billing_address_gstin, "") != self.si.company_gstin) + # Exclude items marked as ignored for GST + .where(IfNull(self.si_item.gst_treatment, "") != IGNORED_GST_TREATMENT) .orderby( self.si.posting_date, self.si.name, diff --git a/india_compliance/gst_india/utils/gstr_1/test_gstr_1_books_data.py b/india_compliance/gst_india/utils/gstr_1/test_gstr_1_books_data.py index fdb9abc0e5..c5abe4f8a9 100644 --- a/india_compliance/gst_india/utils/gstr_1/test_gstr_1_books_data.py +++ b/india_compliance/gst_india/utils/gstr_1/test_gstr_1_books_data.py @@ -835,6 +835,61 @@ def test_transaction_split_b2b_nil(self): ][0], ) + def test_transaction_with_ignored_item(self): + """ + Test that items marked as 'Ignored for GST' are excluded from GSTR-1 report. + Creates an invoice with one taxable item and one ignored item, + verifies only the taxable item appears in the report. + """ + si = create_sales_invoice( + customer="_Test Registered Customer", + is_in_state=True, + do_not_submit=True, + items=[ + { + "item_code": "_Test Trading Goods 1", + "qty": 1.0, + "rate": 100.0, + }, + { + "item_code": "_Test Trading Goods 1", + "qty": 1.0, + "rate": 200.0, + }, + ], + ) + + # Mark second item as ignored for GST + si.items[1].gst_treatment = "Ignored for GST" + si.save() + si.submit() + + data = GSTR1BooksData(filters=FILTERS).prepare_mapped_data() + + # Only the taxable item (100.0) should appear in report + # The ignored item (200.0) should be excluded + self.assertDictEq( + { + "transaction_type": "Invoice", + "total_taxable_value": 100.0, # Only first item + "total_igst_amount": 0.0, + "total_cgst_amount": 9.0, + "total_sgst_amount": 9.0, + "total_cess_amount": 0.0, + "items": [ + { + "taxable_value": 100.0, + "igst_amount": 0.0, + "cgst_amount": 9.0, + "sgst_amount": 9.0, + "cess_amount": 0.0, + "tax_rate": 18.0, + } + ], + }, + data[GSTR1_SubCategory.B2B_REGULAR.value][si.name], + ) + def test_hsn_summary_with_bifurcation(self): si = create_sales_invoice( customer="_Test Registered Customer", diff --git a/india_compliance/gst_india/utils/transaction_data.py b/india_compliance/gst_india/utils/transaction_data.py index a901f8199d..57904a0774 100644 --- a/india_compliance/gst_india/utils/transaction_data.py +++ b/india_compliance/gst_india/utils/transaction_data.py @@ -9,6 +9,7 @@ GST_REFUND_TAX_TYPES, GST_TAX_RATES, GST_TAX_TYPES, + IGNORED_GST_TREATMENT, TAXABLE_GST_TREATMENTS, VALID_HSN_LENGTHS, ) @@ -325,6 +326,10 @@ def get_all_item_details(self): items = self.group_same_items() for row in items: + # Skip items marked as ignored for GST + if row.gst_treatment == IGNORED_GST_TREATMENT: + continue + item_details = frappe._dict( { "item_no": row.idx, diff --git a/india_compliance/hooks.py b/india_compliance/hooks.py index de25983ba7..5e905fb29c 100644 --- a/india_compliance/hooks.py +++ b/india_compliance/hooks.py @@ -443,6 +443,10 @@ accounting_dimension_doctypes = ["Bill of Entry", "Bill of Entry Item"] +# Hook for custom apps to override the taxable value calculation for items +# Called with (doc, item) and should return the custom taxable value or None to skip +india_compliance_get_item_taxable_value = [] + # DocTypes for which Audit Trail must be maintained audit_trail_doctypes = [ # To track the "Enable Audit Trail" setting diff --git a/india_compliance/patches.txt b/india_compliance/patches.txt index 1f92e2b7b5..c409f0e3ca 100644 --- a/india_compliance/patches.txt +++ b/india_compliance/patches.txt @@ -5,7 +5,7 @@ execute:from frappe.installer import add_module_defs; add_module_defs("india_com [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() #69 +execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #70 execute:from india_compliance.gst_india.setup import create_property_setters; create_property_setters() #11 execute:from india_compliance.income_tax_india.setup import create_custom_fields; create_custom_fields() #4 india_compliance.patches.post_install.remove_old_fields #2