/** * 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 = {}; /** 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; } // ───────────────────────── 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 = { 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 = { 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 { 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 { 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; shielded: Record } { 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) => 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 })); }