ERPNext GST India

ERPNext v16 TDS Broken by ITA 2025: The 'No Tax Withholding data found' Fix

Income Tax Act 2025 restructured India's TDS sections and ERPNext v16 Purchase Invoices now throw 'No Tax Withholding data found for the current posting date'. This guide traces the bug into the source, walks a 4-step Category fix, and ships a bench script to backfill every Tax Withholding Category safely.

MManojJuly 24, 202611 min read
#erpnext#tds#india#tax
Share

On ERPNext v16.16.0, saving a Purchase Invoice against a supplier who has a Tax Withholding Category assigned started failing with "No Tax Withholding data found for the current posting date" — even though the Category clearly exists and the supplier is unchanged. The reason is not a bug in ERPNext. It is that the Income Tax Act 2025 renumbered and restructured India's TDS sections, and the rate rows inside your existing Tax Withholding Category documents no longer cover the current posting date. The fix is small, local, and safe — but only if you understand exactly what the match logic looks for.

If your team hit this the day it went live, you are in good company — issue #54772 is the canonical write-up on the ERPNext repo. I am Manoj, ERPNext lead at MithTech in Bengaluru — we run the India-compliance stack across dozens of client sites and have shipped this fix on production twice this quarter.

The exact error you're seeing

The full error text raised on Purchase Invoice save/submit is:

No Tax Withholding data found for the current posting date.

It surfaces in the browser as a red toast and on the server side as a frappe.ValidationError. Documents affected: Purchase Invoice, Payment Entry (advance TDS), and Journal Entry whenever an entry line references a supplier with a tax_withholding_category and the posting date falls outside every rate row's window inside that Category.

Sales Invoice can raise the same error under the equivalent flow — TCS on 206C(1H) — because the same TaxWithholdingCategory.get_applicable_tax_row method backs both directions. The bug is not currency-specific and not supplier-specific. It is date-specific.

What actually broke — Tax Withholding Category internals

The Tax Withholding Category DocType stores period-specific rates in a child table called Tax Withholding Rate. Its JSON at erpnext/accounts/doctype/tax_withholding_rate/tax_withholding_rate.json defines the fields that drive the match:

"field_order": [
  "from_date",
  "to_date",
  "tax_withholding_group",
  "column_break_3",
  "tax_withholding_rate",
  "cumulative_threshold",
  "single_threshold"
]

Three of those fields are load-bearing at match time: from_date, to_date, and tax_withholding_group. The controller method that reads them lives in erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py:

def get_applicable_tax_row(self, posting_date, tax_withholding_group):
    for row in self.rates:
        if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
            row.tax_withholding_group
        ) == cstr(tax_withholding_group):
            return row

    frappe.throw(_("No Tax Withholding data found for the current posting date."))

The match is a linear scan with two ANDed conditions: the date must fall inside a row's window, and the row's tax_withholding_group must equal the group the caller passed in. If either check fails on every row, the loop exits and the throw fires.

The caller sits a few lines down in the same file — TaxWithholdingDetails.get() at line 132 iterates over the categories a supplier is subject to and calls doc.get_applicable_tax_row(self.posting_date, self.tax_withholding_group). Nothing catches the throw. It bubbles all the way to the Purchase Invoice submit handler.

Why the failure is silent-until-submit

get_applicable_tax_row is called during the tax computation that runs on Purchase Invoice save (via TaxWithholdingDetails.get(), invoked from the accounts controller). The user typed nothing wrong — they picked a supplier, added items, hit Save. The failure isn't in their input; it's in seed data that quietly went out of date. This is exactly the class of bug a static gate at the Category level cannot catch — the Category validates fine on its own (validate_dates() at line 46 only checks for row overlap, not coverage of any particular future date). It only breaks when combined with a specific posting date.

Before ITA 2025, most sites had a top row in each Category ending 2025-03-31 and no successor row. The moment somebody raised a Purchase Invoice dated 2025-04-01 or later against a TDS supplier — throw.

CalculatorTDS Category Inspectorlink

Pick a TDS section — see the pre-ITA 2025 vs post-ITA 2025 rate and threshold, with a 'verify with your CA' caveat.

Section inspector

Which TDS section broke — and what to compare against

Pre-ITA 2025 (what your Category holds today)
1% (individual) / 2% (others)
Threshold: ₹30,000 single / ₹1,00,000 aggregate
Post-ITA 2025 (row you must add)
Restated under ITA 2025 — verify
Threshold: Threshold slabs restated — verify
Scope of 194C
Payments to contractors / sub-contractors
Section 194C exists in the new Act under a renumbered clause; slabs and rates were restated. Confirm the exact code before setting up the Category.
Illustrative only. Rates and thresholds change with every Finance Act and every CBDT notification. Verify the exact ITA 2025 figures with your CA before you save the Category or submit an invoice.

The four-step fix

Fixing this correctly at the Desk level takes about ten minutes per Category. No code changes, no version upgrade, no monkey-patching.

InteractiveFour-Step Fix Walkthroughlink

Click through the four steps: identify affected Category → add ITA 2025 rate row → save → test on a Purchase Invoice.

The 4-step fix

Click a step — see exactly what to do

Step 1 of 4
Identify the affected Category

Open Tax Withholding Category list. Filter by the section (194C, 194J, …) your Purchase Invoice referenced. Open its Rates table and note the highest to_date — anything ending before your invoice's posting_date is why get_applicable_tax_row raised.

1 / 4

The critical detail people miss on step 2 is the tax_withholding_group column. If your existing rows use 194C-Individual and 194C-Company as separate groups (a common pattern where different payee types attract different rates), you need one new row per group — not one row for the whole Category. The cstr(row.tax_withholding_group) == cstr(tax_withholding_group) check is strict; None matches "" (both cstr to "") but "" does not match "194C-Individual".

Use the builder below to compose a well-formed row and paste it straight into the Desk grid — or into a fixture.

CalculatorRate Row Builderlink

Compose a Tax Withholding Rate row (from_date, to_date, rate, single & cumulative thresholds) → get a JSON snippet ready to paste into bench console or a fixture.

Rate row builder

Compose a Tax Withholding Rate child row

tax_withholding_rate.json
{
  "doctype": "Tax Withholding Rate",
  "from_date": "2025-04-01",
  "to_date": "2026-03-31",
  "tax_withholding_rate": 1,
  "single_threshold": 30000,
  "cumulative_threshold": 100000,
  "tax_withholding_group": null
}
The rate above is a placeholder. Do not save a Category with a rate you have not confirmed with a qualified tax advisor for your specific section under ITA 2025.

Seeding all categories at once (bench script)

If your site has three or four Categories, do them by hand. If you have twenty — or you're an implementer with a fleet of client sites hitting the same wall this week — the safer move is a bench console snippet that reads every Category, checks whether any existing row covers today's date, and appends a placeholder ITA 2025 row where none is found.

Two design choices are load-bearing:

  1. The placeholder rate is 0.00%. It unblocks get_applicable_tax_row so the error stops. It is also visibly wrong, so any invoice that submits under it will be flagged by your reconciliation review before it goes to the government. Fail loud, not silent.
  2. Nothing is committed until you say so. The script prints its plan and leaves frappe.db.commit() for you to run separately. If the plan looks wrong, frappe.db.rollback() walks it back cleanly.
Code recipeBench + Fixtures Seed Recipeslink

Two recipes to seed missing rate rows across every Tax Withholding Category in one shot: a bench console script and a hooks.py fixture pattern for your custom app.

Seed recipes

Backfill every Category — bench or fixtures

Interactive backfill. Reads every Tax Withholding Category, checks whether an existing rate row covers today, and appends a placeholder ITA 2025 row where missing. Prints a report — nothing is committed until you confirm.

seed_ita_2025.py
# bench --site your-site.local console
import frappe
from frappe.utils import getdate

TODAY = getdate()
FY_FROM = "2025-04-01"
FY_TO   = "2026-03-31"

# Placeholder rate — every category gets the same 0.00% marker so a human MUST
# review before the row is usable. Filtering by 0.00 later flags what still
# needs a real CA-signed value.
PLACEHOLDER_RATE = 0.00
NOTE = "ITA 2025 — verify rate with CA before submitting invoices"

report = {"seeded": [], "already_ok": [], "skipped": []}

for name in frappe.get_all("Tax Withholding Category", pluck="name"):
    doc = frappe.get_doc("Tax Withholding Category", name)

    # Group rows by tax_withholding_group; get_applicable_tax_row matches on
    # (group, posting_date) together, so a per-group check is the right lens.
    from collections import defaultdict
    by_group = defaultdict(list)
    for r in doc.rates:
        by_group[r.tax_withholding_group or ""].append(r)

    for group, rows in by_group.items():
        covered = any(
            getdate(r.from_date) <= TODAY <= getdate(r.to_date) for r in rows
        )
        if covered:
            report["already_ok"].append((name, group))
            continue

        doc.append("rates", {
            "from_date": FY_FROM,
            "to_date":   FY_TO,
            "tax_withholding_rate":  PLACEHOLDER_RATE,
            "single_threshold":      0,
            "cumulative_threshold":  0,
            "tax_withholding_group": group or None,
        })
        report["seeded"].append((name, group))

    try:
        doc.save(ignore_permissions=True)
    except frappe.ValidationError as e:
        # validate_dates() rejects overlapping rows — safe: your data is fine.
        report["skipped"].append((name, str(e)))
        frappe.db.rollback()

print("Seeded:",     len(report["seeded"]))
print("Already ok:", len(report["already_ok"]))
print("Skipped:",    len(report["skipped"]))
for row in report["seeded"][:20]:
    print("  +", row)

# Nothing is committed until you commit explicitly. Review, then:
# frappe.db.commit()
Placeholder rate is 0.00% on purpose. It unblocks get_applicable_tax_row but is visibly wrong until a human sets the real figure — a safe fail-loud.
Do not hard-code non-zero rates in a public app or fixture. TDS rates change with every Finance Act — commit the shape, source the value from your CA every FY.

Automation gate

The seed script unblocks today's invoices. The next step is stopping the same class of error surprising a buyer six months from now, when FY 2026-27 opens and someone forgot to add rows.

Wire a doc_events guard on Purchase Invoice validate that reads every referenced tax_withholding_category and warns — loudly, at save time — if no rate row covers the invoice's posting_date. This runs before get_applicable_tax_row gets a chance to throw, so the message is your polite warning rather than ERPNext's cryptic one.

# custom_ita_2025/hooks.py
doc_events = {
    "Purchase Invoice": {
        "validate": "custom_ita_2025.tds.guards.warn_if_stale_twc",
    }
}

# custom_ita_2025/tds/guards.py
import frappe
from frappe import _
from frappe.utils import getdate

def warn_if_stale_twc(doc, method=None):
    if doc.doctype != "Purchase Invoice":
        return

    categories = {
        i.tax_withholding_category
        for i in (doc.get("items") or [])
        if i.tax_withholding_category
    }
    supplier_twc = frappe.db.get_value("Supplier", doc.supplier, "tax_withholding_category")
    if supplier_twc:
        categories.add(supplier_twc)

    posting = getdate(doc.posting_date)

    for name in categories:
        rates = frappe.get_all(
            "Tax Withholding Rate",
            filters={"parent": name},
            fields=["from_date", "to_date", "tax_withholding_group"],
        )
        covered = any(
            getdate(r.from_date) <= posting <= getdate(r.to_date) for r in rates
        )
        if not covered:
            frappe.msgprint(
                _(
                    "Tax Withholding Category <b>{0}</b> has no rate row covering "
                    "the posting date <b>{1}</b>. Add an ITA 2025 row before submit, "
                    "or the tax computation will fail with 'No Tax Withholding data found'."
                ).format(name, doc.posting_date),
                title=_("Stale TDS Category"),
                indicator="orange",
            )

Deploy this as a frappe.msgprint warning first — collect two weeks of feedback from the buying team. Only escalate to frappe.throw once you're confident there are no false positives on advance invoices, foreign-currency invoices, or reverse-charge cases in your specific setup.

If you prefer a client-side variant so buyers see the warning before they even hit Save, the same logic works as a frappe.ui.form.on('Purchase Invoice', { validate: … }) script — call frappe.db.get_list('Tax Withholding Rate', { filters, fields }) and frappe.msgprint() the same message.

What we DON'T recommend

Do not monkey-patch get_applicable_tax_row

The tempting fix, when you're staring at the throw at 6pm on a submit deadline, is to override the method to return the most recent row instead of raising. Please do not. The throw is the entire correctness contract — it forces a conscious human decision about which rate applies to a payment. Silencing it will land your books with rows applied under the wrong rate for a period nobody remembers configuring, and that mistake is much more expensive at assessment time than a missed evening.

Do not commit specific rate percentages into public code

The rate schedule under ITA 2025 will be amended by CBDT notifications and future Finance Acts, as every year's schedule has been. If you ship a custom app that hard-codes 1.0 or 10.0 into a fixture, every downstream site inherits your obsolete number the moment it changes. Commit the shape (Category names, from/to dates, groups). Source the values from your CA every FY and load them via a site-local bench console run, not a code push.

The India-specific TDS handling was moved out of ERPNext core into the separate india_compliance app during v14 (see erpnext/patches/v14_0/remove_india_localisation.py). If you're running v16 and haven't installed india_compliance, your Categories are whatever a previous implementer created by hand — there is no "official" seed to fall back on. That's another reason a per-site, CA-verified seed is the correct pattern rather than a code-pushed fixture.

FAQ

+Does this error only affect v16.16.0, or later patch versions too?

The error string and the throwing code path are unchanged in erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py up to the current v16.28.0 checkout we verified against. It is not a regression that gets patched — it is the correct behaviour when your Category has no matching row. Upgrading ERPNext will not add the rows for you; only your data change will.

+Why can I save the Category successfully but Purchase Invoice still throws?

Because TaxWithholdingCategory.validate_dates() — at line 46 of the same file — only checks that rows don't overlap. It does not check that some row covers today's date, or any future date. A Category with rate rows ending in 2024 is perfectly valid to save; it just cannot answer a lookup for 2025 or later. The coverage check lives on the invoice-save code path, not the Category-save one.

+Do I need india_compliance installed for the fix to work?

No. The Tax Withholding Category and Tax Withholding Rate DocTypes are in ERPNext core (under erpnext/accounts/doctype/). The india_compliance app adds GST-adjacent tooling on top, but the TDS Category structure and the get_applicable_tax_row code path exist without it. The fix is site-local ERPNext data.

+What's the difference between single_threshold and cumulative_threshold?

Both are floats on the Tax Withholding Rate child table. single_threshold is the per-transaction limit above which TDS applies to that single payment; cumulative_threshold is the year-to-date aggregate limit above which TDS applies to every payment once crossed. validate_thresholds() in the controller enforces cumulative_threshold >= single_threshold. Setting one to zero disables that check, which is common for sections like 194Q where only the aggregate matters.

+What if my rate row's tax_withholding_group is blank — does the match still work?

Yes, as long as the caller also passes a blank group. cstr() on the ERPNext side turns both None and "" into "", so a NULL row group matches a NULL caller group. If your supplier's Category has non-empty group values (e.g. 194C-Individual), your caller must pass the same string exactly. This is the second-most-common cause of "why is my row not matching" after the date window.

+Should I edit an existing row's to_date or add a new row?

Add a new row. Editing history rewrites the meaning of past submitted invoices — it makes the row look like it was in effect during a period it wasn't. Auditors reading Tax Withholding Entry records against Category history will notice. A new row keyed to 2025-04-01 → 2026-03-31 preserves the audit trail cleanly.

+Does this affect Lower Deduction Certificate (LDC) supplier flows?

Not directly — LDC has its own matching path in TaxWithholdingDetails.get_valid_ldc_records() using valid_from/valid_upto against Lower Deduction Certificate documents. But the underlying Category is still looked up first, so if your Category has no rate row for the posting date, the throw fires before LDC ever gets a chance to apply. Fix the Category, then LDC works as before.

+Can I roll the placeholder 0.00% row out to production or is it dev-only?

Only if your reconciliation process will catch a 0% TDS deduction before payment. On a well-run site, TDS Computation Summary or a nightly variance report will flag any invoice that computed zero TDS against a supplier who should have been deducted from. If your process is weaker than that, do the CA-verified rate first for the top few supplier volumes and only use 0.00% for the long tail. The placeholder is a safe fail-loud, not a safe production value.

Closing

The "No Tax Withholding data found" throw is one of the few ERPNext error messages that is doing its job perfectly. It is refusing to guess a TDS rate on your behalf when your Category tells it nothing about the current period. The fix — add a rate row for FY 2025-26 per group, then gate future stale-ness with a doc_events warning — is a ten-minute Desk change wrapped in a two-week rollout plan.

Don't monkey-patch. Don't commit CA-sensitive rate values into a public fixture. Do add the rows, do wire the guard, and do get your CA to sign off on the actual percentages under ITA 2025 before you submit real invoices.

Running ERPNext across multiple sites and hitting this on all of them?

We run the bench-console backfill, deploy the doc_events guard, and hand your CA a rate-row sign-off template so this year's ITA 2025 mess and next year's Finance Act update land as scheduled tasks rather than fire drills.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Keep reading

See what this looks like for your business

Book a free 30-minute audit. We'll map your workflows, find where time and money leak, and design an open-source stack you actually own — no per-user licence fees.

Book a consultation
0 0
Published on 24 July 2026

Manoj

Comments

No comments yet. Start a new discussion.

Ctrl+Enter to add comment