Skip to content
ERPNext Data Model
Esc
navigateopen⌘Jpreview
On this page

TDS/TCS Computation & Withholding Summary

A design reference for the country-specific layer supplying statutory withholding data and reporting on top of a generic tax-withholding engine

1. Requirements

1.1 Functional requirements

  • Supply the country-specific data a generic withholding engine needs to compute Tax Deducted at Source (TDS) and Tax Collected at Source (TCS): statutory section codes, an entity-type classification, and a rule for resolving a party’s tax identity.
  • Bootstrap a withholding ledger account and starter withholding category rules automatically whenever a Legal Entity’s country is set to India, so an administrator does not have to hand-enter statutory rates first.
  • Resolve a party’s tax identity as its Permanent Account Number (PAN), not a generic tax-identifier field, for Customer and Supplier specifically; every other party type resolves to no identity.
  • Because the engine’s cumulative-threshold and lower-deduction matching groups entries by whichever identity this resolution returns, two Customer/Supplier records sharing one PAN must be tracked as a single statutory identity, not two independent ones.
  • Keep a superseded, pre-current-regime section list as read-only historical reference alongside the section list active under the current framework, searchable by code or description.
  • Extend the two generic withholding reports with the statutory section codes and entity-type classification, without altering either report’s row set or figures.
  • Invalidate a small per-Legal-Entity cache of withholding-eligible ledger accounts whenever a category’s configuration changes.

1.2 Non-functional requirements

  • Idempotency: the bootstrap fires on every save of a Legal Entity, not only creation, so repeated saves must never duplicate ledger accounts or category rules.
  • Non-destructive reconciliation: bootstrapping never overwrites a rate row or account link an administrator already customized — it only adds what is missing.
  • Versioned statutory data: section codes, entity-type classification, and rate/threshold rows change with the statutory framework, so this reference data is dated content, not a fixed constant.
  • Extension without forking the generic engine: statutory columns are added by inheriting and widening the generic reports, not by duplicating their logic.
  • Graceful degradation: an unresolvable tax identity, a bootstrap conflict, or a section search matching nothing must all degrade to a safe default rather than blocking a save or a report.

1.3 Constraints

  • The bootstrap only fires for a Legal Entity whose country is India, resolved at call time by a per-country override lookup, not hard-wired into the generic engine’s own path.
  • Only Customer and Supplier can carry a resolvable tax identity; a withholding-eligible transaction against any other party type (for example a Settlement Entry or Journal Entry counterparty) posts with no tax identity attached.
  • The historical, pre-current-regime section field is read-only — descriptive metadata, not an editable surface.
  • Salary-related withholding is out of scope; nothing in the reviewed source computes or reports it against payroll.

2. High-Level Design

2.1 Component diagram

This is a short annotated procedure rather than a branching graph:

  1. A Legal Entity record is saved with country India. The bootstrap fires from a save-lifecycle hook.
  2. Resolve or create the withholding ledger account under the Legal Entity’s own chart of accounts, reusing the same account-provisioning routine used for other default company accounts (see the general ledger reference).
  3. Load the bundled rule dataset and filter to prospective rows — any rate/threshold row whose date range has already elapsed is dropped, so a fresh install never seeds a dead historical row.
  4. For each remaining rule, look for an existing category matching the same section and entity-type pair: if none, insert one carrying this Legal Entity’s account row and the prospective rate rows; if found, reconcile in place — append the account row only if missing, and a new rate row only if its start date is not earlier than the latest end date already stored, so a re-run never duplicates history.
  5. Save tolerantly. The reconciliation save treats the account table as non-mandatory and catches its own validation failures, logging an error against the affected category rather than aborting the Legal Entity’s save or the rest of the loop.

2.3 Data flow — from transaction posting to statutory report


3. Deep Dive

3.1 Data model

Statutory Section Reference A versioned, in-code reference of withholding sections: a superseded list reflecting the prior framework, kept read-only as history, and a current-regime list where each entry carries a numeric code, a citation-style reference, and a description. A small entity-type classification (individual, company, and a party with no valid/invalid PAN, among others) sits alongside it, since rate depends on which of these a party falls into.

Bundled rule dataset A versioned set of category rules shipped with this layer, each naming a section, an entity-type, a category label, and dated rate rows — each carrying its own single-transaction and cumulative thresholds over a fixed date range. This is the data the bootstrap (§2.2) inserts into Tax Withholding Category below; it is reference data, not executable logic.

Statutory Tax Identity Override A per-country swap-in for the engine’s party-to-tax-identity lookup: for Customer or Supplier it returns PAN; for any other party type, an empty identity — rather than falling back to a generic tax-identifier field. Because the engine’s cumulative-threshold accumulation and lower-deduction matching both key off this identity, this one substitution lets two Customer/Supplier records sharing one PAN be tracked as a single statutory identity.

Withholding Category Bootstrap and withholding-account cache The bootstrap is not a stored record but the save-triggered reconciliation procedure in §2.2 that keeps each Legal Entity’s category rows aligned with the bundled dataset. The cache is a small per-Legal-Entity list of withholding-flagged ledger accounts, populated on demand from Tax Withholding Account rows and cleared on category change.

Tax Withholding Category / Account / Entry, and Lower Deduction Certificate (core-side, cross-referenced only) These belong to the generic engine: the category holds dated rate/threshold rows and accounts per Legal Entity; the account maps a Legal Entity to its ledger account; the entry is the immutable ledger row the engine writes per computed deduction or collection, and the only source either report in §3.4 reads. A Lower Deduction Certificate can separately authorize a reduced rate up to a limit, also matched by identity, so the same PAN-sharing extends to certificate eligibility. This layer feeds data into the category and an identity value into the rest, without re-implementing their computation.

3.2 Interception points and wiring mechanisms

This layer has no engine of its own — every contribution attaches through one of four mechanisms:

  1. Lifecycle event hooks. A save-time hook on the Legal Entity record fires the bootstrap on every save, not only creation. A change hook on the Tax Withholding Category clears the account cache on edit.
  2. A per-country override map, resolved through a decorator on the generic hook point. The engine’s party-to-tax-identity function is “regionally overridable”; at call time it checks the calling Legal Entity’s country and, only for India, dispatches to this layer’s override. The same map also swaps in an Income Tax Act depreciation calculation for fixed assets — a real override in the same source area, but a separate concern out of scope here.
  3. Install-time custom-field injection. A one-time setup routine adds a PAN field onto Company, Customer, and Supplier, and statutory section/entity-type fields onto Tax Withholding Category — a schema attachment, not a live hook.
  4. Report subclassing plus a metadata linkage. Each statutory report (§3.4) subclasses a generic report class, inheriting its query/column logic; its own metadata separately names the generic report it extends.

Identity resolution. For Customer or Supplier, return the party’s PAN; for any other type, an empty identity — replacing the engine’s default, which reads a generic tax-identifier field with no party-type restriction.

Section search. Free text matches case-insensitively against a “code plus description” string from the current-regime list; an empty search, or one matching nothing, returns the full unfiltered list — a deliberate fallback so an already-saved value keeps validating.

3.4 The two reports — what each computes and how they differ

Both reports read exclusively from Tax Withholding Entry rows already written at posting time. Neither recomputes anything — every figure shown is exactly what the engine wrote; these reports only widen or group existing rows.

Tax Withholding Details Report (statutory-column variant). Subclasses the generic details report, left-joining Tax Withholding Category to add three read-only columns: current-regime section, historical section, and entity-type. Every other column and the row set come unmodified from the generic query.

TDS Computation Summary Report (statutory-column variant). Subclasses both the Details variant and the generic summary report, which is itself a second thin layer: it runs the details report’s own query, groups rows by party type/party/category, and sums taxable and tax amounts per group — no recomputation, plus a same-fiscal-year check the details report doesn’t enforce. The India variant differs only by carrying the three statutory columns through that group-by step.

In short: the details report is a per-transaction row list, the summary report a per-party, per-category aggregate over the same rows — both extensions over an engine-maintained ledger, not independent computations.

3.5 Error handling

  • Bootstrap conflict on one category: caught, logged, and skipped — remaining rules still run, and the Legal Entity save is never blocked.
  • Unresolvable tax identity: never raises; an out-of-scope party type yields no identity, so the transaction still posts, only without a tax identity on its entry.
  • Section search with no match: degrades to the full option list rather than an empty one.
  • Historical rows on repeated bootstrap runs: prevented structurally — a rate row is appended only when its start date is at or after the latest end date already stored.

4. Scale and Reliability

  • Bootstrap cost is bounded and off the transaction path. It runs once per Legal Entity save — more frequent than creation, since it is a plain save hook — scanning a fixed-size dataset with one existence check per rule; it never runs on a transaction posting.
  • Cache invalidation is coarse but cheap. A category change clears the entire per-Legal-Entity account cache rather than a targeted entry, acceptable because category edits are rare and administrator-driven.
  • Tolerant reconciliation trades visibility for availability. A logged-and-skipped bootstrap conflict keeps every save succeeding, at the cost of a gap only discoverable by reading the error log — there is no administrator-facing signal.
  • Section search is an in-process scan, not a query. It scales with the size of the bundled list, not with database load — fine today, but would need a different approach if that list grew substantially.
  • Reports inherit the generic engine’s read scaling. Since neither computes anything, their cost is a filtered, joined, and (for the summary variant) grouped query over the Tax Withholding Entry ledger, bounded by the caller’s date range.

5. Trade-off Analysis

Decision Trade-off
Bootstrap seeds categories automatically on every Legal Entity save, rather than requiring manual statutory setup Removes setup burden for a new India Legal Entity, but re-runs the reconciliation loop on every save, and a category the bundled dataset no longer recognizes is never cleaned up automatically.
Tax identity resolution overridden per country rather than customizing the generic tax-identifier field Lets the engine’s existing cumulative-threshold and lower-deduction matching work correctly for a shared PAN across party records without touching engine code — but silently changes what the aggregation key means for this one country.
Statutory section list is a versioned, dated in-code table rather than a database-editable master Ships consistently with the app version and is searchable without a query, but a genuine statutory change requires an application upgrade rather than an administrator edit.
Reports are subclasses that only add columns or carry them through grouping, never new queries Cheap to maintain and guaranteed to track the generic report’s own logic, but any defect there propagates unchanged into the statutory variant.
Bootstrap reconciliation failures are logged and skipped rather than raised Keeps a Legal Entity record always savable even when a bundled rule conflicts with a prior manual edit, at the cost of a gap invisible unless someone reads the error log.
Only prospective (not-yet-elapsed) rate rows are seeded at bootstrap time Avoids planting dead historical rows on a fresh install, but a rule whose window had already elapsed before install can never be seeded automatically, even for historical reference.

6. What to Revisit as the System Grows

  • Give the bootstrap a visible failure signal. Today a reconciliation conflict is only discoverable in the error log tied to the affected category; administrators have no dashboard or notification surface for it.
  • Promote the section reference toward a database-editable master if statutory changes start happening more often than the application’s own release cadence can absorb.
  • Add a staleness check for categories with no remaining prospective rate rows — a category fully superseded by a newer bundled dataset is left in place silently today.
  • Revisit the whole-cache invalidation strategy if the number of Legal Entities or categories grows large enough that clearing the entire cache per edit becomes a measurable cost.
  • Reconsider the read-only historical section field if a real need emerges to select it, not just display it, when amending documents created under the prior framework.

Was this page helpful?