302 lines
12 KiB
TypeScript
302 lines
12 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 { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||
import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data';
|
||
import {
|
||
NEAR_THRESHOLD_BAND,
|
||
TdsReportFilter,
|
||
deductedByQuarter,
|
||
tds26qSummary,
|
||
tdsInterestRows,
|
||
tdsInterestRowsForFy,
|
||
tdsInterestSummary,
|
||
tdsRemittanceRows
|
||
} from './tds-reports.compute';
|
||
|
||
/** The complete year in the seed — the one a return would be filed for. */
|
||
const CLOSED_FY = '2025-26';
|
||
|
||
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
|
||
return {
|
||
fy: CLOSED_FY,
|
||
branchCode: '',
|
||
quarter: 0,
|
||
band: '',
|
||
search: '',
|
||
crossedOnly: false,
|
||
nearThresholdOnly: false,
|
||
...overrides
|
||
};
|
||
}
|
||
|
||
describe('the seed has every case the reports need to show', () => {
|
||
it('produces deductees on both sides of the threshold', () => {
|
||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||
expect(rows.length).toBeGreaterThan(20);
|
||
expect(rows.some((r) => r.crossed)).toBe(true);
|
||
expect(rows.some((r) => !r.crossed)).toBe(true);
|
||
expect(rows.some((r) => r.nearThreshold)).toBe(true);
|
||
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
|
||
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
|
||
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
|
||
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
|
||
});
|
||
|
||
it('gives senior citizens the higher threshold', () => {
|
||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||
for (const row of rows) {
|
||
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
|
||
}
|
||
});
|
||
|
||
it('deducts at the higher rate wherever PAN is missing', () => {
|
||
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
|
||
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('the reports follow the Act that governed each year', () => {
|
||
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
|
||
const old = tdsInterestRowsForFy('2025-26');
|
||
expect(old.some((r) => r.form === '15G')).toBe(true);
|
||
expect(old.some((r) => r.form === '15H')).toBe(true);
|
||
expect(old.some((r) => r.form === '121')).toBe(false);
|
||
|
||
// The two forms merged on 01 April 2026.
|
||
const current = tdsInterestRowsForFy('2026-27');
|
||
expect(current.every((r) => r.form === '121')).toBe(true);
|
||
});
|
||
|
||
it('keeps the age-based threshold band in both years', () => {
|
||
for (const fy of TDS_FYS) {
|
||
const rows = tdsInterestRowsForFy(fy);
|
||
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
|
||
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
|
||
for (const row of rows) {
|
||
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('savings interest stays out of the 194A aggregate', () => {
|
||
it('reports it separately and never inside the applicable interest', () => {
|
||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||
const withSavings = rows.filter((r) => r.exemptInterest > 0);
|
||
expect(withSavings.length).toBeGreaterThan(0);
|
||
|
||
for (const row of withSavings) {
|
||
// The applicable figure must equal only the time-deposit interest where
|
||
// this customer is the deductee.
|
||
const expected = row.deductee.accounts
|
||
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
|
||
.flatMap((a) => a.credits)
|
||
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
|
||
.reduce((s, c) => s + c.amount, 0);
|
||
expect(row.tdsApplicableInterest).toBe(expected);
|
||
}
|
||
});
|
||
|
||
it('leaves the savings totals out of the summary base', () => {
|
||
const s = tdsInterestSummary(filter());
|
||
expect(s.exemptInterest).toBeGreaterThan(0);
|
||
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
|
||
});
|
||
});
|
||
|
||
describe('joint deposits are attributed to the first-named holder only', () => {
|
||
it('excludes second-holder interest from the aggregate and shows it apart', () => {
|
||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
|
||
expect(secondHolders.length).toBeGreaterThan(0);
|
||
|
||
for (const row of secondHolders) {
|
||
const jointAccounts = row.deductee.accounts.filter(
|
||
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
|
||
);
|
||
expect(jointAccounts.length).toBeGreaterThan(0);
|
||
// None of that interest may appear in the figure the threshold is tested
|
||
// against, or one deposit would be taxed against two people.
|
||
const jointTotal = jointAccounts
|
||
.flatMap((a) => a.credits)
|
||
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
|
||
.reduce((s, c) => s + c.amount, 0);
|
||
expect(row.jointHeldInterest).toBe(jointTotal);
|
||
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('quarter figures add up to the year', () => {
|
||
it('per row', () => {
|
||
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
|
||
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
|
||
expect(quarterInterest).toBe(row.tdsApplicableInterest);
|
||
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
|
||
}
|
||
});
|
||
|
||
it('and across the summary', () => {
|
||
const s = tdsInterestSummary(filter());
|
||
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
|
||
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
|
||
});
|
||
});
|
||
|
||
describe('filters', () => {
|
||
it("narrows to a branch without changing anyone's figures", () => {
|
||
const all = tdsInterestRows(filter());
|
||
const branch = all[0].branchCode;
|
||
const scoped = tdsInterestRows(filter({ branchCode: branch }));
|
||
expect(scoped.length).toBeGreaterThan(0);
|
||
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
|
||
// A branch view must not re-aggregate — the threshold is computed bank-wide.
|
||
const sample = scoped[0];
|
||
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
|
||
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
|
||
});
|
||
|
||
it('finds the customers worth chasing a declaration from', () => {
|
||
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
|
||
expect(near.length).toBeGreaterThan(0);
|
||
for (const row of near) {
|
||
expect(row.crossed).toBe(false);
|
||
expect(row.headroom).toBeGreaterThan(0);
|
||
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
|
||
}
|
||
});
|
||
|
||
it('narrows to customers who crossed', () => {
|
||
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
|
||
expect(crossed.length).toBeGreaterThan(0);
|
||
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
|
||
});
|
||
|
||
it('searches on name, CIF and PAN', () => {
|
||
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
|
||
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
|
||
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
describe('declarations', () => {
|
||
it('save tax and record the interest paid gross', () => {
|
||
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
|
||
expect(declared.length).toBeGreaterThan(0);
|
||
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
|
||
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
|
||
// be carried even though no tax came off it.
|
||
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
|
||
for (const row of declared) {
|
||
expect(row.declarationUin).toBeTruthy();
|
||
}
|
||
});
|
||
|
||
it('leaves tax deducted before a mid-year declaration in place', () => {
|
||
// Some declarations in the seed were accepted in October. Tax already taken
|
||
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
|
||
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
|
||
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
|
||
);
|
||
expect(midYear.length).toBeGreaterThan(0);
|
||
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('remittance schedule', () => {
|
||
it('deposits by the seventh of the following month, and 30 April for March', () => {
|
||
expect(depositDueDate('2025-06')).toBe('2025-07-07');
|
||
expect(depositDueDate('2025-12')).toBe('2026-01-07');
|
||
expect(depositDueDate('2026-03')).toBe('2026-04-30');
|
||
});
|
||
|
||
it('remits exactly what the engine says was deducted', () => {
|
||
const deducted = deductedByQuarter(CLOSED_FY);
|
||
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
|
||
for (const row of rows) {
|
||
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
|
||
}
|
||
});
|
||
|
||
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
|
||
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
|
||
const late = rows.filter((r) => r.late);
|
||
expect(late.length).toBeGreaterThan(0);
|
||
for (const row of late) {
|
||
expect(row.delayMonths).toBeGreaterThan(0);
|
||
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
|
||
}
|
||
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
|
||
expect(row.defaultInterest).toBe(0);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('the 26Q reconciliation control', () => {
|
||
it.each(TDS_FYS)('balances for FY %s', (fy) => {
|
||
const s = tds26qSummary(fy, TDS_AS_AT);
|
||
// Deducted must equal deposited plus what is still within its due date.
|
||
// Anything else means tax has gone missing between the ledger and the
|
||
// Government, and the return must not be filed until it is found.
|
||
expect(s.difference).toBe(0);
|
||
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
|
||
});
|
||
|
||
it('splits the tax between the standard and the no-PAN rate without loss', () => {
|
||
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
|
||
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
|
||
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('splits interest into the deducted and the paid-gross halves', () => {
|
||
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
|
||
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
|
||
// The gross-paid half is the Form 121 disclosure and must not be zero here,
|
||
// or the report would never exercise that line.
|
||
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('agrees with the interest report on the same year', () => {
|
||
const summary = tdsInterestSummary(filter());
|
||
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
|
||
});
|
||
});
|
||
|
||
describe('the part-run current year', () => {
|
||
it('has postings only up to the business date', () => {
|
||
for (const deductee of tdsDeductees()) {
|
||
for (const account of deductee.accounts) {
|
||
for (const credit of account.credits) {
|
||
expect(credit.date <= TDS_AS_AT).toBe(true);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it('shows the closed quarter as settled and future obligations as not yet due', () => {
|
||
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
|
||
|
||
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
|
||
expect(rows.some((r) => r.quarter === 1)).toBe(true);
|
||
|
||
// Q3 cannot appear: no interest has posted there and nothing settles there.
|
||
expect(rows.some((r) => r.quarter === 3)).toBe(false);
|
||
|
||
// Q4 *can* appear even in a part-run year — deposits on annual interest
|
||
// posting settle once, at year end — but that is a future obligation, so it
|
||
// must read as pending and must never be flagged overdue.
|
||
for (const row of rows.filter((r) => r.quarter === 4)) {
|
||
expect(row.depositedOn).toBeNull();
|
||
expect(row.overdue).toBe(false);
|
||
expect(row.defaultInterest).toBe(0);
|
||
}
|
||
});
|
||
});
|