1102 lines
41 KiB
TypeScript
1102 lines
41 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/.
|
||
*/
|
||
|
||
/**
|
||
* TDS on deposit interest, and the effect of a payee's declaration on it.
|
||
*
|
||
* The governing law changed on 01 April 2026. Up to FY 2025-26 the charge sat in
|
||
* section 194A of the Income-tax Act 1961 and the declaration was Form 15G or
|
||
* 15H; from FY 2026-27 the charge is section 393(1) of the Income Tax Act 2025
|
||
* and a single Form 121 replaces both. Thresholds and rates carried across
|
||
* unchanged. Every provision reference lives in `FY_TAX_CONFIG` so a screen or a
|
||
* return always quotes the law that applied to the year it is showing.
|
||
*
|
||
* Two settlement modes, because deposit products run both:
|
||
*
|
||
* QUARTERLY — deduct at each quarter end, alongside the quarterly return cycle.
|
||
* ANNUAL — one settlement at financial year end, for products that post
|
||
* interest annually.
|
||
*
|
||
* The mode follows the product's interest-posting frequency, never a customer
|
||
* preference: the charge falls when interest is credited or paid, whichever is
|
||
* earlier, so the branch has no discretion to defer. See `tdsModeFor`.
|
||
*
|
||
* Both modes are built from one year-to-date liability ladder, so for an
|
||
* unchanging customer profile they add up to exactly the same rupee figure.
|
||
* `reconcile` reports the single case where they legitimately do not.
|
||
*/
|
||
|
||
/**
|
||
* Which Act governs a financial year.
|
||
*
|
||
* The Income-tax Act 1961 gave way to the Income Tax Act 2025 with effect from
|
||
* 01 April 2026, and the change is not cosmetic for this module: the two
|
||
* declaration forms merged into one and the eligibility test changed with them.
|
||
* A book that spans the changeover has to apply the right rules to each year,
|
||
* which is why the regime is carried per year rather than assumed.
|
||
*/
|
||
export type TaxRegime = 'ITA_1961' | 'ITA_2025';
|
||
|
||
/**
|
||
* The declaration a payee files to stop tax being deducted.
|
||
*
|
||
* Under the 1961 Act there were two, split by age: 15G for the under-60s and 15H
|
||
* for senior citizens. From FY 2026-27 both were replaced by a single Form 121
|
||
* open to residents of any age.
|
||
*/
|
||
export type DeclarationForm = '15G' | '15H' | '121';
|
||
|
||
/**
|
||
* Retained for the screens, which still speak of the 15G/15H split when showing
|
||
* a pre-2026 year.
|
||
*/
|
||
export type Form15Kind = '15G' | '15H';
|
||
|
||
export type TdsMode = 'QUARTERLY' | 'ANNUAL';
|
||
export type Quarter = 1 | 2 | 3 | 4;
|
||
|
||
/**
|
||
* Rates, limits and statutory references for one financial year.
|
||
*
|
||
* Every figure here moves with the annual Act, so it is table-driven rather than
|
||
* inlined at the call sites — a new year is a new entry, not a code change. The
|
||
* citations travel with the figures for the same reason: the screens and the
|
||
* returns must quote the provision that actually applied to the year on display.
|
||
*/
|
||
export interface FyTaxConfig {
|
||
/** Canonical label, e.g. '2026-27'. */
|
||
fy: string;
|
||
regime: TaxRegime;
|
||
/** The provision charging TDS on interest, for labels and audit trails. */
|
||
interestTdsSection: string;
|
||
/** The provision granting the below-threshold exemption. */
|
||
thresholdSection: string;
|
||
/** The provision charging the higher rate where PAN is not furnished. */
|
||
noPanSection: string;
|
||
/** The provision on consequences of failing to deduct or pay. */
|
||
defaultSection: string;
|
||
/** The declaration form in force. */
|
||
declarationForm: DeclarationForm;
|
||
/** The rule the declaration is made under. */
|
||
declarationRule: string;
|
||
/** Provision the declaration itself is filed under. */
|
||
declarationSection: string;
|
||
/** The quarterly TDS return the deduction is reported in. */
|
||
tdsStatementForm: string;
|
||
/** Rule governing that return. */
|
||
tdsStatementRule: string;
|
||
/** The certificate issued to the deductee. */
|
||
certificateForm: string;
|
||
/** How often the payer uploads the consolidated declaration statement. */
|
||
declarationStatementFrequency: 'MONTHLY' | 'QUARTERLY';
|
||
/**
|
||
* Day of the following month or quarter by which that statement is due.
|
||
* Under the 2025 Act the upload became monthly, due on the 7th.
|
||
*/
|
||
declarationStatementDueDay: number;
|
||
/** Deduction trigger for a payee below 60. */
|
||
thresholdGeneral: number;
|
||
/** Deduction trigger for a senior citizen — 60 at any time during the year. */
|
||
thresholdSenior: number;
|
||
/** Rate where a valid PAN is on record. */
|
||
rate: number;
|
||
/** Higher rate where PAN is absent or invalid (section 206AA). */
|
||
rateNoPan: number;
|
||
/**
|
||
* Basic exemption limit. Under the 1961 Act a 15G declarant's estimated income
|
||
* *and* their aggregate interest both had to stay within it.
|
||
*/
|
||
basicExemption: number;
|
||
/**
|
||
* Total income up to which rebate leaves tax at nil. This is the only ceiling
|
||
* for a 15H declarant, and from FY 2026-27 the only ceiling for anyone.
|
||
*/
|
||
nilTaxCeiling: number;
|
||
/**
|
||
* Multiple to round each cumulative liability to. 1 = nearest rupee, which is
|
||
* what deposit systems generally deduct at.
|
||
*/
|
||
roundTo: number;
|
||
/** Failure to deduct — interest per month or part thereof. */
|
||
lateDeductionRate: number;
|
||
/** Deducted but not deposited — interest per month or part thereof. */
|
||
lateDepositRate: number;
|
||
}
|
||
|
||
/**
|
||
* Statutory figures per financial year, keyed '2026-27'.
|
||
*
|
||
* Nothing is compiled in. The values live in m_tds_fy_config, are edited on
|
||
* Organization › TDS Configuration, and are loaded into this registry by
|
||
* `TdsConfigService` before any TDS screen opens (see `tdsConfigResolver`). A
|
||
* year with no entry makes the computation throw rather than guess.
|
||
*
|
||
* For a customer who has opted out onto the old regime, `basicExemption` and
|
||
* `nilTaxCeiling` are lower and vary by age; pass an override through
|
||
* `TdsContext.config`.
|
||
*/
|
||
export const FY_TAX_CONFIG: Record<string, FyTaxConfig> = {};
|
||
|
||
/** Bumped on every load, so memoised computations can tell one configuration from the next. */
|
||
export let fyTaxConfigVersion = 0;
|
||
|
||
/** Replaces the whole registry with what the platform holds. */
|
||
export function setFyTaxConfigs(configs: FyTaxConfig[]): void {
|
||
for (const key of Object.keys(FY_TAX_CONFIG)) {
|
||
delete FY_TAX_CONFIG[key];
|
||
}
|
||
for (const cfg of configs || []) {
|
||
FY_TAX_CONFIG[normaliseFy(cfg.fy)] = cfg;
|
||
}
|
||
fyTaxConfigVersion++;
|
||
}
|
||
|
||
/** The configured years, latest first. */
|
||
export function configuredFys(): string[] {
|
||
return Object.keys(FY_TAX_CONFIG).sort().reverse();
|
||
}
|
||
|
||
// ─────────────────────── Unique Identification Number ───────────────────────
|
||
|
||
/**
|
||
* A Form 121 UIN is 26 characters:
|
||
*
|
||
* D + 9 numeric digits + 6 digits of the tax year + the payer's 10-character TAN
|
||
*
|
||
* The payer allots one to every declaration it accepts and carries it into both
|
||
* the monthly consolidated statement and the deductee annexure of the quarterly
|
||
* return, which is how the department ties a nil-deduction back to a declaration.
|
||
*/
|
||
export const UIN_LENGTH = 26;
|
||
|
||
/** The 6-digit tax-year component of a UIN: start year and end year, e.g. 202627. */
|
||
export function uinTaxYearPart(fy: string): string {
|
||
const year = normaliseFy(fy);
|
||
return `${year.slice(0, 4)}${year.slice(5, 7)}`;
|
||
}
|
||
|
||
/**
|
||
* Builds a UIN from its parts.
|
||
*
|
||
* `sequence` is the payer's own running number for the year; it is zero-padded to
|
||
* nine digits rather than randomised so a branch can reconcile its register
|
||
* against the portal by eye, and so the same declaration always yields the same
|
||
* UIN on a re-run.
|
||
*/
|
||
export function buildUin(sequence: number, fy: string, tan: string): string {
|
||
const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0');
|
||
const normalisedTan = (tan || '')
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9]/g, '')
|
||
.padEnd(10, 'X')
|
||
.slice(0, 10);
|
||
return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`;
|
||
}
|
||
|
||
export interface UinParts {
|
||
sequence: number;
|
||
fy: string;
|
||
tan: string;
|
||
}
|
||
|
||
/** Splits a UIN back into its parts, or null if it is not well formed. */
|
||
export function parseUin(uin: string): UinParts | null {
|
||
const u = (uin || '').toUpperCase();
|
||
if (!/^D\d{9}\d{6}[A-Z0-9]{10}$/.test(u)) {
|
||
return null;
|
||
}
|
||
const startYear = Number(u.slice(10, 14));
|
||
return {
|
||
sequence: Number(u.slice(1, 10)),
|
||
fy: `${startYear}-${u.slice(14, 16)}`,
|
||
tan: u.slice(16)
|
||
};
|
||
}
|
||
|
||
export function isWellFormedUin(uin: string): boolean {
|
||
return parseUin(uin) !== null;
|
||
}
|
||
|
||
/**
|
||
* When the payer must have uploaded the consolidated statement covering a
|
||
* declaration accepted on the given date.
|
||
*
|
||
* Under the 2025 Act the upload is monthly and falls due on the 7th of the
|
||
* following month. Under the 1961 Act it ran with the quarter.
|
||
*/
|
||
export function declarationStatementDueDate(acceptedOn: string, fy: string): string {
|
||
const cfg = FY_TAX_CONFIG[normaliseFy(fy)];
|
||
if (!cfg) {
|
||
throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`);
|
||
}
|
||
const day = `${cfg.declarationStatementDueDay}`.padStart(2, '0');
|
||
|
||
if (cfg.declarationStatementFrequency === 'MONTHLY') {
|
||
const year = Number(acceptedOn.slice(0, 4));
|
||
const month = Number(acceptedOn.slice(5, 7));
|
||
const nextYear = month === 12 ? year + 1 : year;
|
||
const nextMonth = month === 12 ? 1 : month + 1;
|
||
return `${nextYear}-${`${nextMonth}`.padStart(2, '0')}-${day}`;
|
||
}
|
||
|
||
// Quarterly: the month after the quarter in which it was accepted, except the
|
||
// last quarter, which the 1961 Act ran to 30 April.
|
||
const quarter = quarterOf(acceptedOn);
|
||
const start = Number(normaliseFy(fy).slice(0, 4));
|
||
switch (quarter) {
|
||
case 1:
|
||
return `${start}-07-${day}`;
|
||
case 2:
|
||
return `${start}-10-${day}`;
|
||
case 3:
|
||
return `${start + 1}-01-${day}`;
|
||
default:
|
||
return `${start + 1}-04-30`;
|
||
}
|
||
}
|
||
|
||
/** The declaration form in force for a year. */
|
||
export function declarationFormFor(fy: string, dateOfBirth?: string): DeclarationForm {
|
||
const cfg = FY_TAX_CONFIG[normaliseFy(fy)];
|
||
if (!cfg) {
|
||
throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`);
|
||
}
|
||
if (cfg.regime === 'ITA_2025') {
|
||
// One form for everybody, whatever their age.
|
||
return '121';
|
||
}
|
||
return dateOfBirth && isSeniorInFy(dateOfBirth, fy) ? '15H' : '15G';
|
||
}
|
||
|
||
/** One interest credit posted to a deposit. */
|
||
export interface InterestCredit {
|
||
/** ISO date the interest was paid or credited — whichever came first. */
|
||
date: string;
|
||
amount: number;
|
||
accountNo?: string;
|
||
}
|
||
|
||
/**
|
||
* A Form 121 declaration on the branch's record.
|
||
*
|
||
* `acceptedOn` is the operative date: interest credited on or after it is paid
|
||
* without deduction. Interest credited before it was already liable and stays
|
||
* deducted.
|
||
*/
|
||
export interface Declaration {
|
||
kind: Form15Kind;
|
||
acceptedOn: string;
|
||
fy: string;
|
||
/**
|
||
* Unique Identification Number the branch allots on acceptance, carried into
|
||
* the consolidated statement of declarations filed with the department.
|
||
*/
|
||
uin?: string;
|
||
}
|
||
|
||
export interface TdsContext {
|
||
/** 'FY 2026-27' and '2026-27' are both accepted. */
|
||
fy: string;
|
||
dateOfBirth: string;
|
||
pan: string;
|
||
/** A declaration is only available to a resident payee. */
|
||
resident?: boolean;
|
||
declaration?: Declaration | null;
|
||
/** Overrides the FY table — used for old-regime customers. */
|
||
config?: Partial<FyTaxConfig>;
|
||
}
|
||
|
||
// ───────────────────────── Financial year plumbing ─────────────────────────
|
||
|
||
/** Strips the 'FY ' prefix the screens use and validates the shape. */
|
||
export function normaliseFy(fy: string): string {
|
||
const m = /(\d{4})-(\d{2,4})/.exec((fy || '').trim());
|
||
if (!m) {
|
||
throw new Error(`Unrecognised financial year: "${fy}"`);
|
||
}
|
||
const start = Number(m[1]);
|
||
const endShort = m[2].length === 4 ? m[2].slice(2) : m[2];
|
||
return `${start}-${endShort}`;
|
||
}
|
||
|
||
export function fyStart(fy: string): string {
|
||
return `${normaliseFy(fy).slice(0, 4)}-04-01`;
|
||
}
|
||
|
||
export function fyEnd(fy: string): string {
|
||
return `${Number(normaliseFy(fy).slice(0, 4)) + 1}-03-31`;
|
||
}
|
||
|
||
/**
|
||
* The financial year an ISO date belongs to, as '2026-27'.
|
||
*
|
||
* April opens the year, so January to March belong to the year that started in
|
||
* the previous calendar year.
|
||
*/
|
||
export function fyOf(isoDate: string): string {
|
||
const year = Number(isoDate.slice(0, 4));
|
||
const month = Number(isoDate.slice(5, 7));
|
||
const start = month >= 4 ? year : year - 1;
|
||
return `${start}-${`${(start + 1) % 100}`.padStart(2, '0')}`;
|
||
}
|
||
|
||
export const QUARTER_LABEL: Record<Quarter, string> = {
|
||
1: 'Q1 (Apr–Jun)',
|
||
2: 'Q2 (Jul–Sep)',
|
||
3: 'Q3 (Oct–Dec)',
|
||
4: 'Q4 (Jan–Mar)'
|
||
};
|
||
|
||
/** Statutory due date for the quarterly TDS return. */
|
||
export const QUARTER_DUE_DATE: Record<Quarter, string> = {
|
||
1: '31 Jul',
|
||
2: '31 Oct',
|
||
3: '31 Jan',
|
||
4: '31 May'
|
||
};
|
||
|
||
/** Which quarter of the Indian financial year an ISO date falls in. */
|
||
export function quarterOf(isoDate: string): Quarter {
|
||
const month = Number(isoDate.slice(5, 7));
|
||
if (month >= 4 && month <= 6) {
|
||
return 1;
|
||
}
|
||
if (month >= 7 && month <= 9) {
|
||
return 2;
|
||
}
|
||
if (month >= 10 && month <= 12) {
|
||
return 3;
|
||
}
|
||
return 4;
|
||
}
|
||
|
||
/** Last day of the given quarter of the given financial year. */
|
||
export function quarterEnd(quarter: Quarter, fy: string): string {
|
||
const start = Number(normaliseFy(fy).slice(0, 4));
|
||
switch (quarter) {
|
||
case 1:
|
||
return `${start}-06-30`;
|
||
case 2:
|
||
return `${start}-09-30`;
|
||
case 3:
|
||
return `${start}-12-31`;
|
||
default:
|
||
return `${start + 1}-03-31`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Age the customer attains during the financial year.
|
||
*
|
||
* Deliberately not age as at today. For the senior-citizen threshold the
|
||
* test is whether the payee is 60 at any time during the year, so a customer
|
||
* turning 60 in March gets the senior limit from the April credit onwards.
|
||
*/
|
||
export function ageInFy(dateOfBirth: string, fy: string): number {
|
||
if (!dateOfBirth) {
|
||
return 0;
|
||
}
|
||
const born = new Date(`${dateOfBirth}T00:00:00Z`);
|
||
const asAt = new Date(`${fyEnd(fy)}T00:00:00Z`);
|
||
let age = asAt.getUTCFullYear() - born.getUTCFullYear();
|
||
const m = asAt.getUTCMonth() - born.getUTCMonth();
|
||
if (m < 0 || (m === 0 && asAt.getUTCDate() < born.getUTCDate())) {
|
||
age--;
|
||
}
|
||
return age;
|
||
}
|
||
|
||
export function isSeniorInFy(dateOfBirth: string, fy: string): boolean {
|
||
return ageInFy(dateOfBirth, fy) >= 60;
|
||
}
|
||
|
||
/** 15H once the customer is 60 during the year, 15G otherwise. */
|
||
export function formKindFor(dateOfBirth: string, fy: string): Form15Kind {
|
||
return isSeniorInFy(dateOfBirth, fy) ? '15H' : '15G';
|
||
}
|
||
|
||
// ───────────────────────────── PAN validation ──────────────────────────────
|
||
|
||
/**
|
||
* The fourth character of a PAN encodes the holder type. Only an individual (P)
|
||
* or a HUF (H) can give a Form 121 declaration; the rest are entities.
|
||
*/
|
||
const PAN_HOLDER_TYPES = 'ABCFGHJLPT';
|
||
|
||
export function isWellFormedPan(pan: string): boolean {
|
||
const p = (pan || '').toUpperCase();
|
||
return /^[A-Z]{5}[0-9]{4}[A-Z]$/.test(p) && PAN_HOLDER_TYPES.includes(p[3]);
|
||
}
|
||
|
||
export function isIndividualPan(pan: string): boolean {
|
||
const p = (pan || '').toUpperCase();
|
||
return isWellFormedPan(p) && (p[3] === 'P' || p[3] === 'H');
|
||
}
|
||
|
||
// ──────────────────── Which accounts, and whose PAN ────────────────────────
|
||
|
||
/**
|
||
* The shape this module needs from a deposit account. Deliberately loose so it
|
||
* can be fed straight from a Fineract account payload.
|
||
*/
|
||
export interface TdsAccountRef {
|
||
id?: number;
|
||
accountNo?: string;
|
||
/** Fineract `depositType.code`, e.g. 'depositAccountType.fixedDeposit'. */
|
||
depositTypeCode?: string;
|
||
/** RBI account status captured on the stepper: SINGLE, JOINT, MINOR, … */
|
||
accountStatus?: string;
|
||
/** The account holder for a single-name account. */
|
||
clientId?: number | null;
|
||
/** First-named holder on a joint account. */
|
||
primaryClientId?: number | null;
|
||
/** Guardian operating a minor's account. */
|
||
guardianClientId?: number | null;
|
||
jointHolders?: { clientId: number }[];
|
||
/** Fineract `interestPostingPeriodType` — object, code or id. */
|
||
interestPostingPeriodType?: { id?: number; code?: string; value?: string } | string | number | null;
|
||
}
|
||
|
||
/** An account together with the interest credited to it. */
|
||
export interface TdsAccountInput extends TdsAccountRef {
|
||
credits: InterestCredit[];
|
||
}
|
||
|
||
/**
|
||
* Whether interest on this account attracts TDS at all.
|
||
*
|
||
* The charge bites on *time deposits* under either Act. Interest on a savings
|
||
* bank account is
|
||
* outside it — the customer claims the 80TTA / 80TTB deduction instead — so
|
||
* savings interest must be kept out of the aggregate as well as out of the
|
||
* deduction. Folding it in would push customers over the trigger who were never
|
||
* meant to cross it.
|
||
*/
|
||
export function isTdsApplicable(account: TdsAccountRef): boolean {
|
||
const code = (account.depositTypeCode || '').toLowerCase();
|
||
return code.includes('fixeddeposit') || code.includes('recurringdeposit');
|
||
}
|
||
|
||
/**
|
||
* The client in whose PAN the tax must be deducted.
|
||
*
|
||
* A joint deposit is reported against the first-named holder: the bank's
|
||
* obligation runs to one PAN, and the return carries one deductee for the account.
|
||
* Any `sharePercentage` on the account governs operational matters — signing
|
||
* authority, closure proceeds, insurance cover — and must not be used to split
|
||
* interest for tax, which would produce two deductees for one deposit.
|
||
*
|
||
* A minor's interest is clubbed into the guardian's income under section
|
||
* 64(1A), so the deduction follows the guardian.
|
||
*/
|
||
export function tdsDeducteeOf(account: TdsAccountRef): number | null {
|
||
const status = (account.accountStatus || '').toUpperCase();
|
||
if (status === 'JOINT') {
|
||
return account.primaryClientId ?? account.clientId ?? null;
|
||
}
|
||
if (status === 'MINOR') {
|
||
return account.guardianClientId ?? account.clientId ?? null;
|
||
}
|
||
return account.clientId ?? account.primaryClientId ?? null;
|
||
}
|
||
|
||
/**
|
||
* When the branch must settle tax on this account.
|
||
*
|
||
* Driven by the product's interest-posting frequency, because the charge falls
|
||
* when interest is credited or paid, whichever is earlier. A customer cannot
|
||
* elect to defer it — doing so puts the bank in default and attracts interest for
|
||
* every month or part of a month of delay. A customer who wants no deduction has
|
||
* two lawful routes: a declaration if they qualify, or a deposit product that
|
||
* posts interest annually.
|
||
*
|
||
* Monthly and half-yearly posting both settle on the quarterly cycle, which is
|
||
* what the quarterly return runs on.
|
||
*/
|
||
export function tdsModeFor(account: TdsAccountRef): TdsMode {
|
||
const raw = account.interestPostingPeriodType;
|
||
const text = typeof raw === 'object' && raw !== null ? `${raw.code ?? ''} ${raw.value ?? ''}` : `${raw ?? ''}`;
|
||
const s = text.toLowerCase();
|
||
// 'biannual' contains 'annual' — test the compound word first or half-yearly
|
||
// products would silently settle once a year.
|
||
if (s.includes('biannual') || s.includes('bi-annual') || s.includes('semiannual')) {
|
||
return 'QUARTERLY';
|
||
}
|
||
if (s.includes('annual')) {
|
||
return 'ANNUAL';
|
||
}
|
||
return 'QUARTERLY';
|
||
}
|
||
|
||
/**
|
||
* Every interest credit that counts towards one customer's deduction aggregate.
|
||
*
|
||
* This is the one place rules 8 to 12 meet: savings interest is dropped, the
|
||
* deductee is resolved per account, and only the selected year is kept. The
|
||
* aggregate spans every branch, because a bank on core banking computes the
|
||
* threshold for the bank as a whole rather than branch by branch.
|
||
*/
|
||
export function collectTdsCredits(clientId: number, accounts: TdsAccountInput[], fy: string): InterestCredit[] {
|
||
const year = normaliseFy(fy);
|
||
const out: InterestCredit[] = [];
|
||
for (const account of accounts) {
|
||
if (!isTdsApplicable(account) || tdsDeducteeOf(account) !== clientId) {
|
||
continue;
|
||
}
|
||
for (const c of account.credits) {
|
||
if (fyOf(c.date) === year) {
|
||
out.push({ ...c, accountNo: c.accountNo ?? account.accountNo });
|
||
}
|
||
}
|
||
}
|
||
return out.sort((a, b) => a.date.localeCompare(b.date));
|
||
}
|
||
|
||
// ─────────────────────────────── Eligibility ───────────────────────────────
|
||
|
||
export interface EligibilityInput {
|
||
/** Aggregate interest the bank expects to credit this year. */
|
||
estimatedInterest: number;
|
||
/** The customer's estimated total income for the year, interest included. */
|
||
estimatedIncome: number;
|
||
/**
|
||
* Whether the income figure is actually on record. The branch cannot compute
|
||
* it — income from outside the bank is only known if the customer declares
|
||
* it — so when it is missing the income limbs are reported as unassessed
|
||
* rather than silently passed.
|
||
*/
|
||
incomeOnRecord?: boolean;
|
||
}
|
||
|
||
export interface EligibilityResult {
|
||
eligible: boolean;
|
||
/** The form actually in force for the year — '121' from FY 2026-27. */
|
||
form: DeclarationForm;
|
||
/**
|
||
* The 15G/15H label, for screens showing a pre-2026 year. From FY 2026-27 the
|
||
* forms are merged, so this only reflects which threshold band the payee is in.
|
||
*/
|
||
kind: Form15Kind;
|
||
/** Why the declaration cannot be accepted, in the branch's words. */
|
||
reasons: string[];
|
||
/** Tests that could not be run because a figure was missing. */
|
||
unassessed: string[];
|
||
}
|
||
|
||
/**
|
||
* Whether the payee's declaration can be accepted.
|
||
*
|
||
* The test depends on which Act governs the year, and the difference is real:
|
||
*
|
||
* Up to FY 2025-26 (1961 Act) — two forms, split by age.
|
||
* 15G, under 60: estimated total income must be within the basic exemption
|
||
* limit *and* aggregate interest must also be within it. Both limbs.
|
||
* 15H, 60 and over: the only test is a nil tax liability; there is no
|
||
* ceiling on the interest figure, so a senior citizen with several lakh
|
||
* of interest may still declare if rebate brings the tax to nil.
|
||
*
|
||
* From FY 2026-27 (2025 Act) — one Form 121 for residents of any age, and a
|
||
* single test: estimated tax on total income for the year must be nil. The
|
||
* interest limb is gone, so a young depositor with large interest but no
|
||
* liability is now eligible where a 15G would have failed them.
|
||
*
|
||
* No declaration is valid without PAN under either regime, and none is open to a
|
||
* non-resident. Form 121 tightened this further: the 15G/15H forms accepted PAN
|
||
* or Aadhaar, whereas Form 121 takes PAN only.
|
||
*/
|
||
export function checkEligibility(ctx: TdsContext, input: EligibilityInput): EligibilityResult {
|
||
const cfg = resolveConfig(ctx);
|
||
const form = declarationFormFor(ctx.fy, ctx.dateOfBirth);
|
||
const kind = formKindFor(ctx.dateOfBirth, ctx.fy);
|
||
const reasons: string[] = [];
|
||
const unassessed: string[] = [];
|
||
const label = form === '121' ? 'Form 121' : `Form ${form}`;
|
||
|
||
if (!isWellFormedPan(ctx.pan)) {
|
||
reasons.push(
|
||
`PAN is missing or malformed — the declaration is void without a valid PAN (${cfg.declarationRule}).` +
|
||
(cfg.regime === 'ITA_2025' ? ' Form 121 accepts PAN only; Aadhaar is not a substitute.' : '')
|
||
);
|
||
} else if (!isIndividualPan(ctx.pan)) {
|
||
reasons.push(`PAN belongs to an entity, not an individual or HUF — ${label} is not available to it.`);
|
||
}
|
||
|
||
if (ctx.resident === false) {
|
||
reasons.push('Declaration is available only to a resident payee.');
|
||
}
|
||
|
||
const rupees = (n: number) => `₹${n.toLocaleString('en-IN')}`;
|
||
const incomeKnown = input.incomeOnRecord !== false;
|
||
|
||
// The interest limb only ever applied to a 15G, and only under the 1961 Act.
|
||
const hasInterestLimb = cfg.regime === 'ITA_1961' && form === '15G';
|
||
const incomeCeiling = hasInterestLimb ? cfg.basicExemption : cfg.nilTaxCeiling;
|
||
|
||
if (!incomeKnown) {
|
||
unassessed.push(
|
||
`Estimated total income is not on record — the nil-liability test for ${label} was not run. The branch ` +
|
||
`cannot compute income earned outside the bank; the payee must declare it.`
|
||
);
|
||
} else if (input.estimatedIncome > incomeCeiling) {
|
||
reasons.push(
|
||
hasInterestLimb
|
||
? `Estimated total income ${rupees(input.estimatedIncome)} exceeds the basic exemption limit of ` +
|
||
`${rupees(cfg.basicExemption)}.`
|
||
: `Estimated total income ${rupees(input.estimatedIncome)} leaves a tax liability — ${label} requires the ` +
|
||
`liability for the year to be nil.`
|
||
);
|
||
}
|
||
|
||
if (hasInterestLimb && input.estimatedInterest > cfg.basicExemption) {
|
||
// The bank knows this figure without asking, so it is always assessable.
|
||
reasons.push(
|
||
`Aggregate interest ${rupees(input.estimatedInterest)} exceeds the basic exemption limit — a 15G declarant ` +
|
||
`must clear this limb as well as the income limb.`
|
||
);
|
||
}
|
||
|
||
return { eligible: reasons.length === 0, form, kind, reasons, unassessed };
|
||
}
|
||
|
||
// ──────────────────────── The year-to-date liability ────────────────────────
|
||
|
||
function resolveConfig(ctx: TdsContext): FyTaxConfig {
|
||
const fy = normaliseFy(ctx.fy);
|
||
const base = FY_TAX_CONFIG[fy];
|
||
if (!base) {
|
||
throw new Error(`No tax configuration for FY ${fy}`);
|
||
}
|
||
return { ...base, ...ctx.config };
|
||
}
|
||
|
||
/** Rounds to the nearest multiple of `to`. */
|
||
function roundToMultiple(value: number, to: number): number {
|
||
return to <= 1 ? Math.round(value) : Math.round(value / to) * to;
|
||
}
|
||
|
||
/**
|
||
* TDS on the interest credited so far this year.
|
||
*
|
||
* The threshold is a *trigger*, not an allowance. Once aggregate interest for
|
||
* the year exceeds it, the whole of that interest becomes liable — not merely
|
||
* the excess. ₹52,000 of interest attracts 10% of ₹52,000, i.e. ₹5,200, and not
|
||
* 10% of the ₹2,000 above the line.
|
||
*
|
||
* Rounding is applied here, to the cumulative figure. That placement is what
|
||
* makes the quarterly schedule add up — see `quarterlySchedule`.
|
||
*/
|
||
export function cumulativeLiability(cumInterest: number, cumLiableInterest: number, ctx: TdsContext): number {
|
||
const cfg = resolveConfig(ctx);
|
||
const threshold = isSeniorInFy(ctx.dateOfBirth, ctx.fy) ? cfg.thresholdSenior : cfg.thresholdGeneral;
|
||
if (cumInterest <= threshold) {
|
||
return 0;
|
||
}
|
||
const rate = isWellFormedPan(ctx.pan) ? cfg.rate : cfg.rateNoPan;
|
||
return roundToMultiple(Math.max(0, cumLiableInterest) * rate, cfg.roundTo);
|
||
}
|
||
|
||
// ────────────────────────────── The schedules ───────────────────────────────
|
||
|
||
export interface TdsQuarterLine {
|
||
quarter: Quarter;
|
||
label: string;
|
||
dueDate: string;
|
||
/** Interest credited during the quarter. */
|
||
interest: number;
|
||
/** Interest credited from 01 April to the end of this quarter. */
|
||
cumInterest: number;
|
||
/** Of that, the part not covered by a declaration. */
|
||
cumLiableInterest: number;
|
||
/** Liability on the year to date, before crediting what is already deducted. */
|
||
cumLiability: number;
|
||
/**
|
||
* Deducted in this quarter — the catch-up over what has already gone. A
|
||
* negative figure is an in-year adjustment following an interest reversal.
|
||
*/
|
||
tds: number;
|
||
/** Deducted from 01 April to the end of this quarter. */
|
||
cumTds: number;
|
||
/** The aggregate crossed the deduction trigger during this quarter. */
|
||
thresholdCrossedHere: boolean;
|
||
/** A declaration was on the branch's record by the end of this quarter. */
|
||
declarationActive: boolean;
|
||
}
|
||
|
||
export interface TdsSchedule {
|
||
mode: TdsMode;
|
||
fy: string;
|
||
kind: Form15Kind;
|
||
senior: boolean;
|
||
/** Rate actually applied — 20% where PAN is not on record. */
|
||
rateApplied: number;
|
||
threshold: number;
|
||
totalInterest: number;
|
||
/** Interest not covered by a declaration. */
|
||
liableInterest: number;
|
||
/** Interest paid gross because a declaration was on record. */
|
||
shieldedInterest: number;
|
||
totalTds: number;
|
||
lines: TdsQuarterLine[];
|
||
/** Anything the branch should read before filing. */
|
||
notes: string[];
|
||
}
|
||
|
||
function emptyQuarters(): Record<Quarter, number> {
|
||
return { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||
}
|
||
|
||
/**
|
||
* Buckets dated interest credits into the four quarters of the year.
|
||
*
|
||
* Credits outside the year are dropped rather than folded into Q4 — a stray
|
||
* date is a data error, not a Q4 posting.
|
||
*/
|
||
export function bucketByQuarter(credits: InterestCredit[], fy: string): Record<Quarter, number> {
|
||
const from = fyStart(fy);
|
||
const to = fyEnd(fy);
|
||
const out = emptyQuarters();
|
||
for (const c of credits) {
|
||
if (c.date >= from && c.date <= to) {
|
||
out[quarterOf(c.date)] += c.amount;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Splits each quarter's interest into shielded and liable halves. */
|
||
function splitByDeclaration(
|
||
credits: InterestCredit[],
|
||
ctx: TdsContext
|
||
): { liable: Record<Quarter, number>; shielded: Record<Quarter, number> } {
|
||
const from = fyStart(ctx.fy);
|
||
const to = fyEnd(ctx.fy);
|
||
const decl = ctx.declaration;
|
||
const liable = emptyQuarters();
|
||
const shielded = emptyQuarters();
|
||
|
||
for (const c of credits) {
|
||
if (c.date < from || c.date > to) {
|
||
continue;
|
||
}
|
||
const q = quarterOf(c.date);
|
||
// A declaration shields interest credited on or after the date the branch
|
||
// accepted it. It never reaches back over earlier credits.
|
||
if (decl && decl.acceptedOn && c.date >= decl.acceptedOn) {
|
||
shielded[q] += c.amount;
|
||
} else {
|
||
liable[q] += c.amount;
|
||
}
|
||
}
|
||
return { liable, shielded };
|
||
}
|
||
|
||
interface LadderStep {
|
||
quarter: Quarter;
|
||
interest: number;
|
||
cumInterest: number;
|
||
cumLiableInterest: number;
|
||
cumLiability: number;
|
||
declarationActive: boolean;
|
||
}
|
||
|
||
/**
|
||
* The year-to-date liability at each quarter end.
|
||
*
|
||
* This single ladder is the source of both schedules. The quarterly mode reads
|
||
* every rung; the annual mode reads only the last. That is the whole reason the
|
||
* two agree — they are not two calculations, they are two samplings of one.
|
||
*/
|
||
function buildLadder(credits: InterestCredit[], ctx: TdsContext): LadderStep[] {
|
||
const { liable, shielded } = splitByDeclaration(credits, ctx);
|
||
const steps: LadderStep[] = [];
|
||
let cumInterest = 0;
|
||
let cumLiableInterest = 0;
|
||
|
||
for (const q of [
|
||
1,
|
||
2,
|
||
3,
|
||
4
|
||
] as Quarter[]) {
|
||
cumInterest += liable[q] + shielded[q];
|
||
cumLiableInterest += liable[q];
|
||
steps.push({
|
||
quarter: q,
|
||
interest: liable[q] + shielded[q],
|
||
cumInterest,
|
||
cumLiableInterest,
|
||
cumLiability: cumulativeLiability(cumInterest, cumLiableInterest, ctx),
|
||
// Keyed off the acceptance date rather than off whether shielded interest
|
||
// happened to arrive: a quarter carrying only a reversal is still a
|
||
// quarter in which the declaration was on the branch's record.
|
||
declarationActive:
|
||
!!ctx.declaration && !!ctx.declaration.acceptedOn && quarterEnd(q, ctx.fy) >= ctx.declaration.acceptedOn
|
||
});
|
||
}
|
||
return steps;
|
||
}
|
||
|
||
function scheduleShell(mode: TdsMode, credits: InterestCredit[], ctx: TdsContext): TdsSchedule {
|
||
const cfg = resolveConfig(ctx);
|
||
const senior = isSeniorInFy(ctx.dateOfBirth, ctx.fy);
|
||
const { liable, shielded } = splitByDeclaration(credits, ctx);
|
||
const sum = (r: Record<Quarter, number>) => r[1] + r[2] + r[3] + r[4];
|
||
return {
|
||
mode,
|
||
fy: normaliseFy(ctx.fy),
|
||
kind: formKindFor(ctx.dateOfBirth, ctx.fy),
|
||
senior,
|
||
rateApplied: isWellFormedPan(ctx.pan) ? cfg.rate : cfg.rateNoPan,
|
||
threshold: senior ? cfg.thresholdSenior : cfg.thresholdGeneral,
|
||
totalInterest: sum(liable) + sum(shielded),
|
||
liableInterest: sum(liable),
|
||
shieldedInterest: sum(shielded),
|
||
totalTds: 0,
|
||
lines: [],
|
||
notes: []
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Deduct at each quarter end.
|
||
*
|
||
* Each quarter deducts the difference between the liability on the year to date
|
||
* and what has already been deducted:
|
||
*
|
||
* tds(n) = cumLiability(n) − cumTds(n−1)
|
||
*
|
||
* Written that way the four quarters telescope: they sum to cumLiability(4) less
|
||
* cumLiability(0), which is precisely the liability on the whole year's
|
||
* interest. The identity survives rounding because it is the *cumulative* figure
|
||
* that gets rounded, so each quarter is a difference of two already rounded
|
||
* numbers. Rounding each quarter's own share independently is what makes naive
|
||
* implementations drift by a rupee or two a year.
|
||
*
|
||
* It also gets the threshold right for free. Nothing is deducted while the
|
||
* aggregate is under the trigger; in the quarter it is crossed, the full
|
||
* year-to-date liability falls due at once. That catch-up looks severe against
|
||
* the one quarter's interest, and it is what a branch actually deducts.
|
||
*/
|
||
export function quarterlySchedule(credits: InterestCredit[], ctx: TdsContext): TdsSchedule {
|
||
const out = scheduleShell('QUARTERLY', credits, ctx);
|
||
const ladder = buildLadder(credits, ctx);
|
||
const decl = ctx.declaration;
|
||
|
||
let cumTds = 0;
|
||
let frozen = false;
|
||
let prevCumInterest = 0;
|
||
|
||
for (const step of ladder) {
|
||
// Once a declaration is operative the branch stops deducting. It does not
|
||
// refund what it has already remitted to the Government either — the
|
||
// customer reclaims that in their return, which is why cumTds freezes here
|
||
// rather than falling back to the now-lower liability.
|
||
if (decl && step.declarationActive) {
|
||
frozen = true;
|
||
}
|
||
|
||
const target = frozen ? Math.max(cumTds, step.cumLiability) : step.cumLiability;
|
||
const tds = target - cumTds;
|
||
cumTds = target;
|
||
|
||
out.lines.push({
|
||
quarter: step.quarter,
|
||
label: QUARTER_LABEL[step.quarter],
|
||
dueDate: QUARTER_DUE_DATE[step.quarter],
|
||
interest: step.interest,
|
||
cumInterest: step.cumInterest,
|
||
cumLiableInterest: step.cumLiableInterest,
|
||
cumLiability: step.cumLiability,
|
||
tds,
|
||
cumTds,
|
||
thresholdCrossedHere: prevCumInterest <= out.threshold && step.cumInterest > out.threshold,
|
||
declarationActive: step.declarationActive
|
||
});
|
||
prevCumInterest = step.cumInterest;
|
||
}
|
||
|
||
out.totalTds = cumTds;
|
||
|
||
if (decl && decl.acceptedOn && cumTds > 0) {
|
||
out.notes.push(
|
||
`₹${cumTds.toLocaleString('en-IN')} relates to interest credited before the declaration was accepted on ` +
|
||
`${decl.acceptedOn} and has been remitted to the Government. A declaration operates prospectively, so ` +
|
||
`the branch cannot reverse it — the customer recovers it in their return if the nil-liability basis holds.`
|
||
);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Defer to a single settlement at financial year end.
|
||
*
|
||
* This is the last rung of the same ladder, so with an unchanging profile it
|
||
* equals the sum of the quarterly figures by construction. The per-quarter lines
|
||
* still carry the interest and the liability as they accrued, with the whole
|
||
* deduction landing in Q4 — which is what the branch's Q4 return will show.
|
||
*/
|
||
export function annualSchedule(credits: InterestCredit[], ctx: TdsContext): TdsSchedule {
|
||
const out = scheduleShell('ANNUAL', credits, ctx);
|
||
const ladder = buildLadder(credits, ctx);
|
||
const final = ladder[ladder.length - 1];
|
||
let prevCumInterest = 0;
|
||
|
||
for (const step of ladder) {
|
||
const isLast = step.quarter === 4;
|
||
out.lines.push({
|
||
quarter: step.quarter,
|
||
label: QUARTER_LABEL[step.quarter],
|
||
dueDate: QUARTER_DUE_DATE[step.quarter],
|
||
interest: step.interest,
|
||
cumInterest: step.cumInterest,
|
||
cumLiableInterest: step.cumLiableInterest,
|
||
cumLiability: step.cumLiability,
|
||
tds: isLast ? final.cumLiability : 0,
|
||
cumTds: isLast ? final.cumLiability : 0,
|
||
thresholdCrossedHere: prevCumInterest <= out.threshold && step.cumInterest > out.threshold,
|
||
declarationActive: step.declarationActive
|
||
});
|
||
prevCumInterest = step.cumInterest;
|
||
}
|
||
|
||
out.totalTds = final.cumLiability;
|
||
return out;
|
||
}
|
||
|
||
export function buildSchedule(mode: TdsMode, credits: InterestCredit[], ctx: TdsContext): TdsSchedule {
|
||
return mode === 'QUARTERLY' ? quarterlySchedule(credits, ctx) : annualSchedule(credits, ctx);
|
||
}
|
||
|
||
// ───────────────────────────── Reconciliation ──────────────────────────────
|
||
|
||
export interface TdsReconciliation {
|
||
quarterlyTotal: number;
|
||
annualTotal: number;
|
||
/** Quarterly less annual. Positive means the quarterly book deducted more. */
|
||
difference: number;
|
||
matched: boolean;
|
||
reason: string;
|
||
}
|
||
|
||
/**
|
||
* Checks the two modes against each other for one customer.
|
||
*
|
||
* They agree to the rupee in every ordinary case, including a declaration taken
|
||
* mid-year: because a declaration operates prospectively, the year-end figure
|
||
* still recognises the interest credited before it was accepted as liable, and
|
||
* the annual book deducts on exactly that. Interest reversals agree too — the
|
||
* quarterly book adjusts them in-year.
|
||
*
|
||
* One case genuinely diverges, and it is not an arithmetic fault. If the
|
||
* year-to-date liability *falls* after a declaration has already frozen
|
||
* deductions — a large reversal dragging the aggregate back under the deduction
|
||
* trigger, say — the quarterly book is holding tax it may no longer collect but
|
||
* cannot refund either, while the annual book never deducts it at all.
|
||
*
|
||
* Worth running as a year-end control: a non-zero difference with no declaration
|
||
* on record, or with no fall in liability behind it, is a bug.
|
||
*/
|
||
export function reconcile(credits: InterestCredit[], ctx: TdsContext): TdsReconciliation {
|
||
const q = quarterlySchedule(credits, ctx);
|
||
const a = annualSchedule(credits, ctx);
|
||
const difference = q.totalTds - a.totalTds;
|
||
|
||
let reason = 'Quarterly and annual settlement agree on the year.';
|
||
if (difference !== 0) {
|
||
const firstActive = q.lines.find((l) => l.declarationActive);
|
||
reason = firstActive
|
||
? `₹${difference.toLocaleString('en-IN')} was deducted before the declaration became operative in ` +
|
||
`${QUARTER_LABEL[firstActive.quarter]} and the year-to-date liability has since fallen. The branch ` +
|
||
`cannot refund it; the customer recovers it in their return.`
|
||
: `Unexplained difference of ₹${difference.toLocaleString('en-IN')} — investigate before filing.`;
|
||
}
|
||
|
||
return { quarterlyTotal: q.totalTds, annualTotal: a.totalTds, difference, matched: difference === 0, reason };
|
||
}
|
||
|
||
// ──────────────────────── Default interest (s.201(1A)) ──────────────────────
|
||
|
||
/**
|
||
* Months, or parts of a month, between two dates.
|
||
*
|
||
* Section 201(1A) charges for "every month or part of a month", so any delay at
|
||
* all counts as a full month and a fortnight over a month counts as two.
|
||
*/
|
||
export function monthsOrPartBetween(fromIso: string, toIso: string): number {
|
||
if (!fromIso || !toIso || toIso <= fromIso) {
|
||
return 0;
|
||
}
|
||
const [
|
||
y1,
|
||
m1,
|
||
d1
|
||
] = fromIso.split('-').map(Number);
|
||
const [
|
||
y2,
|
||
m2,
|
||
d2
|
||
] = toIso.split('-').map(Number);
|
||
let months = (y2 - y1) * 12 + (m2 - m1);
|
||
if (d2 > d1) {
|
||
months += 1;
|
||
}
|
||
return Math.max(1, months);
|
||
}
|
||
|
||
export interface LateInterestResult {
|
||
months: number;
|
||
rate: number;
|
||
interest: number;
|
||
}
|
||
|
||
/**
|
||
* Interest payable by the bank for deducting late, or for depositing late.
|
||
*
|
||
* `kind` picks the limb: 'DEDUCTION' is 1% a month from the date the tax was
|
||
* deductible to the date it was actually deducted; 'DEPOSIT' is 1.5% a month
|
||
* from deduction to actual payment. This is the cost the branch carries if it
|
||
* settles outside the statutory cycle, and it is what makes deferring at a
|
||
* customer's request a bad trade for the bank.
|
||
*/
|
||
export function lateDeductionInterest(
|
||
amount: number,
|
||
dueDateIso: string,
|
||
actualDateIso: string,
|
||
kind: 'DEDUCTION' | 'DEPOSIT',
|
||
fy: string
|
||
): LateInterestResult {
|
||
const cfg = FY_TAX_CONFIG[normaliseFy(fy)];
|
||
if (!cfg) {
|
||
throw new Error(`No tax configuration for FY ${normaliseFy(fy)}`);
|
||
}
|
||
const rate = kind === 'DEDUCTION' ? cfg.lateDeductionRate : cfg.lateDepositRate;
|
||
const months = monthsOrPartBetween(dueDateIso, actualDateIso);
|
||
return { months, rate, interest: Math.round(amount * rate * months) };
|
||
}
|
||
|
||
// ──────────────────────────────── Projection ────────────────────────────────
|
||
|
||
/**
|
||
* Spreads a single annual interest estimate evenly across the four quarters.
|
||
*
|
||
* A convenience for screens that hold only a projected annual figure, as the
|
||
* Form 121 register does. The remainder lands in Q4 so the four parts add back
|
||
* to the estimate exactly rather than losing rupees to division.
|
||
*/
|
||
export function projectQuarterlyCredits(annualInterest: number, fy: string): InterestCredit[] {
|
||
const startYear = Number(normaliseFy(fy).slice(0, 4));
|
||
const base = Math.floor(annualInterest / 4);
|
||
const dates = [
|
||
`${startYear}-06-30`,
|
||
`${startYear}-09-30`,
|
||
`${startYear}-12-31`,
|
||
`${startYear + 1}-03-31`
|
||
];
|
||
return dates.map((date, i) => ({ date, amount: i === 3 ? annualInterest - base * 3 : base }));
|
||
}
|