770 lines
26 KiB
TypeScript
770 lines
26 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import {
|
|
FY_TAX_CONFIG,
|
|
InterestCredit,
|
|
TdsAccountInput,
|
|
TdsContext,
|
|
ageInFy,
|
|
declarationFormFor,
|
|
annualSchedule,
|
|
bucketByQuarter,
|
|
checkEligibility,
|
|
collectTdsCredits,
|
|
cumulativeLiability,
|
|
formKindFor,
|
|
fyOf,
|
|
isTdsApplicable,
|
|
isWellFormedPan,
|
|
lateDeductionInterest,
|
|
monthsOrPartBetween,
|
|
projectQuarterlyCredits,
|
|
quarterlySchedule,
|
|
reconcile,
|
|
tdsDeducteeOf,
|
|
tdsModeFor
|
|
} from './tds-15gh.compute';
|
|
import { installTdsConfigFixture } from './tds-config.fixture';
|
|
|
|
installTdsConfigFixture();
|
|
|
|
const FY = 'FY 2026-27';
|
|
|
|
/** Under 60 for FY 2026-27, valid individual PAN. */
|
|
function general(overrides: Partial<TdsContext> = {}): TdsContext {
|
|
return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides };
|
|
}
|
|
|
|
/** 60+ for FY 2026-27. */
|
|
function senior(overrides: Partial<TdsContext> = {}): TdsContext {
|
|
return { fy: FY, dateOfBirth: '1958-06-22', pan: 'ABCPM1234E', resident: true, ...overrides };
|
|
}
|
|
|
|
/** Four dated credits, one per quarter. */
|
|
function credits(q1: number, q2: number, q3: number, q4: number): InterestCredit[] {
|
|
return [
|
|
{ date: '2026-06-30', amount: q1 },
|
|
{ date: '2026-09-30', amount: q2 },
|
|
{ date: '2026-12-31', amount: q3 },
|
|
{ date: '2027-03-31', amount: q4 }
|
|
];
|
|
}
|
|
|
|
describe('194A threshold behaviour', () => {
|
|
it('deducts nothing while the aggregate stays under the trigger', () => {
|
|
expect(quarterlySchedule(credits(12_000, 12_000, 12_000, 12_000), general()).totalTds).toBe(0);
|
|
});
|
|
|
|
it('deducts nothing when the aggregate lands exactly on the trigger', () => {
|
|
// The section bites only where the amount *exceeds* the limit.
|
|
expect(quarterlySchedule(credits(12_500, 12_500, 12_500, 12_500), general()).totalTds).toBe(0);
|
|
});
|
|
|
|
it('treats the threshold as a trigger, not an allowance', () => {
|
|
// ₹52,000 of interest: 10% of the whole ₹52,000, not 10% of the ₹2,000 above
|
|
// the ₹50,000 line.
|
|
const s = annualSchedule(credits(13_000, 13_000, 13_000, 13_000), general());
|
|
expect(s.totalTds).toBe(5_200);
|
|
expect(s.totalTds).not.toBe(200);
|
|
});
|
|
|
|
it('applies the senior citizen trigger of ₹1,00,000', () => {
|
|
const c = credits(20_000, 20_000, 20_000, 20_000); // ₹80,000
|
|
expect(quarterlySchedule(c, general()).totalTds).toBe(8_000);
|
|
expect(quarterlySchedule(c, senior()).totalTds).toBe(0);
|
|
});
|
|
|
|
it('falls back to the 206AA rate of 20% where PAN is not on record', () => {
|
|
const c = credits(25_000, 25_000, 25_000, 25_000); // ₹1,00,000
|
|
expect(annualSchedule(c, general()).totalTds).toBe(10_000);
|
|
expect(annualSchedule(c, general({ pan: '' })).totalTds).toBe(20_000);
|
|
});
|
|
});
|
|
|
|
describe('the quarterly schedule catches up in the quarter the trigger is crossed', () => {
|
|
it('loads the whole year-to-date liability into that quarter', () => {
|
|
// ₹20,000 a quarter. The aggregate crosses ₹50,000 during Q3.
|
|
const s = quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general());
|
|
expect(s.lines.map((l) => l.tds)).toEqual([
|
|
0,
|
|
0,
|
|
6_000,
|
|
2_000
|
|
]);
|
|
expect(s.lines[2].thresholdCrossedHere).toBe(true);
|
|
// Q3 deducts ₹6,000 against ₹20,000 of that quarter's own interest. That is
|
|
// correct: the earlier quarters became liable the moment the line was
|
|
// crossed, and the branch collects the arrears now.
|
|
expect(s.totalTds).toBe(8_000);
|
|
});
|
|
|
|
it('never deducts a negative amount on a rising book', () => {
|
|
const s = quarterlySchedule(credits(5_000, 60_000, 1_000, 400), general());
|
|
for (const line of s.lines) {
|
|
expect(line.tds).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('quarterly and annual settlement agree to the rupee', () => {
|
|
const distributions: [number, number, number, number][] = [
|
|
[
|
|
12_000,
|
|
12_000,
|
|
12_000,
|
|
12_000
|
|
],
|
|
[
|
|
20_000,
|
|
20_000,
|
|
20_000,
|
|
20_000
|
|
],
|
|
[
|
|
60_000,
|
|
0,
|
|
0,
|
|
0
|
|
],
|
|
[
|
|
0,
|
|
0,
|
|
0,
|
|
75_000
|
|
],
|
|
[
|
|
49_999,
|
|
1,
|
|
0,
|
|
0
|
|
],
|
|
[
|
|
13_333,
|
|
13_333,
|
|
13_333,
|
|
13_334
|
|
],
|
|
[
|
|
1,
|
|
2,
|
|
3,
|
|
99_999
|
|
],
|
|
[
|
|
250_000,
|
|
250_000,
|
|
250_000,
|
|
250_000
|
|
]
|
|
];
|
|
|
|
it.each(distributions)('holds for %i / %i / %i / %i', (q1, q2, q3, q4) => {
|
|
const c = credits(q1, q2, q3, q4);
|
|
for (const ctx of [
|
|
general(),
|
|
senior(),
|
|
general({ pan: '' })
|
|
]) {
|
|
const q = quarterlySchedule(c, ctx);
|
|
const a = annualSchedule(c, ctx);
|
|
expect(q.totalTds).toBe(a.totalTds);
|
|
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(a.totalTds);
|
|
}
|
|
});
|
|
|
|
it('holds under section 288B rounding to the nearest ₹10', () => {
|
|
const ctx = general({ config: { roundTo: 10 } });
|
|
const c = credits(13_333, 13_333, 13_333, 13_334);
|
|
expect(quarterlySchedule(c, ctx).totalTds).toBe(annualSchedule(c, ctx).totalTds);
|
|
expect(annualSchedule(c, ctx).totalTds % 10).toBe(0);
|
|
});
|
|
|
|
/**
|
|
* Property test. Rounding drift only shows up on awkward numbers, so sweep a
|
|
* deterministic spread of them rather than trusting hand-picked cases.
|
|
*/
|
|
it('holds across 2000 pseudo-random interest distributions', () => {
|
|
let seed = 20260801;
|
|
const next = (max: number) => {
|
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
|
return Math.floor((seed / 4294967296) * max);
|
|
};
|
|
|
|
for (let i = 0; i < 2000; i++) {
|
|
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
|
const ctx = general({ config: { roundTo: i % 2 === 0 ? 1 : 10 } });
|
|
const q = quarterlySchedule(c, ctx);
|
|
const a = annualSchedule(c, ctx);
|
|
if (q.totalTds !== a.totalTds) {
|
|
throw new Error(
|
|
`Mismatch on ${c.map((x) => x.amount).join('/')}: quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
|
);
|
|
}
|
|
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(q.totalTds);
|
|
}
|
|
});
|
|
|
|
it('holds across 2000 distributions with a declaration at every acceptance point', () => {
|
|
let seed = 19980731;
|
|
const next = (max: number) => {
|
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
|
return Math.floor((seed / 4294967296) * max);
|
|
};
|
|
const dates = [
|
|
'2026-04-01',
|
|
'2026-05-15',
|
|
'2026-08-01',
|
|
'2026-10-01',
|
|
'2027-01-10',
|
|
'2027-03-31'
|
|
];
|
|
|
|
for (let i = 0; i < 2000; i++) {
|
|
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
|
const ctx = general({
|
|
declaration: { kind: '15G', acceptedOn: dates[i % dates.length], fy: '2026-27' },
|
|
config: { roundTo: i % 2 === 0 ? 1 : 10 }
|
|
});
|
|
const q = quarterlySchedule(c, ctx);
|
|
const a = annualSchedule(c, ctx);
|
|
if (q.totalTds !== a.totalTds) {
|
|
throw new Error(
|
|
`Mismatch on ${c.map((x) => x.amount).join('/')} declared ${dates[i % dates.length]}: ` +
|
|
`quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('adjusts within the year when interest is reversed', () => {
|
|
// Q2 took the aggregate to ₹80,000 and ₹8,000 was deducted. The reversal
|
|
// brings the year back to ₹50,000, which does not exceed the trigger at all,
|
|
// so the whole ₹8,000 is given back in Q3.
|
|
const c = credits(40_000, 40_000, -30_000, 0);
|
|
const q = quarterlySchedule(c, general());
|
|
expect(q.lines.map((l) => l.tds)).toEqual([
|
|
0,
|
|
8_000,
|
|
-8_000,
|
|
0
|
|
]);
|
|
expect(q.totalTds).toBe(0);
|
|
expect(annualSchedule(c, general()).totalTds).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('a Form 121 declaration', () => {
|
|
const fromApril = general({
|
|
declaration: { kind: '15G', acceptedOn: '2026-04-01', fy: '2026-27', uin: 'U0001' }
|
|
});
|
|
const fromOctober = general({
|
|
declaration: { kind: '15G', acceptedOn: '2026-10-01', fy: '2026-27' }
|
|
});
|
|
|
|
it('stops deduction for the whole year when on record from April', () => {
|
|
const c = credits(30_000, 30_000, 30_000, 30_000);
|
|
expect(quarterlySchedule(c, fromApril).totalTds).toBe(0);
|
|
expect(annualSchedule(c, fromApril).totalTds).toBe(0);
|
|
expect(reconcile(c, fromApril).matched).toBe(true);
|
|
});
|
|
|
|
it('shields only the interest credited on or after it was accepted', () => {
|
|
const s = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
|
expect(s.liableInterest).toBe(60_000);
|
|
expect(s.shieldedInterest).toBe(60_000);
|
|
});
|
|
|
|
it('does not claw back tax already remitted before it arrived', () => {
|
|
const q = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
|
expect(q.lines.map((l) => l.tds)).toEqual([
|
|
0,
|
|
6_000,
|
|
0,
|
|
0
|
|
]);
|
|
expect(q.totalTds).toBe(6_000);
|
|
expect(q.notes[0]).toContain('cannot reverse it');
|
|
});
|
|
|
|
it('leaves the two modes in agreement even when taken mid-year', () => {
|
|
// A declaration is prospective, so the year-end figure still sees the
|
|
// pre-declaration interest as liable.
|
|
const r = reconcile(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
|
expect(r.quarterlyTotal).toBe(6_000);
|
|
expect(r.annualTotal).toBe(6_000);
|
|
expect(r.matched).toBe(true);
|
|
});
|
|
|
|
it('reports the divergence when liability falls after deductions were frozen', () => {
|
|
// Q1 crossed the trigger and ₹6,000 went to the Government. A reversal in
|
|
// Q3, after the declaration was on record, drags the year back under
|
|
// ₹50,000 — the liability is now nil but the branch cannot refund.
|
|
const c: InterestCredit[] = [
|
|
{ date: '2026-06-30', amount: 60_000 },
|
|
{ date: '2026-12-31', amount: -15_000 }
|
|
];
|
|
const r = reconcile(c, fromOctober);
|
|
expect(r.quarterlyTotal).toBe(6_000);
|
|
expect(r.annualTotal).toBe(0);
|
|
expect(r.matched).toBe(false);
|
|
expect(quarterlySchedule(c, fromOctober).lines[2].tds).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('age is measured against the financial year, not today', () => {
|
|
it('gives the senior threshold to a customer who turns 60 in March', () => {
|
|
expect(ageInFy('1967-03-20', FY)).toBe(60);
|
|
expect(formKindFor('1967-03-20', FY)).toBe('15H');
|
|
const ctx = general({ dateOfBirth: '1967-03-20' });
|
|
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), ctx).threshold).toBe(100_000);
|
|
});
|
|
|
|
it('keeps the general threshold for a customer who turns 60 the next April', () => {
|
|
expect(ageInFy('1967-04-20', FY)).toBe(59);
|
|
expect(formKindFor('1967-04-20', FY)).toBe('15G');
|
|
});
|
|
});
|
|
|
|
describe('savings interest is outside section 194A', () => {
|
|
it.each([
|
|
[
|
|
'depositAccountType.fixedDeposit',
|
|
true
|
|
],
|
|
[
|
|
'depositAccountType.recurringDeposit',
|
|
true
|
|
],
|
|
[
|
|
'depositAccountType.savingsDeposit',
|
|
false
|
|
],
|
|
[
|
|
'depositAccountType.currentDeposit',
|
|
false
|
|
],
|
|
[
|
|
'',
|
|
false
|
|
]
|
|
])('%s -> %s', (code, expected) => {
|
|
expect(isTdsApplicable({ depositTypeCode: code })).toBe(expected);
|
|
});
|
|
|
|
it('keeps savings interest out of the aggregate entirely', () => {
|
|
const accounts: TdsAccountInput[] = [
|
|
{
|
|
accountNo: 'FD001',
|
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
|
accountStatus: 'SINGLE',
|
|
clientId: 1,
|
|
credits: credits(10_000, 10_000, 10_000, 10_000)
|
|
},
|
|
{
|
|
accountNo: 'SB001',
|
|
depositTypeCode: 'depositAccountType.savingsDeposit',
|
|
accountStatus: 'SINGLE',
|
|
clientId: 1,
|
|
credits: credits(5_000, 5_000, 5_000, 5_000)
|
|
}
|
|
];
|
|
const collected = collectTdsCredits(1, accounts, FY);
|
|
|
|
// ₹40,000 of FD interest, not ₹60,000 — so the trigger is never crossed and
|
|
// nothing is deducted. Folding the savings interest in would have crossed it.
|
|
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(40_000);
|
|
expect(quarterlySchedule(collected, general()).totalTds).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('whose PAN the tax is deducted against', () => {
|
|
it('uses the account holder for a single-name account', () => {
|
|
expect(tdsDeducteeOf({ accountStatus: 'SINGLE', clientId: 7 })).toBe(7);
|
|
expect(tdsDeducteeOf({ accountStatus: 'INDIVIDUAL_MALE', clientId: 7 })).toBe(7);
|
|
});
|
|
|
|
it('uses the first-named holder on a joint account', () => {
|
|
expect(
|
|
tdsDeducteeOf({
|
|
accountStatus: 'JOINT',
|
|
primaryClientId: 7,
|
|
jointHolders: [{ clientId: 9 }]
|
|
})
|
|
).toBe(7);
|
|
});
|
|
|
|
it('uses the guardian on a minor account', () => {
|
|
expect(tdsDeducteeOf({ accountStatus: 'MINOR', clientId: 11, guardianClientId: 4 })).toBe(4);
|
|
});
|
|
|
|
it('does not count a joint deposit twice across the two holders', () => {
|
|
const jointFd: TdsAccountInput = {
|
|
accountNo: 'FD-JOINT',
|
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
|
accountStatus: 'JOINT',
|
|
primaryClientId: 7,
|
|
jointHolders: [{ clientId: 9 }],
|
|
credits: credits(15_000, 15_000, 15_000, 15_000) // ₹60,000
|
|
};
|
|
|
|
// Primary holder carries the whole ₹60,000 and the whole ₹6,000 of tax.
|
|
const primary = collectTdsCredits(7, [jointFd], FY);
|
|
expect(primary.reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
|
expect(quarterlySchedule(primary, general()).totalTds).toBe(6_000);
|
|
|
|
// The second holder carries none of it — no double deduction on one deposit.
|
|
const secondHolder = collectTdsCredits(9, [jointFd], FY);
|
|
expect(secondHolder).toEqual([]);
|
|
expect(quarterlySchedule(secondHolder, general()).totalTds).toBe(0);
|
|
});
|
|
|
|
it('ignores any share percentage when attributing interest', () => {
|
|
// A 50/50 joint account still reports the full amount against the primary
|
|
// holder; splitting would produce two deductees for one deposit.
|
|
const jointFd: TdsAccountInput = {
|
|
accountNo: 'FD-JOINT',
|
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
|
accountStatus: 'JOINT',
|
|
primaryClientId: 7,
|
|
jointHolders: [{ clientId: 9 }],
|
|
credits: credits(15_000, 15_000, 15_000, 15_000)
|
|
};
|
|
expect(collectTdsCredits(7, [jointFd], FY).reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
|
});
|
|
|
|
it('aggregates a customer across every branch', () => {
|
|
// Three deposits in three branches, each under the trigger on its own.
|
|
const accounts: TdsAccountInput[] = [
|
|
'FD-MUM',
|
|
'FD-PUN',
|
|
'FD-NAS'
|
|
].map((accountNo, i) => ({
|
|
accountNo,
|
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
|
accountStatus: 'SINGLE',
|
|
clientId: 1,
|
|
credits: [{ date: `2026-0${6 + i}-30`, amount: [
|
|
20_000,
|
|
18_000,
|
|
15_000
|
|
][i] }]
|
|
}));
|
|
|
|
const collected = collectTdsCredits(1, accounts, FY);
|
|
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(53_000);
|
|
// Branch by branch none would have crossed ₹50,000. Bank-wide, it does.
|
|
expect(quarterlySchedule(collected, general()).totalTds).toBe(5_300);
|
|
});
|
|
|
|
it('keeps other financial years out of the selected year', () => {
|
|
const account: TdsAccountInput = {
|
|
accountNo: 'FD001',
|
|
depositTypeCode: 'depositAccountType.fixedDeposit',
|
|
accountStatus: 'SINGLE',
|
|
clientId: 1,
|
|
credits: [
|
|
{ date: '2026-03-31', amount: 99_000 }, // FY 2025-26
|
|
{ date: '2026-06-30', amount: 10_000 }, // FY 2026-27
|
|
{ date: '2027-04-01', amount: 99_000 } // FY 2027-28
|
|
]
|
|
};
|
|
expect(collectTdsCredits(1, [account], FY).map((c) => c.amount)).toEqual([10_000]);
|
|
});
|
|
});
|
|
|
|
describe('settlement mode follows the product, not the customer', () => {
|
|
it.each([
|
|
[
|
|
'savingsPostingInterestPeriodType.monthly',
|
|
'QUARTERLY'
|
|
],
|
|
[
|
|
'savingsPostingInterestPeriodType.quarterly',
|
|
'QUARTERLY'
|
|
],
|
|
[
|
|
'savingsPostingInterestPeriodType.annual',
|
|
'ANNUAL'
|
|
],
|
|
[
|
|
'savingsPostingInterestPeriodType.annually',
|
|
'ANNUAL'
|
|
]
|
|
])('%s -> %s', (code, expected) => {
|
|
expect(tdsModeFor({ interestPostingPeriodType: { code } })).toBe(expected);
|
|
});
|
|
|
|
it('does not mistake a half-yearly product for an annual one', () => {
|
|
// 'biannual' contains 'annual' — a naive substring test settles half-yearly
|
|
// products once a year, which is a deduction default.
|
|
expect(tdsModeFor({ interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.biannual' } })).toBe(
|
|
'QUARTERLY'
|
|
);
|
|
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Bi-Annual' } })).toBe('QUARTERLY');
|
|
});
|
|
|
|
it('reads the value when no code is present, and defaults safely', () => {
|
|
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Annually' } })).toBe('ANNUAL');
|
|
// Unknown or missing configuration settles on the quarterly cycle, which is
|
|
// the conservative choice — deducting sooner is never a default.
|
|
expect(tdsModeFor({})).toBe('QUARTERLY');
|
|
expect(tdsModeFor({ interestPostingPeriodType: null })).toBe('QUARTERLY');
|
|
});
|
|
});
|
|
|
|
describe('PAN validation', () => {
|
|
it.each([
|
|
[
|
|
'ABCPM1234E',
|
|
true
|
|
],
|
|
[
|
|
'EFGHV5678A',
|
|
true
|
|
],
|
|
[
|
|
'PQRSR2233Z',
|
|
false
|
|
],
|
|
[
|
|
'TUVWS3344K',
|
|
false
|
|
],
|
|
[
|
|
'ABCP1234E',
|
|
false
|
|
],
|
|
[
|
|
'',
|
|
false
|
|
]
|
|
])('%s -> %s', (pan, expected) => {
|
|
expect(isWellFormedPan(pan)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
const income = (estimatedInterest: number, estimatedIncome: number) => ({
|
|
estimatedInterest,
|
|
estimatedIncome,
|
|
incomeOnRecord: true
|
|
});
|
|
|
|
/** FY 2025-26 — the last year the 1961 Act and the 15G/15H split applied. */
|
|
const OLD_FY = 'FY 2025-26';
|
|
|
|
describe('which declaration form is in force', () => {
|
|
it('splits by age up to FY 2025-26', () => {
|
|
expect(declarationFormFor(OLD_FY, '1985-02-14')).toBe('15G');
|
|
expect(declarationFormFor(OLD_FY, '1958-06-22')).toBe('15H');
|
|
});
|
|
|
|
it('is a single Form 121 from FY 2026-27, whatever the age', () => {
|
|
expect(declarationFormFor(FY, '1985-02-14')).toBe('121');
|
|
expect(declarationFormFor(FY, '1958-06-22')).toBe('121');
|
|
});
|
|
|
|
it('keeps the senior threshold even though the forms merged', () => {
|
|
// The two forms became one, but the deduction trigger is still age-based.
|
|
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()).threshold).toBe(50_000);
|
|
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), senior()).threshold).toBe(100_000);
|
|
});
|
|
});
|
|
|
|
describe('eligibility up to FY 2025-26 — the 15G / 15H tests', () => {
|
|
const old = (o: Partial<TdsContext> = {}) => general({ fy: OLD_FY, ...o });
|
|
const oldSenior = (o: Partial<TdsContext> = {}) => senior({ fy: OLD_FY, ...o });
|
|
|
|
it('accepts a 15G within both the income and the interest ceiling', () => {
|
|
const r = checkEligibility(old(), income(90_000, 320_000));
|
|
expect(r.form).toBe('15G');
|
|
expect(r.eligible).toBe(true);
|
|
});
|
|
|
|
it('rejects a 15G whose estimated income exceeds the basic exemption', () => {
|
|
const r = checkEligibility(old(), income(38_500, 850_000));
|
|
expect(r.eligible).toBe(false);
|
|
expect(r.reasons.join(' ')).toContain('exceeds the basic exemption');
|
|
});
|
|
|
|
it('rejects a 15G whose interest alone exceeds the basic exemption', () => {
|
|
// The second limb: interest had to clear the limit as well as income.
|
|
expect(checkEligibility(old(), income(450_000, 380_000)).eligible).toBe(false);
|
|
});
|
|
|
|
it('places no interest ceiling on a 15H', () => {
|
|
// A senior citizen with ₹8,00,000 of interest could still declare, provided
|
|
// deductions and rebate left the liability at nil.
|
|
const r = checkEligibility(oldSenior(), income(800_000, 900_000));
|
|
expect(r.form).toBe('15H');
|
|
expect(r.eligible).toBe(true);
|
|
});
|
|
|
|
it('rejects a 15H that leaves a liability', () => {
|
|
const r = checkEligibility(oldSenior(), income(800_000, 1_500_000));
|
|
expect(r.eligible).toBe(false);
|
|
expect(r.reasons.join(' ')).toContain('nil');
|
|
});
|
|
|
|
it('cites the rule that applied to that year', () => {
|
|
const r = checkEligibility(old({ pan: '' }), income(10_000, 100_000));
|
|
expect(r.eligible).toBe(false);
|
|
expect(r.reasons.join(' ')).toContain('Rule 29C');
|
|
});
|
|
|
|
it('still checks the interest limb when income is not on record', () => {
|
|
// Income has to be declared by the payee, but the bank knows the interest
|
|
// figure, so that limb remains assessable.
|
|
expect(
|
|
checkEligibility(old(), { estimatedInterest: 450_000, estimatedIncome: 0, incomeOnRecord: false }).eligible
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('eligibility from FY 2026-27 — the single Form 121 test', () => {
|
|
it('drops the interest limb entirely', () => {
|
|
// Under a 15G this failed: interest of ₹4,50,000 was over the basic
|
|
// exemption limit. Form 121 asks only whether the tax liability is nil, so
|
|
// the same payee is now eligible.
|
|
expect(checkEligibility(general({ fy: OLD_FY }), income(450_000, 380_000)).eligible).toBe(false);
|
|
const r = checkEligibility(general(), income(450_000, 380_000));
|
|
expect(r.form).toBe('121');
|
|
expect(r.eligible).toBe(true);
|
|
});
|
|
|
|
it('applies one nil-liability ceiling to every age', () => {
|
|
for (const ctx of [
|
|
general(),
|
|
senior()
|
|
]) {
|
|
expect(checkEligibility(ctx, income(800_000, 900_000)).eligible).toBe(true);
|
|
const over = checkEligibility(ctx, income(800_000, 1_500_000));
|
|
expect(over.eligible).toBe(false);
|
|
expect(over.reasons.join(' ')).toContain('nil');
|
|
}
|
|
});
|
|
|
|
it('takes PAN only — Aadhaar is not a substitute', () => {
|
|
const r = checkEligibility(general({ pan: '' }), income(10_000, 100_000));
|
|
expect(r.eligible).toBe(false);
|
|
expect(r.reasons.join(' ')).toContain('Rule 211');
|
|
expect(r.reasons.join(' ')).toContain('Aadhaar is not a substitute');
|
|
});
|
|
|
|
it('refuses an entity PAN', () => {
|
|
const r = checkEligibility(general({ pan: 'ABCCM1234E' }), income(10_000, 100_000));
|
|
expect(r.eligible).toBe(false);
|
|
expect(r.reasons.join(' ')).toContain('entity');
|
|
});
|
|
|
|
it('refuses a non-resident', () => {
|
|
expect(checkEligibility(general({ resident: false }), income(10_000, 100_000)).eligible).toBe(false);
|
|
});
|
|
|
|
it('reports the nil-liability test as unassessed rather than passing it silently', () => {
|
|
// The branch cannot compute income earned outside the bank, so a missing
|
|
// figure must not read as a pass.
|
|
const r = checkEligibility(general(), {
|
|
estimatedInterest: 90_000,
|
|
estimatedIncome: 0,
|
|
incomeOnRecord: false
|
|
});
|
|
expect(r.unassessed.length).toBe(1);
|
|
expect(r.unassessed[0]).toContain('not on record');
|
|
// With no interest limb left, nothing else can fail it — so the branch must
|
|
// read the unassessed note rather than treat the row as cleared.
|
|
expect(r.eligible).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('statutory references travel with the year', () => {
|
|
it('quotes the 1961 Act for FY 2025-26 and the 2025 Act for FY 2026-27', () => {
|
|
expect(FY_TAX_CONFIG['2025-26'].regime).toBe('ITA_1961');
|
|
expect(FY_TAX_CONFIG['2025-26'].interestTdsSection).toContain('194A');
|
|
expect(FY_TAX_CONFIG['2025-26'].tdsStatementForm).toBe('Form 26Q');
|
|
expect(FY_TAX_CONFIG['2025-26'].declarationStatementFrequency).toBe('QUARTERLY');
|
|
|
|
expect(FY_TAX_CONFIG['2026-27'].regime).toBe('ITA_2025');
|
|
expect(FY_TAX_CONFIG['2026-27'].interestTdsSection).toContain('393');
|
|
expect(FY_TAX_CONFIG['2026-27'].tdsStatementForm).toBe('Form 140');
|
|
expect(FY_TAX_CONFIG['2026-27'].declarationStatementFrequency).toBe('MONTHLY');
|
|
});
|
|
|
|
it('carries the same thresholds and rates across the changeover', () => {
|
|
const a = FY_TAX_CONFIG['2025-26'];
|
|
const b = FY_TAX_CONFIG['2026-27'];
|
|
expect(b.thresholdGeneral).toBe(a.thresholdGeneral);
|
|
expect(b.thresholdSenior).toBe(a.thresholdSenior);
|
|
expect(b.rate).toBe(a.rate);
|
|
expect(b.rateNoPan).toBe(a.rateNoPan);
|
|
});
|
|
});
|
|
|
|
describe('default interest under section 201(1A)', () => {
|
|
it('counts any part of a month as a full month', () => {
|
|
expect(monthsOrPartBetween('2026-07-07', '2026-07-07')).toBe(0);
|
|
expect(monthsOrPartBetween('2026-07-07', '2026-07-08')).toBe(1);
|
|
expect(monthsOrPartBetween('2026-07-07', '2026-08-07')).toBe(1);
|
|
expect(monthsOrPartBetween('2026-07-07', '2026-08-08')).toBe(2);
|
|
expect(monthsOrPartBetween('2026-07-07', '2027-01-07')).toBe(6);
|
|
});
|
|
|
|
it('charges 1% a month for deducting late and 1.5% for depositing late', () => {
|
|
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEDUCTION', FY)).toEqual({
|
|
months: 6,
|
|
rate: 0.01,
|
|
interest: 360
|
|
});
|
|
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEPOSIT', FY).interest).toBe(540);
|
|
});
|
|
|
|
it('charges nothing when settled on time', () => {
|
|
expect(lateDeductionInterest(6_000, '2026-07-07', '2026-07-07', 'DEPOSIT', FY).interest).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('helpers', () => {
|
|
it('resolves the financial year of a date', () => {
|
|
expect(fyOf('2026-04-01')).toBe('2026-27');
|
|
expect(fyOf('2027-03-31')).toBe('2026-27');
|
|
expect(fyOf('2026-03-31')).toBe('2025-26');
|
|
expect(fyOf('2027-04-01')).toBe('2027-28');
|
|
});
|
|
|
|
it('buckets dated credits into the right quarters and drops out-of-year dates', () => {
|
|
const b = bucketByQuarter(
|
|
[
|
|
{ date: '2026-04-05', amount: 100 },
|
|
{ date: '2026-08-01', amount: 200 },
|
|
{ date: '2026-11-30', amount: 300 },
|
|
{ date: '2027-01-15', amount: 400 },
|
|
{ date: '2026-03-31', amount: 999 },
|
|
{ date: '2027-04-01', amount: 999 }
|
|
],
|
|
FY
|
|
);
|
|
expect(b).toEqual({ 1: 100, 2: 200, 3: 300, 4: 400 });
|
|
});
|
|
|
|
it('spreads an annual estimate without losing rupees to division', () => {
|
|
for (const annual of [
|
|
38_500,
|
|
52_801,
|
|
1,
|
|
999_999
|
|
]) {
|
|
expect(projectQuarterlyCredits(annual, FY).reduce((s, x) => s + x.amount, 0)).toBe(annual);
|
|
}
|
|
});
|
|
|
|
it('exposes the cumulative liability directly for ad-hoc checks', () => {
|
|
expect(cumulativeLiability(50_000, 50_000, general())).toBe(0);
|
|
expect(cumulativeLiability(50_001, 50_001, general())).toBe(5_000);
|
|
});
|
|
});
|