Skip to content

fix: add tax_id update for Tax Withholding Entry#4110

Open
ljain112 wants to merge 6 commits intoresilient-tech:developfrom
ljain112:fix-tds-taxid
Open

fix: add tax_id update for Tax Withholding Entry#4110
ljain112 wants to merge 6 commits intoresilient-tech:developfrom
ljain112:fix-tds-taxid

Conversation

@ljain112
Copy link
Copy Markdown
Member

Override Tax ID with PAN and update existing entries.

depends on: frappe/erpnext#53598

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 18, 2026

Confidence Score: 4/5

  • Safe to merge with minor follow-up recommended for non-Supplier/Customer party type handling.
  • Core logic is correct and well-scoped: the one-line override, the patch query, and the hook registration are all straightforward. Tests use dynamic unique PANs to avoid cross-run pollution (addressing prior review feedback). The only substantive concern is the missing fallback in get_tax_id_for_party for party types without a pan field, which could silently null out tax_id for non-Supplier/Customer TDS scenarios. This is a low-risk edge case for the current India compliance context but worth a guard clause.
  • india_compliance/income_tax_india/overrides/tax_withholding_category.py — no fallback for party types without a pan field.

Important Files Changed

Filename Overview
india_compliance/hooks.py Registers get_tax_id_for_party as a regional override for India, routing TDS party ID lookups to the IC implementation that returns pan instead of tax_id.
india_compliance/income_tax_india/overrides/tax_withholding_category.py Adds a one-line override of get_tax_id_for_party that returns the pan field for Supplier/Customer. No fallback for party types that don't carry a pan field (e.g., Employee), which would silently return None instead of the original tax_id.
india_compliance/income_tax_india/overrides/test_tax_withholding_category.py New integration test file covering PAN-based TDS deduction, threshold aggregation across shared-PAN parties, and LDC application. Tests use generate_unique_pan() to avoid cross-run pollution, but existing_pans.add(pan) in that helper is dead code (no-op when element is already in the set).
india_compliance/patches/v16/update_tax_id_for_tax_withholding_entries.py Migration patch that backfills tax_id with the party's pan for all non-migration Tax Withholding Entries in Indian companies. Logic is correct: INNER JOIN on party, filters on non-empty PAN, and excludes migration-created entries via created_by_migration == 0.
india_compliance/patches.txt Appends the new v16 patch entry; trailing newline added, resolving a pre-existing POSIX convention issue.

Reviews (7): Last reviewed commit: "fix: replace random PAN generation with ..." | Re-trigger Greptile

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an India regional override routing ERPNext's tax-withholding tax ID lookup to an India-specific implementation. Implements get_tax_id_for_party(party_type, party) to fetch a party's PAN. Introduces integration tests covering PAN retrieval, TDS calculation, cumulative threshold behavior for parties sharing a PAN, and Lower Deduction Certificate handling. Adds a v16 patch and patch entry update_tax_id_for_tax_withholding_entries to backfill Tax Withholding Entry.tax_id from linked supplier/customer PANs for Indian companies.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: add tax_id update for Tax Withholding Entry' accurately summarizes the main change - adding functionality to update the tax_id field for Tax Withholding Entry records, with PAN values.
Description check ✅ Passed The description 'Override Tax ID with PAN and update existing entries' directly relates to the changeset, which adds an override hook, new function, patch, and tests for updating tax_id with PAN values in Tax Withholding Entry records.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
india_compliance/patches/v16/update_tax_id_for_tax_withholding_entries.py (1)

23-33: Consider chunking updates to reduce migration lock time on large sites.

Line 23-Line 33 runs one broad UPDATE per party type. For high-volume Tax Withholding Entry tables, chunking (e.g., by company) can reduce lock duration during migrate windows.

Proposed refactor (company-wise chunks)
 def execute():
@@
-    update_tax_id("Supplier", indian_companies)
-    update_tax_id("Customer", indian_companies)
+    update_tax_id("Supplier", indian_companies)
+    update_tax_id("Customer", indian_companies)
@@
 def update_tax_id(party_type, companies):
     twe = frappe.qb.DocType("Tax Withholding Entry", alias="twe")
     party = frappe.qb.DocType(party_type, alias="party")
-
-    (
-        frappe.qb.update(twe)
-        .join(party)
-        .on(twe.party == party.name)
-        .set(twe.tax_id, party.pan)
-        .where(twe.party_type == party_type)
-        .where(twe.company.isin(companies))
-        .where(party.pan.isnotnull())
-        .where(party.pan != "")
-        .where((twe.tax_id.isnull()) | (twe.tax_id == ""))
-        .where(twe.created_by_migration == 0)
-        .run()
-    )
+    for company in companies:
+        (
+            frappe.qb.update(twe)
+            .join(party)
+            .on(twe.party == party.name)
+            .set(twe.tax_id, party.pan)
+            .where(twe.party_type == party_type)
+            .where(twe.company == company)
+            .where(party.pan.isnotnull())
+            .where(party.pan != "")
+            .where((twe.tax_id.isnull()) | (twe.tax_id == ""))
+            .where(twe.created_by_migration == 0)
+            .run()
+        )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4e04f148-bd25-4f82-abb6-1dd58709fe4e

📥 Commits

Reviewing files that changed from the base of the PR and between 95759e3 and 58c7bb4.

📒 Files selected for processing (5)
  • india_compliance/hooks.py
  • india_compliance/income_tax_india/overrides/tax_withholding_category.py
  • india_compliance/income_tax_india/overrides/test_tax_withholding_category.py
  • india_compliance/patches.txt
  • india_compliance/patches/v16/update_tax_id_for_tax_withholding_entries.py

@ljain112
Copy link
Copy Markdown
Member Author

@greptileai

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
india_compliance/income_tax_india/overrides/test_tax_withholding_category.py (1)

169-176: Assert exact Tax Withholding Entry count before checking row fields.

The current assertion validates only the first row; if multiple rows are unexpectedly created, this can mask regressions.

Suggested test-hardening diff
         twe_rows = frappe.get_all(
             "Tax Withholding Entry",
             filters={"parenttype": "Purchase Invoice", "parent": pi.name},
             fields=["tax_id", "lower_deduction_certificate"],
         )
-        self.assertTrue(twe_rows)
+        self.assertEqual(len(twe_rows), 1)
         self.assertEqual(twe_rows[0].tax_id, pan)
         self.assertEqual(twe_rows[0].lower_deduction_certificate, ldc_doc.name)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53d7a150-db05-4877-adda-20adcc158e3e

📥 Commits

Reviewing files that changed from the base of the PR and between 58c7bb4 and bdf4344.

📒 Files selected for processing (2)
  • india_compliance/income_tax_india/overrides/test_tax_withholding_category.py
  • india_compliance/patches.txt
🚧 Files skipped from review as they are similar to previous changes (1)
  • india_compliance/patches.txt

@ljain112
Copy link
Copy Markdown
Member Author

@greptileai

@ljain112
Copy link
Copy Markdown
Member Author

@greptileai

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
india_compliance/income_tax_india/overrides/test_tax_withholding_category.py (1)

218-243: Make existing-category setup fully deterministic by resetting key flags.

When a category already exists, calling this helper without explicit kwargs can retain stale fields from prior state. Reset disable_transaction_threshold explicitly for deterministic fixtures.

♻️ Suggested idempotency tweak
 def create_tax_withholding_category(category_name, account_name, **kwargs):
     fiscal_year = get_fiscal_year(today(), company=COMPANY, as_dict=True)
     tax_withholding_rate = kwargs.pop("tax_withholding_rate", 10)
     single_threshold = kwargs.pop("single_threshold", 0)
     cumulative_threshold = kwargs.pop("cumulative_threshold", 0)
+    disable_transaction_threshold = kwargs.pop("disable_transaction_threshold", 0)
@@
-    doc.update(kwargs)
+    doc.update(
+        {
+            "disable_transaction_threshold": disable_transaction_threshold,
+            **kwargs,
+        }
+    )
     doc.set("accounts", [account_row])
     doc.set("rates", [rate_row])
     doc.save()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c62d542-681e-467d-96f6-2c2a9a653732

📥 Commits

Reviewing files that changed from the base of the PR and between bdf4344 and 68bdd5f.

📒 Files selected for processing (3)
  • india_compliance/income_tax_india/overrides/test_tax_withholding_category.py
  • india_compliance/patches.txt
  • india_compliance/patches/v16/update_tax_id_for_tax_withholding_entries.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • india_compliance/patches.txt
  • india_compliance/patches/v16/update_tax_id_for_tax_withholding_entries.py

Comment on lines +171 to +178
twe_rows = frappe.get_all(
"Tax Withholding Entry",
filters={"parenttype": "Purchase Invoice", "parent": pi.name},
fields=["tax_id", "lower_deduction_certificate"],
)
self.assertTrue(twe_rows)
self.assertEqual(twe_rows[0].tax_id, pan)
self.assertEqual(twe_rows[0].lower_deduction_certificate, ldc_doc.name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid indexing twe_rows[0] without asserting row cardinality.

frappe.get_all ordering/cardinality assumptions can make this test brittle. Assert exactly one row (or iterate deterministically) before field checks.

✅ Suggested test hardening
         twe_rows = frappe.get_all(
             "Tax Withholding Entry",
             filters={"parenttype": "Purchase Invoice", "parent": pi.name},
             fields=["tax_id", "lower_deduction_certificate"],
         )
         self.assertTrue(twe_rows)
-        self.assertEqual(twe_rows[0].tax_id, pan)
-        self.assertEqual(twe_rows[0].lower_deduction_certificate, ldc_doc.name)
+        self.assertEqual(len(twe_rows), 1)
+        row = twe_rows[0]
+        self.assertEqual(row.tax_id, pan)
+        self.assertEqual(row.lower_deduction_certificate, ldc_doc.name)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
twe_rows = frappe.get_all(
"Tax Withholding Entry",
filters={"parenttype": "Purchase Invoice", "parent": pi.name},
fields=["tax_id", "lower_deduction_certificate"],
)
self.assertTrue(twe_rows)
self.assertEqual(twe_rows[0].tax_id, pan)
self.assertEqual(twe_rows[0].lower_deduction_certificate, ldc_doc.name)
twe_rows = frappe.get_all(
"Tax Withholding Entry",
filters={"parenttype": "Purchase Invoice", "parent": pi.name},
fields=["tax_id", "lower_deduction_certificate"],
)
self.assertTrue(twe_rows)
self.assertEqual(len(twe_rows), 1)
row = twe_rows[0]
self.assertEqual(row.tax_id, pan)
self.assertEqual(row.lower_deduction_certificate, ldc_doc.name)

@ljain112
Copy link
Copy Markdown
Member Author

@greptileai

@ljain112
Copy link
Copy Markdown
Member Author

@greptileai

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
india_compliance/income_tax_india/overrides/test_tax_withholding_category.py (1)

169-176: ⚠️ Potential issue | 🟡 Minor

Assert row cardinality before indexing twe_rows[0].

This assertion path is brittle if more than one Tax Withholding Entry row is returned.

✅ Suggested hardening
         twe_rows = frappe.get_all(
             "Tax Withholding Entry",
             filters={"parenttype": "Purchase Invoice", "parent": pi.name},
             fields=["tax_id", "lower_deduction_certificate"],
         )
         self.assertTrue(twe_rows)
-        self.assertEqual(twe_rows[0].tax_id, pan)
-        self.assertEqual(twe_rows[0].lower_deduction_certificate, ldc_doc.name)
+        self.assertEqual(len(twe_rows), 1)
+        row = twe_rows[0]
+        self.assertEqual(row.tax_id, pan)
+        self.assertEqual(row.lower_deduction_certificate, ldc_doc.name)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e307c6a6-fcc3-446a-87e6-a566dedf0bb9

📥 Commits

Reviewing files that changed from the base of the PR and between 68bdd5f and 09e48b7.

📒 Files selected for processing (1)
  • india_compliance/income_tax_india/overrides/test_tax_withholding_category.py

Comment on lines +1 to +2
import random
import string
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace random-based PAN generation to avoid Ruff S311 CI failures.

generate_random_pan() uses random.choices/random.choice, which is flagged by Ruff S311 (as already reported). This can block lint even in tests.

✅ Suggested fix
-import random
+import secrets
 import string
@@
 def generate_random_pan():
     return (
-        "".join(random.choices(string.ascii_uppercase, k=5))
-        + "".join(random.choices(string.digits, k=4))
-        + random.choice(string.ascii_uppercase)
+        "".join(secrets.choice(string.ascii_uppercase) for _ in range(5))
+        + "".join(secrets.choice(string.digits) for _ in range(4))
+        + secrets.choice(string.ascii_uppercase)
     )

Also applies to: 199-204

Comment on lines +22 to +32
(
frappe.qb.update(twe)
.join(party)
.on(twe.party == party.name)
.set(twe.tax_id, party.pan)
.where(twe.party_type == party_type)
.where(twe.company.isin(companies))
.where(party.pan.isnotnull())
.where(party.pan != "")
.where(twe.created_by_migration == 0)
.run()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inner JOIN silently excludes entries for deleted parties

frappe.qb.update().join() generates an INNER JOIN, so any Tax Withholding Entry whose linked party record has since been deleted will be excluded from the update and its tax_id will remain stale. For most real-world deployments this is acceptable (there's no PAN to look up for a deleted party), but it's worth a comment to make the intent clear to future maintainers.

Suggested change
(
frappe.qb.update(twe)
.join(party)
.on(twe.party == party.name)
.set(twe.tax_id, party.pan)
.where(twe.party_type == party_type)
.where(twe.company.isin(companies))
.where(party.pan.isnotnull())
.where(party.pan != "")
.where(twe.created_by_migration == 0)
.run()
(
frappe.qb.update(twe)
.join(party) # INNER JOIN: entries for deleted parties are intentionally skipped
.on(twe.party == party.name)

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
india_compliance/income_tax_india/overrides/test_tax_withholding_category.py (1)

199-219: ⚠️ Potential issue | 🟡 Minor

Replace random with secrets to pass Ruff S311 lint check.

While not a security concern in test code, Ruff S311 flags random.choices/random.choice and will fail CI. The past review already noted this.

✅ Suggested fix
-import random
+import secrets
 import string
 ...
 def generate_unique_pan():
     existing_pans = frappe.get_all(
         "Supplier", pluck="pan", filters={"pan": ("is", "set")}
     )
     existing_pans += frappe.get_all(
         "Customer", pluck="pan", filters={"pan": ("is", "set")}
     )
     existing_pans = set(existing_pans)

     for _ in range(100):
-        letters = "".join(random.choices(string.ascii_uppercase, k=5))
-        digits = "".join(random.choices(string.digits, k=4))
-        suffix = random.choice(string.ascii_uppercase)
+        letters = "".join(secrets.choice(string.ascii_uppercase) for _ in range(5))
+        digits = "".join(secrets.choice(string.digits) for _ in range(4))
+        suffix = secrets.choice(string.ascii_uppercase)
         pan = f"{letters}{digits}{suffix}"

         if pan not in existing_pans:
             return pan

         existing_pans.add(pan)

     raise RuntimeError("Unable to generate unique PAN")

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aaf0db48-9af3-46d8-8dbd-75f04ce80591

📥 Commits

Reviewing files that changed from the base of the PR and between 09e48b7 and 3301b7a.

📒 Files selected for processing (1)
  • india_compliance/income_tax_india/overrides/test_tax_withholding_category.py

@ljain112
Copy link
Copy Markdown
Member Author

@greptileai

Comment on lines +21 to +22
def get_tax_id_for_party(party_type, party):
return frappe.db.get_value(party_type, party, "pan")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No fallback for party types without a pan field

frappe.db.get_value(party_type, party, "pan") silently returns None if the pan field doesn't exist on the requested DocType (e.g. "Employee" or "Company"). ERPNext's TDS framework can apply withholding tax to party types beyond Supplier and Customer, so if this override is ever invoked for such a type, tax_id will be set to None rather than falling back to the original tax_id field value.

Consider returning the original field for party types where pan is not applicable:

def get_tax_id_for_party(party_type, party):
    if party_type in ("Supplier", "Customer"):
        return frappe.db.get_value(party_type, party, "pan")
    return frappe.db.get_value(party_type, party, "tax_id")

@ljain112
Copy link
Copy Markdown
Member Author

@Mergifyio rebase

@mergify
Copy link
Copy Markdown
Contributor

mergify bot commented Mar 25, 2026

rebase

✅ Branch has been successfully rebased

@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 1, 2026

Not up to standards ⛔

🔴 Issues 3 medium

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
Security 3 medium

View in Codacy

🟢 Metrics 32 complexity

Metric Results
Complexity 32

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant