diff --git a/india_compliance/gst_india/constants/custom_fields.py b/india_compliance/gst_india/constants/custom_fields.py index 828c1e7efc..c8afadf110 100644 --- a/india_compliance/gst_india/constants/custom_fields.py +++ b/india_compliance/gst_india/constants/custom_fields.py @@ -1556,6 +1556,41 @@ "Supplier Quotation": {**reverse_charge_field, "insert_after": "has_unit_price_items"}, } +# no fallback: a blank value means 0 +SALES_ITEM_DOCTYPES = ( + "Quotation Item", + "Sales Order Item", + "Delivery Note Item", + "Sales Invoice Item", + "POS Invoice Item", +) + +RSP_FIELDS = { + SALES_ITEM_DOCTYPES: { + "fieldname": "gst_retail_sale_price", + "label": "Retail Sale Price (MRP)", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "price_list_rate", + "hidden": 1, + "print_hide": 1, + "translatable": 0, + } +} + +MARGIN_FIELDS = { + SALES_ITEM_DOCTYPES: { + "fieldname": "gst_purchase_price", + "label": "Purchase Price", + "fieldtype": "Currency", + "options": "Company:company:default_currency", + "insert_after": "gst_retail_sale_price", + "hidden": 1, + "print_hide": 1, + "translatable": 0, + } +} + E_INVOICE_FIELDS = { "Sales Invoice": [ { diff --git a/india_compliance/gst_india/doctype/gst_settings/gst_settings.json b/india_compliance/gst_india/doctype/gst_settings/gst_settings.json index be90c3b790..fa8fd1e581 100644 --- a/india_compliance/gst_india/doctype/gst_settings/gst_settings.json +++ b/india_compliance/gst_india/doctype/gst_settings/gst_settings.json @@ -9,6 +9,8 @@ "hsn_wise_tax_breakup", "enable_reverse_charge_in_sales", "enable_overseas_transactions", + "enable_taxes_on_mrp", + "enable_margin_scheme", "enable_sales_through_ecommerce_operators", "round_off_gst_values", "require_supplier_invoice_no", @@ -258,6 +260,20 @@ "fieldtype": "Check", "label": "Enable Reverse Charge in Sales Transactions" }, + { + "default": "0", + "description": "Adds the 'On MRP' charge type for RSP-based valuation (e.g. tobacco, Rule 31D). Enter the Retail Sale Price (MRP) in company currency per item row.", + "fieldname": "enable_taxes_on_mrp", + "fieldtype": "Check", + "label": "Enable Taxes on MRP" + }, + { + "default": "0", + "description": "Adds the 'On Margin' charge type for the margin scheme (Rule 32(5)). Enter the Purchase Price in company currency per item row.", + "fieldname": "enable_margin_scheme", + "fieldtype": "Check", + "label": "Enable Margin Scheme" + }, { "default": "0", "depends_on": "eval: doc.enable_e_waybill", diff --git a/india_compliance/gst_india/doctype/gst_settings/gst_settings.py b/india_compliance/gst_india/doctype/gst_settings/gst_settings.py index 5188fcdef3..c19c9ce18e 100644 --- a/india_compliance/gst_india/doctype/gst_settings/gst_settings.py +++ b/india_compliance/gst_india/doctype/gst_settings/gst_settings.py @@ -15,6 +15,8 @@ from india_compliance.gst_india.constants.custom_fields import ( E_INVOICE_FIELDS, E_WAYBILL_FIELDS, + MARGIN_FIELDS, + RSP_FIELDS, SALES_REVERSE_CHARGE_FIELDS, ) from india_compliance.gst_india.doctype.gst_return_log.gst_return_log import ( @@ -217,6 +219,19 @@ def update_custom_fields(self): if self.has_value_changed("enable_reverse_charge_in_sales"): toggle_custom_fields(SALES_REVERSE_CHARGE_FIELDS, self.enable_reverse_charge_in_sales) + if self.has_value_changed("enable_taxes_on_mrp"): + toggle_custom_fields(RSP_FIELDS, self.enable_taxes_on_mrp) + + if self.has_value_changed("enable_margin_scheme"): + toggle_custom_fields(MARGIN_FIELDS, self.enable_margin_scheme) + + if self.has_value_changed("enable_taxes_on_mrp") or self.has_value_changed("enable_margin_scheme"): + from india_compliance.gst_india.setup.property_setters import ( + toggle_charge_type_options, + ) + + toggle_charge_type_options(self) + if self.has_value_changed("enable_e_waybill_from_asset_movement") and ( self.enable_e_waybill_from_asset_movement ): diff --git a/india_compliance/gst_india/overrides/taxable_value.py b/india_compliance/gst_india/overrides/taxable_value.py new file mode 100644 index 0000000000..ffc2a4ef1f --- /dev/null +++ b/india_compliance/gst_india/overrides/taxable_value.py @@ -0,0 +1,76 @@ +import frappe +from frappe.utils import flt + +from india_compliance.gst_india.constants import TAX_TYPES + + +def get_item_taxable_value(doc, item, default): + # reset: the same item is resolved again on every save + item._dont_update_taxable_value = False + item._deemed_taxable_value = None + + resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {} + if not resolvers: + return default + + for tax in doc.get("taxes") or []: + if tax.get("gst_tax_type") not in TAX_TYPES: + continue + + path = resolvers.get(tax.charge_type) + if not path: + continue + + method = path[-1] if isinstance(path, list | tuple) else path + base = flt(frappe.get_attr(method)(frappe._dict(doc=doc), item, tax)) + + # a resolver may tax a deemed base but report the net value (MRP) + if getattr(item, "_dont_update_taxable_value", None): + return default + + return base * flt(doc.get("conversion_rate") or 1) + + return default + + +def _item_rate(item, tax): + item_tax_rate = frappe.parse_json(item.get("item_tax_rate")) or {} + + if tax.get("account_head") in item_tax_rate: + return flt(item_tax_rate[tax.account_head]) + + return flt(tax.get("rate")) + + +def _inclusive_rate(doc, item, tax): + # CGST and SGST rows share one base, so their rates add up + rates = [_item_rate(item, t) for t in doc.get("taxes") or [] if t.charge_type == tax.charge_type] + return sum(rates) or _item_rate(item, tax) + + +# Rule 31D: tax from the MRP, report the net sale value +def on_mrp(calc, item, tax): + conversion_rate = flt(calc.doc.get("conversion_rate")) or 1 + rate = _inclusive_rate(calc.doc, item, tax) + rsp = flt(item.get("gst_retail_sale_price")) * flt(item.qty) / conversion_rate + deemed = rsp * 100 / (100 + rate) if rate else rsp + + item._dont_update_taxable_value = True + item._deemed_taxable_value = deemed * conversion_rate + + return deemed + + +# Rule 32(5): only the margin is taxed; Rule 35 takes GST out when the price includes it +def on_margin(calc, item, tax): + conversion_rate = flt(calc.doc.get("conversion_rate")) or 1 + cost = flt(item.get("gst_purchase_price")) * flt(item.qty) / conversion_rate + margin = flt(item.amount) - cost + if abs(flt(item.amount)) < abs(cost): + margin = 0 + + if not tax.get("included_in_print_rate"): + return margin + + rate = _inclusive_rate(calc.doc, item, tax) + return margin * 100 / (100 + rate) if rate else margin diff --git a/india_compliance/gst_india/overrides/test_transaction.py b/india_compliance/gst_india/overrides/test_transaction.py index 68bcac6d18..1a70ce73b3 100644 --- a/india_compliance/gst_india/overrides/test_transaction.py +++ b/india_compliance/gst_india/overrides/test_transaction.py @@ -24,11 +24,13 @@ from frappe.utils import add_days, add_to_date, flt, getdate, now_datetime, today from parameterized import parameterized_class -from india_compliance.gst_india.constants import ( - GST_TAX_TYPES, - SALES_DOCTYPES, -) +from india_compliance.gst_india.constants import GST_TAX_TYPES, SALES_DOCTYPES from india_compliance.gst_india.constants.custom_fields import E_WAYBILL_FIELDS +from india_compliance.gst_india.overrides.taxable_value import ( + get_item_taxable_value, + on_margin, + on_mrp, +) from india_compliance.gst_india.overrides.transaction import ( ADDRESS_DEPENDENT_FIELDS, DOCTYPES_WITH_GST_DETAIL, @@ -1842,6 +1844,230 @@ def test_is_multicurrency_doc(self): self.assertFalse(_is_multicurrency_doc({})) self.assertFalse(_is_multicurrency_doc('{"conversion_rate": 1}')) + @change_settings("GST Settings", {"enable_overseas_transactions": 1, "enable_taxes_on_mrp": 1}) + @change_settings( + "Accounts Settings", + {"allow_multi_currency_invoices_against_single_party_account": 1}, + ) + def test_multicurrency_taxable_value_on_mrp(self): + _create_currency_exchange("USD", "INR", 80) + + doc = create_transaction( + doctype="Sales Invoice", + customer="_Test Foreign Customer", + party_name="_Test Foreign Customer", + currency="USD", + is_export_with_gst=1, + is_out_state=1, + rate=100, + do_not_save=True, + ) + doc.items[0].gst_retail_sale_price = 9600 + for tax in doc.taxes: + tax.charge_type = "On MRP" + + doc.insert() + + item = doc.items[0] + self.assertEqual(flt(doc.conversion_rate), 80) + self.assertEqual(item.taxable_value, flt(item.base_net_amount)) + self.assertAlmostEqual(item._deemed_taxable_value, 9600 * 100 / 118, places=2) + self.assertAlmostEqual(item.igst_amount, flt(item._deemed_taxable_value) * 18 / 100, delta=1) + self.assertGreater(item.igst_amount, flt(item.taxable_value * 18 / 100)) + + def test_on_mrp_resolver_rsp_deemed_value_and_flags(self): + calc = frappe._dict(doc=frappe._dict(conversion_rate=1)) + tax = frappe._dict(charge_type="On MRP", rate=40) + item = frappe._dict(gst_retail_sale_price=500, qty=1) + + self.assertAlmostEqual(on_mrp(calc, item, tax), 500 * 100 / 140, places=4) + self.assertTrue(item._dont_update_taxable_value) + self.assertAlmostEqual(item._deemed_taxable_value, 500 * 100 / 140, places=4) + + def test_get_item_tax_amount_preserves_zero_deemed_base(self): + # a blank RSP is an explicit 0 deemed base, not a fallback to taxable_value + calc = ItemGSTDetails(frappe._dict()) + calc.precision = frappe._dict(igst_amount=2) + + zero_deemed = frappe._dict(_deemed_taxable_value=0, taxable_value=10000, qty=1) + self.assertEqual(calc.get_item_tax_amount(zero_deemed, 18, "igst"), 0) + + plain = frappe._dict(taxable_value=10000, qty=1) + self.assertEqual(calc.get_item_tax_amount(plain, 18, "igst"), 1800) + + def test_on_margin_resolver_inclusive_deemed_margin(self): + calc = frappe._dict(doc=frappe._dict(conversion_rate=1)) + tax = frappe._dict(charge_type="On Margin", rate=18, included_in_print_rate=1) + + item = frappe._dict(amount=300000, gst_purchase_price=250000, qty=1) + self.assertAlmostEqual(on_margin(calc, item, tax), 50000 * 100 / 118, places=4) + + loss = frappe._dict(amount=100000, gst_purchase_price=250000, qty=1) + self.assertEqual(on_margin(calc, loss, tax), 0) + + def test_get_item_taxable_value_respects_dont_update_flag(self): + tax = frappe._dict(gst_tax_type="igst", charge_type="On MRP", rate=18) + item = frappe._dict(gst_retail_sale_price=120, qty=1) + doc = frappe._dict(conversion_rate=80, taxes=[tax]) + + self.assertEqual(get_item_taxable_value(doc, item, 8000), 8000) + self.assertTrue(item._dont_update_taxable_value) + self.assertAlmostEqual(item._deemed_taxable_value, 120 * 100 / 118, places=4) + + margin_tax = frappe._dict( + gst_tax_type="igst", charge_type="On Margin", rate=18, included_in_print_rate=1 + ) + margin_item = frappe._dict(amount=300000, gst_purchase_price=250000, qty=1) + margin_doc = frappe._dict(conversion_rate=1, taxes=[margin_tax]) + self.assertAlmostEqual( + get_item_taxable_value(margin_doc, margin_item, 0), 50000 * 100 / 118, places=4 + ) + + plain = frappe._dict( + conversion_rate=80, taxes=[frappe._dict(gst_tax_type="igst", charge_type="On Net Total")] + ) + self.assertEqual( + get_item_taxable_value(plain, frappe._dict(gst_retail_sale_price=120, qty=1), 555), 555 + ) + + def test_charge_type_options_gated_by_settings(self): + from india_compliance.gst_india.setup.property_setters import toggle_charge_type_options + + # the options were cached uncommitted; clear again after the rollback + self.addCleanup(frappe.clear_cache, doctype="Sales Taxes and Charges") + + def options(): + frappe.clear_cache(doctype="Sales Taxes and Charges") + return frappe.get_meta("Sales Taxes and Charges").get_options("charge_type").split("\n") + + toggle_charge_type_options(frappe._dict()) + self.assertNotIn("On MRP", options()) + self.assertNotIn("On Margin", options()) + + toggle_charge_type_options(frappe._dict(enable_taxes_on_mrp=1)) + self.assertIn("On MRP", options()) + self.assertNotIn("On Margin", options()) + + toggle_charge_type_options(frappe._dict(enable_taxes_on_mrp=1, enable_margin_scheme=1)) + self.assertIn("On MRP", options()) + self.assertIn("On Margin", options()) + + # a user/other-app custom charge type must survive disabling our settings + frappe.make_property_setter( + { + "doctype": "Sales Taxes and Charges", + "fieldname": "charge_type", + "property": "options", + "value": "\n".join([*options(), "On Custom"]), + }, + validate_fields_for_doctype=False, + is_system_generated=True, + ) + toggle_charge_type_options(frappe._dict()) + opts = options() + self.assertIn("On Custom", opts) + self.assertNotIn("On MRP", opts) + self.assertNotIn("On Margin", opts) + + def _margin_scheme_invoice(self, **kwargs): + doc = create_transaction( + doctype="Sales Invoice", + rate=300, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + **kwargs, + ) + doc.items[0].gst_purchase_price = 182 + doc.items[0].allow_zero_valuation_rate = 1 + + for tax in doc.taxes: + tax.charge_type = "On Margin" + tax.included_in_print_rate = 1 + + return doc + + @change_settings("GST Settings", {"enable_api": 0, "enable_e_invoice": 0, "enable_margin_scheme": 1}) + def test_margin_scheme_return_reverses_gst(self): + doc = self._margin_scheme_invoice() + doc.insert() + doc.submit() + + self.assertEqual(doc.items[0].taxable_value, 100) + self.assertEqual(doc.items[0].cgst_amount, 9) + self.assertEqual(doc.items[0].sgst_amount, 9) + + return_doc = make_return_doc("Sales Invoice", doc.name) + return_doc.insert() + + self.assertEqual(return_doc.items[0].taxable_value, -100) + self.assertEqual(return_doc.items[0].cgst_amount, -9) + self.assertEqual(return_doc.items[0].sgst_amount, -9) + + loss_doc = self._margin_scheme_invoice() + loss_doc.items[0].rate = 100 + loss_doc.items[0].gst_purchase_price = 250 + loss_doc.insert() + loss_doc.submit() + + loss_return = make_return_doc("Sales Invoice", loss_doc.name) + loss_return.insert() + + for item in (loss_doc.items[0], loss_return.items[0]): + self.assertEqual(item.taxable_value, 0) + self.assertEqual(item.cgst_amount, 0) + self.assertEqual(item.sgst_amount, 0) + + @change_settings("GST Settings", {"enable_api": 0, "enable_e_invoice": 0, "enable_taxes_on_mrp": 1}) + def test_resolver_uses_item_tax_template_rate(self): + doc = create_transaction( + doctype="Sales Invoice", + rate=100, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + ) + append_item(doc, frappe._dict(item_tax_template="GST 28% - _TIRC", rate=100)) + + doc.items[0].gst_retail_sale_price = 118 # 18% -> deemed 100 + doc.items[1].gst_retail_sale_price = 128 # 28% -> deemed 100 + + for tax in doc.taxes: + tax.charge_type = "On MRP" + + doc.insert() + + self.assertAlmostEqual(doc.items[0]._deemed_taxable_value, 100, places=2) + self.assertAlmostEqual(doc.items[0].cgst_amount, 9, places=2) + self.assertAlmostEqual(doc.items[0].sgst_amount, 9, places=2) + + self.assertAlmostEqual(doc.items[1]._deemed_taxable_value, 100, places=2) + self.assertAlmostEqual(doc.items[1].cgst_amount, 14, places=2) + self.assertAlmostEqual(doc.items[1].sgst_amount, 14, places=2) + + @change_settings("GST Settings", {"enable_api": 0, "enable_e_invoice": 0, "enable_margin_scheme": 1}) + def test_on_margin_uses_item_tax_template_rate(self): + doc = self._margin_scheme_invoice() # rate 300 + doc.items[0].item_tax_template = "GST 28% - _TIRC" + doc.items[0].gst_purchase_price = 172 # margin 128 incl 28% -> deemed 100 + doc.insert() + + item = doc.items[0] + self.assertAlmostEqual(item.taxable_value, 100, places=2) + self.assertAlmostEqual(item.cgst_amount, 14, places=2) + self.assertAlmostEqual(item.sgst_amount, 14, places=2) + self.assertAlmostEqual(item.net_amount, 272, places=2) # 300 - 28 + + # inclusive: the printed price still holds + self.assertAlmostEqual(doc.grand_total, 300, places=2) + + doc = self._margin_scheme_invoice() + doc.items[0].rate = 299.99 + doc.insert() + + item = doc.items[0] + self.assertEqual(item.taxable_value, flt(117.99 * 100 / 118, item.precision("taxable_value"))) + def test_copy_e_waybill_fields_from_dn_to_si(self): "Make sure e-Waybill fields are copied from Delivery Note to Sales Invoice" dn = create_transaction(doctype="Delivery Note", vehicle_no="GJ01AA1111") diff --git a/india_compliance/gst_india/overrides/transaction.py b/india_compliance/gst_india/overrides/transaction.py index c8376ed790..8e16949ece 100644 --- a/india_compliance/gst_india/overrides/transaction.py +++ b/india_compliance/gst_india/overrides/transaction.py @@ -30,6 +30,9 @@ restrict_gstr_1_transaction_for, ) from india_compliance.gst_india.doctype.gstin.gstin import get_and_validate_gstin_status +from india_compliance.gst_india.overrides.taxable_value import ( + get_item_taxable_value, +) from india_compliance.gst_india.utils import ( get_all_gst_accounts, get_changed_fields, @@ -121,7 +124,9 @@ def update_taxable_values(doc): has_no_qty_value = True for item in doc.items: - item.taxable_value = item.base_net_amount + item.taxable_value = flt( + get_item_taxable_value(doc, item, item.base_net_amount), item.precision("taxable_value") + ) if not total_charges: continue @@ -1389,7 +1394,6 @@ def validate_item_gst_details(self): expected_amt = self.get_item_tax_amount(item, item.get(f"{tax}_rate"), tax) diff = abs(item.get(f"{tax}_amount") - expected_amt) - if diff > ALLOWED_TAX_DIFFERENCE: invalid_rows[item.idx].append(tax.upper()) @@ -1446,7 +1450,13 @@ def get_item_tax_rate(self, item, tax_row): return tax_row.rate def get_item_tax_amount(self, item, tax_rate, tax): - multiplier = item.qty if tax == "cess_non_advol" else item.taxable_value / 100 + if tax == "cess_non_advol": + multiplier = item.qty + else: + # RSP etc.: tax is on a deemed base, not taxable_value + deemed_base = getattr(item, "_deemed_taxable_value", None) + base = flt(deemed_base) if deemed_base is not None else item.taxable_value + multiplier = base / 100 return tax_rate * multiplier diff --git a/india_compliance/gst_india/setup/__init__.py b/india_compliance/gst_india/setup/__init__.py index e3b7b2ee54..596ffba9a6 100644 --- a/india_compliance/gst_india/setup/__init__.py +++ b/india_compliance/gst_india/setup/__init__.py @@ -16,7 +16,9 @@ EDUCATION_CUSTOM_FIELDS, HEALTHCARE_CUSTOM_FIELDS, HRMS_CUSTOM_FIELDS, + MARGIN_FIELDS, PURCHASE_REVERSE_CHARGE_FIELDS, + RSP_FIELDS, SALES_REVERSE_CHARGE_FIELDS, ) from india_compliance.gst_india.setup.property_setters import get_property_setters @@ -340,6 +342,8 @@ def get_all_custom_fields(): PURCHASE_REVERSE_CHARGE_FIELDS, E_INVOICE_FIELDS, E_WAYBILL_FIELDS, + RSP_FIELDS, + MARGIN_FIELDS, ): for doctypes, fields in custom_fields.items(): if isinstance(fields, dict): diff --git a/india_compliance/gst_india/setup/property_setters.py b/india_compliance/gst_india/setup/property_setters.py index 8c16adc7d2..dad8669c63 100644 --- a/india_compliance/gst_india/setup/property_setters.py +++ b/india_compliance/gst_india/setup/property_setters.py @@ -98,6 +98,48 @@ def get_property_setters(*, include_defaults=False): return properties +GST_CHARGE_TYPE_SETTINGS = { + "On MRP": "enable_taxes_on_mrp", + "On Margin": "enable_margin_scheme", +} + + +def toggle_charge_type_options(settings): + doctype = "Sales Taxes and Charges" + enabled = [ct for ct, field in GST_CHARGE_TYPE_SETTINGS.items() if settings.get(field)] + + # current effective options minus our managed ones (keeps native + anything user-added) + current = [o for o in frappe.get_meta(doctype).get_options("charge_type").split("\n") if o] + target = list(dict.fromkeys([o for o in current if o not in GST_CHARGE_TYPE_SETTINGS] + enabled)) + + native = frappe.db.get_value("DocField", {"parent": doctype, "fieldname": "charge_type"}, "options") + native = [o for o in (native or "").split("\n") if o] + + existing = frappe.db.exists( + "Property Setter", + {"doc_type": doctype, "field_name": "charge_type", "property": "options"}, + ) + + if set(target) == set(native): + # only native options left — drop our override instead of pinning them + if existing: + frappe.delete_doc("Property Setter", existing) + frappe.clear_cache(doctype=doctype) + return + + frappe.make_property_setter( + { + "doctype": doctype, + "fieldname": "charge_type", + "property": "options", + "value": "\n".join(target), + }, + validate_fields_for_doctype=False, + is_system_generated=True, + ) + frappe.clear_cache(doctype=doctype) + + def get_options_property_setter(doctype, fieldname, new_options, prepend=True): existing_options = frappe.get_meta(doctype).get_options(fieldname).split("\n") if prepend: diff --git a/india_compliance/gst_india/utils/test_e_invoice.py b/india_compliance/gst_india/utils/test_e_invoice.py index c17dedc45d..d73374576b 100644 --- a/india_compliance/gst_india/utils/test_e_invoice.py +++ b/india_compliance/gst_india/utils/test_e_invoice.py @@ -31,6 +31,7 @@ from india_compliance.gst_india.utils.tests import ( append_item, create_sales_invoice, + enable_custom_gst_charge_types, ) @@ -60,6 +61,8 @@ def setUpClass(cls): ) ) update_dates_for_test_data(cls.e_invoice_test_data) + enable_custom_gst_charge_types() + cls.addClassCleanup(frappe.clear_cache, doctype="Sales Taxes and Charges") def _mock_e_invoice_response(self, data, api="ei/api/invoice"): """Mock response for e-Invoice API""" @@ -733,6 +736,156 @@ def test_request_data_with_nil_exempted_item_as_line_item(self): self.assertEqual(0, request_data["ValDtls"]["OthChrg"]) self.assertEqual(111, request_data["ValDtls"]["TotInvVal"]) + def test_request_data_for_rsp_on_mrp(self): + si = create_sales_invoice( + rate=90, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + ) + si.items[0].gst_retail_sale_price = 118 + for tax in si.taxes: + tax.charge_type = "On MRP" + si.insert() + + request_data = EInvoiceData(si).get_data() + item = request_data["ItemList"][0] + + self.assertEqual(item["AssAmt"], 90) + self.assertEqual(item["GstRt"], 18.0) + self.assertEqual(item["CgstAmt"], 9) + self.assertEqual(item["SgstAmt"], 9) + self.assertEqual(item["OthChrg"], 0) + self.assertEqual(item["TotItemVal"], 108) + + val = request_data["ValDtls"] + self.assertEqual(val["AssVal"], 90) + self.assertEqual(val["OthChrg"], 0) + self.assertEqual(val["TotInvVal"], 108) + # NIC: TotInvVal = sum(item TotItemVal) + doc OthChrg + total_item_val = sum(i["TotItemVal"] for i in request_data["ItemList"]) + self.assertEqual(total_item_val + val["OthChrg"], val["TotInvVal"]) + + def test_request_data_for_margin_scheme(self): + si = create_sales_invoice( + rate=300, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + ) + si.items[0].gst_purchase_price = 182 + si.items[0].allow_zero_valuation_rate = 1 + for tax in si.taxes: + tax.charge_type = "On Margin" + tax.included_in_print_rate = 1 + si.insert() + + request_data = EInvoiceData(si).get_data() + item = request_data["ItemList"][0] + + self.assertEqual(item["AssAmt"], 100) + self.assertEqual(item["GstRt"], 18.0) + self.assertEqual(item["CgstAmt"], 9) + self.assertEqual(item["SgstAmt"], 9) + self.assertEqual(item["OthChrg"], 0) # cost is reported at doc level, not the item + self.assertEqual(item["TotItemVal"], 118) # margin + tax + + val = request_data["ValDtls"] + self.assertEqual(val["AssVal"], 100) + self.assertEqual(val["OthChrg"], 182) # purchase cost balance + self.assertEqual(val["TotInvVal"], 300) + self.assertLess(val["AssVal"] + val["CgstVal"] + val["SgstVal"], val["TotInvVal"]) + # NIC: TotInvVal = sum(item TotItemVal) + doc OthChrg (no double counting -> no error 2189) + total_item_val = sum(i["TotItemVal"] for i in request_data["ItemList"]) + self.assertEqual(total_item_val + val["OthChrg"], val["TotInvVal"]) + + si = create_sales_invoice( + rate=300, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + ) + si.items[0].gst_purchase_price = 182 + si.items[0].allow_zero_valuation_rate = 1 + for tax in si.taxes: + tax.charge_type = "On Margin" + si.insert() + + request_data = EInvoiceData(si).get_data() + item = request_data["ItemList"][0] + val = request_data["ValDtls"] + + self.assertEqual(item["AssAmt"], 118) + self.assertEqual(item["CgstAmt"], 10.62) + self.assertEqual(item["SgstAmt"], 10.62) + self.assertEqual(val["OthChrg"], 182) + self.assertEqual(si.grand_total, 321.24) + + def test_request_data_for_margin_scheme_return(self): + si = create_sales_invoice( + rate=300, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + ) + si.items[0].gst_purchase_price = 182 + si.items[0].allow_zero_valuation_rate = 1 + for tax in si.taxes: + tax.charge_type = "On Margin" + tax.included_in_print_rate = 1 + si.insert() + si.submit() + + cn = make_return_doc("Sales Invoice", si.name) + cn.insert() + + request_data = EInvoiceData(cn).get_data() + item = request_data["ItemList"][0] + val = request_data["ValDtls"] + + self.assertEqual(cn.items[0].cgst_amount, -9) + self.assertEqual(cn.items[0].sgst_amount, -9) + self.assertEqual(cn.items[0].taxable_value, -100) + + self.assertEqual(item["AssAmt"], 100) + self.assertEqual(item["CgstAmt"], 9) + self.assertEqual(item["SgstAmt"], 9) + self.assertEqual(item["OthChrg"], 0) # cost rides doc-level OthChrg + self.assertEqual(item["TotItemVal"], 118) + self.assertEqual(val["AssVal"], 100) + self.assertEqual(val["OthChrg"], 182) + self.assertEqual(val["TotInvVal"], 300) + total_item_val = sum(i["TotItemVal"] for i in request_data["ItemList"]) + self.assertEqual(total_item_val + val["OthChrg"], val["TotInvVal"]) + + @change_settings("GST Settings", {"nil_exempt_e_invoice_treatment": "Generate with Taxable Values"}) + def test_margin_scheme_not_folded_by_nil_as_taxable(self): + si = create_sales_invoice( + rate=300, + is_in_state=True, + company_address="_Test Indian Registered Company-Billing", + do_not_save=True, + ) + si.items[0].gst_purchase_price = 182 + si.items[0].allow_zero_valuation_rate = 1 + for tax in si.taxes: + tax.charge_type = "On Margin" + tax.included_in_print_rate = 1 + si.insert() + + request_data = EInvoiceData(si).get_data() + item = request_data["ItemList"][0] + val = request_data["ValDtls"] + + self.assertEqual(item["AssAmt"], 100) + self.assertEqual(item["OthChrg"], 0) + self.assertEqual(item["TotItemVal"], 118) # AssAmt + tax, not folded with the cost + self.assertEqual(val["AssVal"], 100) # margin only, NOT 100 + 182 + self.assertEqual(val["OthChrg"], 182) + self.assertEqual(val["TotInvVal"], 300) + total_item_val = sum(i["TotItemVal"] for i in request_data["ItemList"]) + self.assertEqual(total_item_val + val["OthChrg"], val["TotInvVal"]) + @responses.activate def test_credit_note_e_invoice_with_goods_item(self): """Generate test e-Invoice for returned Sales Invoices""" diff --git a/india_compliance/gst_india/utils/tests.py b/india_compliance/gst_india/utils/tests.py index b9942d4280..4e1398117d 100644 --- a/india_compliance/gst_india/utils/tests.py +++ b/india_compliance/gst_india/utils/tests.py @@ -37,6 +37,12 @@ } +def enable_custom_gst_charge_types(): + from india_compliance.gst_india.setup.property_setters import toggle_charge_type_options + + toggle_charge_type_options(frappe._dict(enable_taxes_on_mrp=1, enable_margin_scheme=1)) + + def create_sales_invoice(**data): data["doctype"] = "Sales Invoice" return create_transaction(**data) diff --git a/india_compliance/hooks.py b/india_compliance/hooks.py index 2933ed9026..b677c55b4a 100644 --- a/india_compliance/hooks.py +++ b/india_compliance/hooks.py @@ -56,6 +56,11 @@ app_include_js = "india_compliance.bundle.js" +erpnext_taxable_base_resolvers = { + "On MRP": "india_compliance.gst_india.overrides.taxable_value.on_mrp", + "On Margin": "india_compliance.gst_india.overrides.taxable_value.on_margin", +} + doctype_js = { "Address": [ "gst_india/client_scripts/party.js", diff --git a/india_compliance/patches.txt b/india_compliance/patches.txt index 71062c7065..59860a33f6 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() #79 +execute:from india_compliance.gst_india.setup import create_custom_fields; create_custom_fields() #80 execute:from india_compliance.gst_india.setup import create_property_setters; create_property_setters() #18 execute:from india_compliance.income_tax_india.setup import create_custom_fields; create_custom_fields() #8 india_compliance.patches.post_install.remove_old_fields #2 diff --git a/india_compliance/public/js/india_compliance.bundle.js b/india_compliance/public/js/india_compliance.bundle.js index 7a72bf62ea..dc295a8cb5 100644 --- a/india_compliance/public/js/india_compliance.bundle.js +++ b/india_compliance/public/js/india_compliance.bundle.js @@ -1,4 +1,5 @@ import "./utils"; +import "./taxable_base_resolvers"; import "./isd"; import "./gst_api_handler"; import "./quick_entry"; diff --git a/india_compliance/public/js/taxable_base_resolvers.js b/india_compliance/public/js/taxable_base_resolvers.js new file mode 100644 index 0000000000..8990eefa26 --- /dev/null +++ b/india_compliance/public/js/taxable_base_resolvers.js @@ -0,0 +1,49 @@ +frappe.provide("erpnext"); + +// Mirror of overrides/taxable_value.py resolvers (client preview). +erpnext.taxable_base_resolvers = erpnext.taxable_base_resolvers || {}; + +const _item_rate = (item, tax) => { + const item_tax_rate = item.item_tax_rate ? JSON.parse(item.item_tax_rate) : {}; + + if (tax.account_head in item_tax_rate) return flt(item_tax_rate[tax.account_head]); + + return flt(tax.rate); +}; + +// CGST and SGST rows share one base, so their rates add up +const _inclusive_rate = (calc, item, tax) => { + const taxes = (calc.frm && calc.frm.doc.taxes) || []; + const total = taxes.reduce( + (s, t) => s + (t.charge_type === tax.charge_type ? _item_rate(item, t) : 0), + 0, + ); + return total || _item_rate(item, tax); +}; + +// Rule 31D: tax from the MRP, report the net sale value +erpnext.taxable_base_resolvers["On MRP"] = (calc, item, tax) => { + const conversion_rate = flt(calc.frm && calc.frm.doc.conversion_rate) || 1; + const rate = _inclusive_rate(calc, item, tax); + const rsp = (flt(item.gst_retail_sale_price) * flt(item.qty)) / conversion_rate; + const deemed = rate ? (rsp * 100) / (100 + rate) : rsp; + + item._dont_update_taxable_value = true; + item._deemed_taxable_value = deemed * conversion_rate; + + return deemed; +}; + +// Rule 32(5): only the margin is taxed; Rule 35 takes GST out when the price includes it +erpnext.taxable_base_resolvers["On Margin"] = (calc, item, tax) => { + const conversion_rate = flt(calc.frm && calc.frm.doc.conversion_rate) || 1; + const cost = (flt(item.gst_purchase_price) * flt(item.qty)) / conversion_rate; + + let margin = flt(item.amount) - cost; + if (Math.abs(flt(item.amount)) < Math.abs(cost)) margin = 0; + + if (!tax.included_in_print_rate) return margin; + + const rate = _inclusive_rate(calc, item, tax); + return rate ? (margin * 100) / (100 + rate) : margin; +};