Files
cbsnexora/src/app/letter-mgmt/certificates/form-15gh/tds-15gh.compute.ts
T

1102 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }));
}