From 2f734f1c49fb599a6be671fe88db67dae9225038 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Fri, 24 Apr 2026 20:05:05 +0530 Subject: [PATCH 1/6] feat: allow itc claim period for RCM Purchase Invoice --- .../gstr_3b_report/test_gstr_3b_report.py | 64 ++++++++++++++++++- .../overrides/test_purchase_invoice.py | 33 +++------- india_compliance/gst_india/utils/itc_claim.py | 21 ------ .../gst_india/utils/test_itc_claim.py | 7 +- 4 files changed, 78 insertions(+), 47 deletions(-) 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 f2cd7717e6..44ffd7bf0e 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 @@ -5,7 +5,7 @@ import frappe from frappe.tests import IntegrationTestCase, change_settings -from frappe.utils import get_month, getdate +from frappe.utils import add_months, get_month, getdate from india_compliance.gst_india.doctype.bill_of_entry.bill_of_entry import ( make_bill_of_entry, @@ -18,6 +18,7 @@ GSTR3B_Inward_Nil_Exempt, ) from india_compliance.gst_india.utils import get_gst_accounts_by_type +from india_compliance.gst_india.utils.itc_claim import format_period from india_compliance.gst_india.utils.tests import ( append_item, create_purchase_invoice, @@ -752,6 +753,67 @@ def test_inter_state_advance_payment_entry(self): self.assertEqual(output["sup_details"]["osup_det"]["camt"], 0.0) self.assertEqual(output["sup_details"]["osup_det"]["samt"], 0.0) + def test_rcm_outward_liability(self): + """ + Scenario: RCM invoice posted this month but ITC deferred to next month. + - This month's report: outward liability shows invoice, ITC does not. + - Next month's report: outward liability does not show invoice, ITC does. + """ + today = getdate() + next_month = add_months(today, 1) + next_period = format_period(next_month) + + pi = create_purchase_invoice( + supplier="_Test Unregistered Supplier", + is_reverse_charge=True, + is_in_state_rcm=True, + posting_date=today, + do_not_submit=True, + ) + pi.itc_claim_period = next_period + pi.save() + pi.submit() + + # -- Report for THIS month (filter_by ITC Claim Period) -- + report_this = frappe.get_doc( + { + "doctype": "GSTR 3B Report", + "company": "_Test Indian Registered Company", + "company_gstin": "24AAQCA8719H1ZC", + "year": today.year, + "month_or_quarter": get_month(today), + "filter_by": "ITC Claim Period", + } + ).insert() + out_this = json.loads(report_this.json_output) + + # Outward RCM liability always by posting date → invoice IS included + self.assertGreater(out_this["sup_details"]["isup_rev"]["txval"], 0) + # ITC by claim period → invoice is NOT included (deferred to next month) + itc_this = {r["ty"]: r for r in out_this["itc_elg"]["itc_avl"]} + self.assertEqual(itc_this.get("ISRC", {}).get("camt", 0.0), 0.0) + self.assertEqual(itc_this.get("ISRC", {}).get("samt", 0.0), 0.0) + + # -- Report for NEXT month (filter_by ITC Claim Period) -- + report_next = frappe.get_doc( + { + "doctype": "GSTR 3B Report", + "company": "_Test Indian Registered Company", + "company_gstin": "24AAQCA8719H1ZC", + "year": next_month.year, + "month_or_quarter": get_month(next_month), + "filter_by": "ITC Claim Period", + } + ).insert() + out_next = json.loads(report_next.json_output) + + # Outward RCM by posting date → invoice is NOT in next month's liability + self.assertEqual(out_next["sup_details"]["isup_rev"]["txval"], 0.0) + # ITC by claim period → invoice IS in next month's ITC + itc_next = {r["ty"]: r for r in out_next["itc_elg"]["itc_avl"]} + self.assertGreater(itc_next.get("ISRC", {}).get("camt", 0.0), 0.0) + self.assertGreater(itc_next.get("ISRC", {}).get("samt", 0.0), 0.0) + def create_sales_invoices(): create_sales_invoice(is_in_state=True) diff --git a/india_compliance/gst_india/overrides/test_purchase_invoice.py b/india_compliance/gst_india/overrides/test_purchase_invoice.py index e7616974c9..4c5b8f3096 100644 --- a/india_compliance/gst_india/overrides/test_purchase_invoice.py +++ b/india_compliance/gst_india/overrides/test_purchase_invoice.py @@ -272,7 +272,8 @@ def test_itc_claim_period_invalid_format(self): def test_itc_claim_period_for_unregistered_rcm(self): """ - For Unregistered supplier RCM, ITC Claim Period must match the posting period + Unregistered RCM ITC Claim Period can be any valid period — + not restricted to the posting period. """ pinv = create_purchase_invoice( supplier="_Test Unregistered Supplier", @@ -280,31 +281,15 @@ def test_itc_claim_period_for_unregistered_rcm(self): do_not_submit=True, ) - posting_period = format_period(pinv.posting_date) - self.assertEqual(pinv.itc_claim_period, posting_period) - - # Try to change itc_claim_period to a different period - should fail - pinv.itc_claim_period = "012099" # Different period - - self.assertRaisesRegex( - frappe.exceptions.ValidationError, - re.compile( - r"ITC Claim Period must be .* for purchases from Unregistered suppliers under Reverse Charge" - ), - pinv.save, - ) - - # Try to set to "Deferred" - should also fail for Unregistered RCM - pinv.reload() + # Setting to "Deferred" must be allowed pinv.itc_claim_period = ITC_CLAIM_PERIOD_DEFERRED + pinv.save() + self.assertEqual(pinv.itc_claim_period, ITC_CLAIM_PERIOD_DEFERRED) - self.assertRaisesRegex( - frappe.exceptions.ValidationError, - re.compile( - r"ITC Claim Period must be .* for purchases from Unregistered suppliers under Reverse Charge" - ), - pinv.save, - ) + # Setting to any future valid period must also be allowed + pinv.itc_claim_period = "012099" + pinv.save() + self.assertEqual(pinv.itc_claim_period, "012099") def test_itc_claim_period_deferred(self): """ diff --git a/india_compliance/gst_india/utils/itc_claim.py b/india_compliance/gst_india/utils/itc_claim.py index 14d5f35277..68145bb9a3 100644 --- a/india_compliance/gst_india/utils/itc_claim.py +++ b/india_compliance/gst_india/utils/itc_claim.py @@ -337,23 +337,18 @@ def _calculate_itc_claim_period( if inward_supply and inward_supply.get("return_period_2b"): default_period = _max_period(posting_period, inward_supply.return_period_2b) - if doc.get("gst_category") == "Unregistered" and doc.get("is_reverse_charge"): - return posting_period - return _get_next_unfiled_period(doc.company_gstin, default_period, doc.posting_date, filed) def validate_itc_claim_period(doc) -> None: validate_mandatory_fields(doc, "itc_claim_period") _validate_period_format(doc.itc_claim_period) - _validate_itc_claim_period_for_rcm_invoice(doc) _validate_itc_claim_period_as_per_filing(doc) def validate_itc_claim_period_on_update_after_submit(doc) -> None: validate_mandatory_fields(doc, "itc_claim_period") _validate_period_format(doc.itc_claim_period) - _validate_itc_claim_period_for_rcm_invoice(doc) # On update-after-submit, period checks are needed only if period changed. previous = doc.get_doc_before_save() @@ -386,22 +381,6 @@ def _validate_itc_claim_period_as_per_filing(doc) -> None: ) -def _validate_itc_claim_period_for_rcm_invoice(doc) -> None: - """For Unregistered RCM, ITC must be claimed in the same period as posting.""" - if ( - doc.doctype == "Purchase Invoice" - and doc.gst_category == "Unregistered" - and doc.is_reverse_charge - and doc.itc_claim_period != format_period(doc.posting_date) - ): - frappe.throw( - _( - "ITC Claim Period must be {0} (same as posting date) for purchases from" - " Unregistered suppliers under Reverse Charge." - ).format(format_period(doc.posting_date)) - ) - - # ============================================================================= # Bulk Processing # ============================================================================= diff --git a/india_compliance/gst_india/utils/test_itc_claim.py b/india_compliance/gst_india/utils/test_itc_claim.py index 1b84125908..9e116e6722 100644 --- a/india_compliance/gst_india/utils/test_itc_claim.py +++ b/india_compliance/gst_india/utils/test_itc_claim.py @@ -358,15 +358,20 @@ def test_calc_2b_period_earlier_than_posting(self): self.assertEqual(result, "032024") def test_calc_unregistered_rcm(self): - """Unregistered RCM always returns posting period.""" + """Unregistered RCM follows same logic as regular invoices (next unfiled period).""" doc = self._make_doc( gst_category="Unregistered", is_reverse_charge=1, posting_date=getdate("2024-01-15"), ) + # posting period unfiled → returns posting period result = _calculate_itc_claim_period(doc, filed=set()) self.assertEqual(result, "012024") + # posting period filed → skips to next unfiled (same as regular invoices) + result = _calculate_itc_claim_period(doc, filed={"012024"}) + self.assertEqual(result, "022024") + def test_calc_no_inward_supply(self): """No inward supply → uses posting period as start.""" doc = self._make_doc(posting_date=getdate("2024-01-15")) From 8b3c665dbdd5551b79d22670ad68ee815a4f1432 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 27 Apr 2026 11:25:22 +0530 Subject: [PATCH 2/6] chore: codacy issue --- .../gst_india/doctype/gstr_3b_report/test_gstr_3b_report.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 44ffd7bf0e..bde6669136 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 @@ -754,7 +754,8 @@ def test_inter_state_advance_payment_entry(self): self.assertEqual(output["sup_details"]["osup_det"]["samt"], 0.0) def test_rcm_outward_liability(self): - """ + """RCM outward liability uses posting date while ITC uses claim period. + Scenario: RCM invoice posted this month but ITC deferred to next month. - This month's report: outward liability shows invoice, ITC does not. - Next month's report: outward liability does not show invoice, ITC does. From 90b2f58fc81be928b959e13a9c3fe484aafca4f2 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 27 Apr 2026 11:46:32 +0530 Subject: [PATCH 3/6] refactor: changes as per review --- .../gstr_3b_report/test_gstr_3b_report.py | 20 +++++++++---------- .../overrides/test_purchase_invoice.py | 2 -- .../gst_india/utils/test_itc_claim.py | 2 -- 3 files changed, 10 insertions(+), 14 deletions(-) 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 bde6669136..a6947127f9 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 @@ -786,14 +786,14 @@ def test_rcm_outward_liability(self): "filter_by": "ITC Claim Period", } ).insert() - out_this = json.loads(report_this.json_output) + output = json.loads(report_this.json_output) # Outward RCM liability always by posting date → invoice IS included - self.assertGreater(out_this["sup_details"]["isup_rev"]["txval"], 0) + self.assertEqual(output["sup_details"]["isup_rev"]["txval"], 100.0) # ITC by claim period → invoice is NOT included (deferred to next month) - itc_this = {r["ty"]: r for r in out_this["itc_elg"]["itc_avl"]} - self.assertEqual(itc_this.get("ISRC", {}).get("camt", 0.0), 0.0) - self.assertEqual(itc_this.get("ISRC", {}).get("samt", 0.0), 0.0) + itc_section = {r["ty"]: r for r in output["itc_elg"]["itc_avl"]} + self.assertEqual(itc_section.get("ISRC", {}).get("camt", 0.0), 0.0) + self.assertEqual(itc_section.get("ISRC", {}).get("samt", 0.0), 0.0) # -- Report for NEXT month (filter_by ITC Claim Period) -- report_next = frappe.get_doc( @@ -806,14 +806,14 @@ def test_rcm_outward_liability(self): "filter_by": "ITC Claim Period", } ).insert() - out_next = json.loads(report_next.json_output) + output = json.loads(report_next.json_output) # Outward RCM by posting date → invoice is NOT in next month's liability - self.assertEqual(out_next["sup_details"]["isup_rev"]["txval"], 0.0) + self.assertEqual(output["sup_details"]["isup_rev"]["txval"], 0.0) # ITC by claim period → invoice IS in next month's ITC - itc_next = {r["ty"]: r for r in out_next["itc_elg"]["itc_avl"]} - self.assertGreater(itc_next.get("ISRC", {}).get("camt", 0.0), 0.0) - self.assertGreater(itc_next.get("ISRC", {}).get("samt", 0.0), 0.0) + itc_section = {r["ty"]: r for r in output["itc_elg"]["itc_avl"]} + self.assertEqual(itc_section.get("ISRC", {}).get("camt", 0.0), 9.0) + self.assertEqual(itc_section.get("ISRC", {}).get("samt", 0.0), 9.0) def create_sales_invoices(): diff --git a/india_compliance/gst_india/overrides/test_purchase_invoice.py b/india_compliance/gst_india/overrides/test_purchase_invoice.py index 4c5b8f3096..99f1102660 100644 --- a/india_compliance/gst_india/overrides/test_purchase_invoice.py +++ b/india_compliance/gst_india/overrides/test_purchase_invoice.py @@ -281,12 +281,10 @@ def test_itc_claim_period_for_unregistered_rcm(self): do_not_submit=True, ) - # Setting to "Deferred" must be allowed pinv.itc_claim_period = ITC_CLAIM_PERIOD_DEFERRED pinv.save() self.assertEqual(pinv.itc_claim_period, ITC_CLAIM_PERIOD_DEFERRED) - # Setting to any future valid period must also be allowed pinv.itc_claim_period = "012099" pinv.save() self.assertEqual(pinv.itc_claim_period, "012099") diff --git a/india_compliance/gst_india/utils/test_itc_claim.py b/india_compliance/gst_india/utils/test_itc_claim.py index 9e116e6722..df237de8d8 100644 --- a/india_compliance/gst_india/utils/test_itc_claim.py +++ b/india_compliance/gst_india/utils/test_itc_claim.py @@ -364,11 +364,9 @@ def test_calc_unregistered_rcm(self): is_reverse_charge=1, posting_date=getdate("2024-01-15"), ) - # posting period unfiled → returns posting period result = _calculate_itc_claim_period(doc, filed=set()) self.assertEqual(result, "012024") - # posting period filed → skips to next unfiled (same as regular invoices) result = _calculate_itc_claim_period(doc, filed={"012024"}) self.assertEqual(result, "022024") From 77ddce90c3029f94c3ec45643e31009263352a45 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 27 Apr 2026 11:57:42 +0530 Subject: [PATCH 4/6] chore: update docstring for RCM outward liability test case --- .../gst_india/doctype/gstr_3b_report/test_gstr_3b_report.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a6947127f9..d54a25cec6 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 @@ -754,7 +754,8 @@ def test_inter_state_advance_payment_entry(self): self.assertEqual(output["sup_details"]["osup_det"]["samt"], 0.0) def test_rcm_outward_liability(self): - """RCM outward liability uses posting date while ITC uses claim period. + """ + RCM outward liability uses posting date while ITC uses claim period. Scenario: RCM invoice posted this month but ITC deferred to next month. - This month's report: outward liability shows invoice, ITC does not. From 30f9106fc2c92790c89bb7541660d176f53fbff2 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 27 Apr 2026 12:00:28 +0530 Subject: [PATCH 5/6] chore: remove comment --- .../doctype/gstr_3b_report/test_gstr_3b_report.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 d54a25cec6..3964ce0b5b 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 @@ -754,13 +754,7 @@ def test_inter_state_advance_payment_entry(self): self.assertEqual(output["sup_details"]["osup_det"]["samt"], 0.0) def test_rcm_outward_liability(self): - """ - RCM outward liability uses posting date while ITC uses claim period. - - Scenario: RCM invoice posted this month but ITC deferred to next month. - - This month's report: outward liability shows invoice, ITC does not. - - Next month's report: outward liability does not show invoice, ITC does. - """ + """RCM outward liability uses posting date while ITC uses claim period.""" today = getdate() next_month = add_months(today, 1) next_period = format_period(next_month) From 730140f165bbbb25168b02d7de94f7639803ff34 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Wed, 6 May 2026 18:59:24 +0530 Subject: [PATCH 6/6] fix: set default filter by "Posting Date" in GSTR3BOutwardInvoices --- india_compliance/gst_india/utils/gstr3b/gstr3b_outward_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/india_compliance/gst_india/utils/gstr3b/gstr3b_outward_data.py b/india_compliance/gst_india/utils/gstr3b/gstr3b_outward_data.py index 2fe9c1c432..d45bfaab57 100644 --- a/india_compliance/gst_india/utils/gstr3b/gstr3b_outward_data.py +++ b/india_compliance/gst_india/utils/gstr3b/gstr3b_outward_data.py @@ -124,6 +124,7 @@ def is_inward_reverse_charge(self, invoice): class GSTR3BOutwardInvoices(GSTR3BCategoryConditions): def __init__(self, filters): self.filters = filters + self.filters.filter_by = "Posting Date" self.inward_query = GSTR3BInwardQuery(filters) self.gstr1_query = GSTR1Query(filters)