DEV Community

Cover image for The ₹1 That Costs ₹62,400: Modelling India's Tax Slabs Without Cliff-Edge Bugs
Monu Kumar
Monu Kumar

Posted on

The ₹1 That Costs ₹62,400: Modelling India's Tax Slabs Without Cliff-Edge Bugs

Progressive tax is the textbook example of a reduce. Walk the slabs, tax each band at its rate, sum it up. Twelve lines, done.

Then you hit a rebate threshold and discover your function has a cliff in it.

In India's new tax regime, taxable income up to ₹12,00,000 attracts zero tax — not because the slabs produce zero, but because a rebate wipes out the computed liability. Run ₹12,00,000 through a naive implementation and you get ₹0. Run ₹12,00,001 and you get roughly ₹62,400.

One extra rupee of income, ₹62,400 of extra tax. That is not how the law works, but it is exactly how an unpatched slab function behaves.

I ran into this while building a regime-comparison tool, and the fix turned out to be the interesting part. This post covers the three discontinuities that break naive tax code, and a small binary search that answers a question closed-form algebra doesn't handle well.

Code is TypeScript, framework-agnostic. I'll mention where React comes in at the end.

The part that actually is a reduce

Start with the easy bit. A slab is an upper bound and a rate:

type Slab = { upTo: number | null; rate: number };

const NEW_REGIME_SLABS: Slab[] = [
  { upTo:   400_000, rate: 0    },
  { upTo:   800_000, rate: 0.05 },
  { upTo: 1_200_000, rate: 0.10 },
  { upTo: 1_600_000, rate: 0.15 },
  { upTo: 2_000_000, rate: 0.20 },
  { upTo: 2_400_000, rate: 0.25 },
  { upTo:      null, rate: 0.30 }, // null = no ceiling
];
Enter fullscreen mode Exit fullscreen mode

Walking them:

function slabTax(taxable: number, slabs: Slab[]): number {
  let tax = 0;
  let floor = 0;

  for (const { upTo, rate } of slabs) {
    if (taxable <= floor) break;
    const ceiling = upTo ?? Infinity;
    const amountInBand = Math.min(taxable, ceiling) - floor;
    tax += amountInBand * rate;
    floor = ceiling;
  }

  return tax;
}
Enter fullscreen mode Exit fullscreen mode

Sanity check at ₹11,25,000:

band 1:        0 →   400,000  @ 0%   →      0
band 2:  400,000 →   800,000  @ 5%   → 20,000
band 3:  800,000 → 1,125,000  @ 10%  → 32,500
                                total = 52,500
Enter fullscreen mode Exit fullscreen mode

That's correct, and it's the last thing in this domain that behaves nicely.

The single most common bug I've seen in hand-rolled tax code is taxing the whole income at the top applicable rate instead of banding it. Someone earning ₹12,10,000 gets shown a 15% flat bill. The floor/ceiling walk above is what prevents that, and it's worth a test even though it looks obviously right.

Discontinuity one: the rebate cliff

Section 87A grants a rebate that cancels tax entirely below a threshold. In the new regime for FY 2026-27 that's up to ₹60,000 of rebate for taxable income up to ₹12,00,000.

Modelled naively:

const REBATE = {
  new: { limit: 1_200_000, maxRebate: 60_000 },
  old: { limit:   500_000, maxRebate: 12_500 },
} as const;

function applyRebate(taxable: number, tax: number, regime: Regime): number {
  const { limit, maxRebate } = REBATE[regime];
  if (taxable > limit) return tax;          // ← the cliff
  return Math.max(0, tax - maxRebate);
}
Enter fullscreen mode Exit fullscreen mode

Plot taxable → tax around the threshold and you get a step function. At ₹12,00,000 the slabs produce exactly ₹60,000, the rebate cancels it, output is zero. At ₹12,00,001 the rebate vanishes and the full ₹60,000 lands, plus cess.

The law patches this with marginal relief: tax is capped at the amount by which income exceeds the threshold. Earn ₹1 over, pay at most ₹1.

function applyMarginalRelief(
  taxable: number,
  taxAfterRebate: number,
  regime: Regime
): number {
  const { limit } = REBATE[regime];
  if (taxable <= limit) return taxAfterRebate;

  const excessOverLimit = taxable - limit;
  return Math.min(taxAfterRebate, excessOverLimit);
}
Enter fullscreen mode Exit fullscreen mode

Now the curve is continuous. From ₹12,00,000 upward, tax rises rupee-for-rupee with income until the relief stops binding — somewhere around ₹12,70,000 — after which normal slab tax takes over.

That transition point is emergent, not hardcoded. It's wherever slabTax - rebate crosses income - limit. Worth understanding, because if you ever find yourself wanting to hardcode ₹12,70,000 as a magic number, you've misunderstood the mechanism.

Discontinuity two: surcharge, same shape, bigger numbers

High incomes attract a surcharge — a percentage of the tax, not of income — that steps up at ₹50L, ₹1Cr, ₹2Cr and ₹5Cr.

Same cliff, worse blast radius. At ₹50,00,000 there's no surcharge. At ₹50,00,001, a 10% surcharge applies to the entire tax liability. Without relief, one rupee of income triggers a tax jump in the lakhs.

Surcharge marginal relief works the same way, phrased differently: the increase in total tax cannot exceed the increase in income over the threshold.

const SURCHARGE_BANDS = [
  { threshold:  5_000_000, rate: 0.10 },
  { threshold: 10_000_000, rate: 0.15 },
  { threshold: 20_000_000, rate: 0.25 },
  { threshold: 50_000_000, rate: 0.37 },
];

function surchargeWithRelief(
  taxable: number,
  baseTax: number,
  slabs: Slab[],
  regime: Regime
): number {
  const band = [...SURCHARGE_BANDS]
    .reverse()
    .find(b => taxable > b.threshold);

  if (!band) return 0;

  const rawSurcharge = baseTax * band.rate;

  // Tax at the threshold itself, for comparison
  const taxAtThreshold = applyMarginalRelief(
    band.threshold,
    applyRebate(band.threshold, slabTax(band.threshold, slabs), regime),
    regime
  );

  const incomeOverThreshold = taxable - band.threshold;
  const maxAllowedTotal = taxAtThreshold + incomeOverThreshold;
  const reliefCappedSurcharge = Math.max(0, maxAllowedTotal - baseTax);

  return Math.min(rawSurcharge, reliefCappedSurcharge);
}
Enter fullscreen mode Exit fullscreen mode

Two things to note. The .reverse().find() picks the highest applicable band — a plain find on the ascending array returns 10% for a ₹3 crore income, which is wrong and won't show up unless you test at high incomes. And the new regime caps surcharge at 25% rather than 37%, so a production version needs a regime-aware band list.

Putting the pipeline together

Order matters, and it's the order the statute specifies:

function computeTax(input: TaxInput): TaxResult {
  const { grossIncome, deductions, isSalaried, regime, ageGroup } = input;

  const standardDeduction = isSalaried
    ? (regime === "new" ? 75_000 : 50_000)
    : 0;

  // Chapter VI-A deductions apply to the old regime only
  const applicableDeductions = regime === "old" ? deductions : 0;

  const taxable = Math.max(
    0,
    grossIncome - standardDeduction - applicableDeductions
  );

  const slabs = getSlabs(regime, ageGroup);

  const gross     = slabTax(taxable, slabs);
  const afterReb  = applyRebate(taxable, gross, regime);
  const afterRel  = applyMarginalRelief(taxable, afterReb, regime);
  const surcharge = surchargeWithRelief(taxable, afterRel, slabs, regime);
  const cess      = (afterRel + surcharge) * 0.04;

  return {
    taxable,
    totalTax: Math.round(afterRel + surcharge + cess),
    monthlyTds: Math.round((afterRel + surcharge + cess) / 12),
  };
}
Enter fullscreen mode Exit fullscreen mode

Cess sits on top of tax plus surcharge — a mistake that's easy to make and hard to spot, because it only skews results for high earners who aren't your test case while you're building.

The actual problem: solving for break-even

Here's the question the tool exists to answer, and the reason I wrote any of this.

India lets you pick between two regimes. The new one has lower rates and almost no deductions. The old one has higher rates but allows deductions for investments, health insurance, rent and home loan interest.

Every comparison tool tells you which regime is cheaper given the deductions you typed in. Almost none answer the more useful question:

How much deduction would I need for the old regime to win at all?

That's an inverse problem. Fix the income, treat old-regime tax as a function of deductions, and find where it crosses the constant new-regime tax:

find D such that oldRegimeTax(income, D) === newRegimeTax(income)
Enter fullscreen mode Exit fullscreen mode

You could solve it algebraically per slab, but the rebate, marginal relief and surcharge all introduce breakpoints, so a closed-form solution turns into a stack of case analysis that has to be rewritten every time a Budget moves a threshold.

The function is monotonically decreasing in D — more deductions never increase tax. Monotonic means binary search:

function findBreakEvenDeductions(input: BaseInput): number | null {
  const newTax = computeTax({ ...input, regime: "new", deductions: 0 }).totalTax;

  const oldAtZero = computeTax({ ...input, regime: "old", deductions: 0 }).totalTax;
  if (oldAtZero <= newTax) return 0;   // old regime already wins

  const oldAtMax = computeTax({
    ...input, regime: "old", deductions: input.grossIncome,
  }).totalTax;
  if (oldAtMax > newTax) return null;  // unreachable at any deduction level

  let lo = 0;                    // old regime loses here
  let hi = input.grossIncome;    // old regime wins here

  for (let i = 0; i < 50; i++) {
    const mid = (lo + hi) / 2;
    const midTax = computeTax({
      ...input, regime: "old", deductions: mid,
    }).totalTax;

    if (midTax > newTax) lo = mid;
    else hi = mid;
  }

  return Math.ceil(hi);
}
Enter fullscreen mode Exit fullscreen mode

Fifty iterations narrows a ₹1 crore range to well under a rupee. Each iteration is a handful of arithmetic operations, so the whole solve runs in microseconds — fast enough to recompute on every keystroke without debouncing.

The two guard clauses matter. oldAtZero <= newTax catches incomes where the old regime already wins with nothing claimed. oldAtMax > newTax catches the case where no deduction amount can close the gap, and returning null there is honest — the alternative is a binary search converging on a meaningless boundary value that you then render as a real number.

Why this number is worth computing

Running it across incomes produces something more useful than any individual comparison:

Income Break-even deductions needed
₹8L ~₹2.5L
₹10L ~₹4.5L
₹12L ~₹6.5L
₹20L ~₹7.1L

The realistic ceiling for someone without a home loan is roughly ₹3L — ₹1.5L of investments, ₹50k of pension contribution, up to ₹1L of health insurance. That's below the break-even at every row.

Which means for a large share of users, the answer isn't "the new regime is cheaper today." It's "the old regime is structurally unreachable for you, and no amount of tax-saving products will change that." Same computation, considerably more decisive framing — and it only exists because the tool solves the inverse problem rather than the forward one.

The live version is here if you want to poke at the edge cases. Try ₹12,00,001 and ₹50,00,001 — those are the two cliffs from earlier.

Testing the cliffs

Tax code is unusually well-suited to property-based testing, because the properties are legislated rather than invented.

test("tax never decreases as income increases", () => {
  for (let income = 100_000; income <= 60_000_000; income += 7_919) {
    const a = computeTax({ ...base, grossIncome: income }).totalTax;
    const b = computeTax({ ...base, grossIncome: income + 1 }).totalTax;
    expect(b).toBeGreaterThanOrEqual(a);
  }
});

test("no cliff exceeds the income increment", () => {
  for (let income = 100_000; income <= 60_000_000; income += 7_919) {
    const a = computeTax({ ...base, grossIncome: income }).totalTax;
    const b = computeTax({ ...base, grossIncome: income + 1_000 }).totalTax;
    expect(b - a).toBeLessThanOrEqual(1_000 + 1);
  }
});
Enter fullscreen mode Exit fullscreen mode

The second test is the one that earns its keep. It encodes the entire point of marginal relief — an extra rupee of income can never cost more than a rupee of tax — and it fails loudly on any threshold you've forgotten to patch. The odd step size (7,919) is deliberate; round increments have a habit of stepping neatly over the exact boundaries you're trying to catch.

The rest is table-driven: known income/deduction pairs with hand-computed expected values, including one deliberately at each surcharge threshold.

React notes, briefly

Nothing above touches the DOM, which is the point — it's a pure module with no dependencies, testable in isolation and portable.

Two things worth mentioning about wiring it up:

Everything runs client-side. No income figures leave the browser, which sidesteps an entire category of privacy questions and lets the whole thing be statically served. Given the compute cost is microseconds, a server round-trip would be strictly worse in every dimension.

Next.js App Router splits cleanly. The page is a Server Component that owns the metadata, the structured data and the long-form explanatory content — all statically rendered. Only the interactive panel is a Client Component. The explanatory content is what search engines index, and it costs zero client-side JavaScript.

One useMemo, not several. Regime comparison plus break-even solve is a single derived computation:

const result = useMemo(
  () => ({
    newRegime: computeTax({ ...input, regime: "new" }),
    oldRegime: computeTax({ ...input, regime: "old" }),
    breakEven: findBreakEvenDeductions(input),
  }),
  [input]
);
Enter fullscreen mode Exit fullscreen mode

Splitting these across multiple memos means multiple recomputation paths for the same underlying change, and the numbers can briefly disagree with each other mid-render. One input object, one memo, one consistent snapshot.

What I'd tell past-me

Three things, in order of how much time they cost me:

Model the discontinuities before writing the slab walk. The slabs are the easy 20%. Rebates, marginal relief and surcharge are the 80%, and retrofitting them into a "finished" function means restructuring rather than extending.

Encode the law's invariants as tests, not the outputs. "Tax is monotonic" and "no cliff exceeds its trigger" survive a Budget that moves every threshold. A test asserting ₹12,00,000 → ₹0 breaks the moment a rate changes, which trains you to update tests reflexively instead of reading them.

Solve the inverse problem too. Forward calculation — inputs to answer — is what everyone builds. The inverse — what would the input need to be for a different answer — is often the question the user actually has, and with a monotonic function it's twenty lines of binary search.

If you're modelling a jurisdiction with similar rebate mechanics, I'd be curious how you handled it. The relief interactions are where I'd expect implementations to quietly diverge.


Slab rates and thresholds above reflect India's FY 2026-27 provisions as I understood them while building; verify against the Income Tax Department before relying on any of it. This is a post about numerical edge cases, not tax advice.

Top comments (1)

Collapse
 
aadesh-kumar profile image
Aadesh Kumar

great congo