/** * 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/. */ /** * Aggregation for the two TDS reports. * * Every rupee figure here comes out of `tds-15gh.compute` — the reports do no * tax arithmetic of their own. Writing it twice is how the register and the * return end up disagreeing, so the engine is the single authority and this * module only groups and totals what it returns. */ import { DeclarationForm, FY_TAX_CONFIG, Quarter, TdsMode, TdsSchedule, ageInFy, buildSchedule, collectTdsCredits, declarationFormFor, formKindFor, fyOf, isSeniorInFy, isTdsApplicable, isWellFormedPan, lateDeductionInterest, monthsOrPartBetween, tdsDeducteeOf, tdsModeFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data'; // ─────────────────────────────── Filtering ─────────────────────────────── export interface TdsReportFilter { fy: string; /** '' means every branch. */ branchCode: string; /** 0 means the whole year. */ quarter: 0 | Quarter; /** '' means both declaration types. */ band: '' | 'GENERAL' | 'SENIOR'; /** Free text over name, CIF and PAN. */ search: string; /** Only customers whose aggregate crossed the deduction trigger. */ crossedOnly: boolean; /** Only customers sitting just below the trigger — the follow-up list. */ nearThresholdOnly: boolean; } /** How close to the trigger counts as "worth chasing a declaration for". */ export const NEAR_THRESHOLD_BAND = 6_000; // ───────────────────────── Report 1: interest ───────────────────────── export interface TdsInterestRow { deductee: TdsDeductee; branchCode: string; /** The form in force for the year — '121' from FY 2026-27. */ form: DeclarationForm; /** Which deduction trigger applies. Age-based, even after the forms merged. */ band: 'GENERAL' | 'SENIOR'; /** * The 15G/15H label. A form distinction only up to FY 2025-26; from FY 2026-27 * the two merged, so it duplicates `band` and is kept for pre-2026 years. */ kind: '15G' | '15H'; age: number; /** Settlement timing, from the customer's deposit products. */ mode: TdsMode; /** Interest on time deposits where this customer is the deductee, by quarter. */ quarters: Record; /** Total of the above — the figure the deduction trigger is tested against. */ tdsApplicableInterest: number; /** Savings interest, shown alongside but outside the charge on time deposits. */ exemptInterest: number; /** Interest on deposits where somebody else is the first-named holder. */ jointHeldInterest: number; threshold: number; crossed: boolean; /** The quarter the aggregate crossed the trigger, if it did. */ crossedInQuarter: Quarter | null; /** How much more interest before the trigger bites. */ headroom: number; nearThreshold: boolean; schedule: TdsSchedule; hasDeclaration: boolean; declarationUin: string | null; declarationAcceptedOn: string | null; /** Tax the declaration kept out of the customer's pocket. */ tdsSaved: number; /** Interest paid gross on the strength of the declaration — reportable in the return. */ paidWithoutDeduction: number; panOnRecord: boolean; rateApplied: number; } function emptyQuarters(): Record { return { 1: 0, 2: 0, 3: 0, 4: 0 }; } /** * One row per deductee for the selected year. * * Memoised on the year alone: building a row runs two schedules, and the getters * that feed the template are read many times per change-detection pass. */ let rowCacheKey = ''; let rowCache: TdsInterestRow[] = []; export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { if (rowCacheKey === fy) { return rowCache; } const rows: TdsInterestRow[] = []; for (const deductee of tdsDeductees()) { const ctx = { fy, dateOfBirth: deductee.dob, pan: deductee.pan, resident: true }; // Rules 8 to 12 live in the engine: savings interest dropped, joint deposits // attributed to the first-named holder, every branch aggregated. const credits = collectTdsCredits(deductee.clientId, deductee.accounts, fy); if (!credits.length && !deductee.accounts.length) { continue; } const declaration = deductee.declarations[fy] ?? null; const mode = overallModeFor(deductee); const schedule = buildSchedule(mode, credits, { ...ctx, declaration }); const withoutDeclaration = declaration ? buildSchedule(mode, credits, { ...ctx, declaration: null }) : schedule; const quarters = emptyQuarters(); for (const line of schedule.lines) { quarters[line.quarter] = line.interest; } let exemptInterest = 0; let jointHeldInterest = 0; for (const account of deductee.accounts) { const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0); if (!isTdsApplicable(account)) { exemptInterest += inYear; } else if (tdsDeducteeOf(account) !== deductee.clientId) { jointHeldInterest += inYear; } } const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere); const headroom = Math.max(0, schedule.threshold - schedule.totalInterest); rows.push({ deductee, branchCode: deductee.branchCode, form: declarationFormFor(fy, deductee.dob), band: isSeniorInFy(deductee.dob, fy) ? 'SENIOR' : 'GENERAL', kind: formKindFor(deductee.dob, fy), age: ageInFy(deductee.dob, fy), mode, quarters, tdsApplicableInterest: schedule.totalInterest, exemptInterest, jointHeldInterest, threshold: schedule.threshold, crossed: schedule.totalInterest > schedule.threshold, crossedInQuarter: crossedLine?.quarter ?? null, headroom, nearThreshold: headroom > 0 && headroom <= NEAR_THRESHOLD_BAND, schedule, hasDeclaration: !!declaration, declarationUin: declaration?.uin ?? null, declarationAcceptedOn: declaration?.acceptedOn ?? null, tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0, paidWithoutDeduction: schedule.shieldedInterest, panOnRecord: isWellFormedPan(deductee.pan), rateApplied: schedule.rateApplied }); } rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest); rowCacheKey = fy; rowCache = rows; return rows; } /** * When tax settles for a customer whose deposits post interest on different * cycles. A quarterly deposit anywhere means a deduction falls due at the first * quarter end, so that governs. */ function overallModeFor(deductee: TdsDeductee): TdsMode { const applicable = deductee.accounts.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === deductee.clientId); return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL'; } export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] { const needle = filter.search.trim().toLowerCase(); return tdsInterestRowsForFy(filter.fy).filter((row) => { if (filter.branchCode && row.branchCode !== filter.branchCode) { return false; } if (filter.band && row.band !== filter.band) { return false; } if (filter.crossedOnly && !row.crossed) { return false; } if (filter.nearThresholdOnly && !row.nearThreshold) { return false; } // A quarter filter narrows to customers who actually had interest in it. if (filter.quarter && !row.quarters[filter.quarter]) { return false; } if (!needle) { return true; } const d = row.deductee; return ( d.name.toLowerCase().includes(needle) || d.customerId.toLowerCase().includes(needle) || d.pan.toLowerCase().includes(needle) ); }); } export interface TdsInterestSummary { deductees: number; /** Interest on time deposits — the base the charge applies to. */ applicableInterest: number; /** Savings interest, outside the section. */ exemptInterest: number; totalTds: number; crossed: number; nearThreshold: number; declarations: number; tdsSaved: number; paidWithoutDeduction: number; noPan: number; byQuarter: { quarter: Quarter; interest: number; tds: number }[]; } export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary { const rows = tdsInterestRows(filter); const byQuarter = ([ 1, 2, 3, 4 ] as Quarter[]).map((quarter) => ({ quarter, interest: rows.reduce((s, r) => s + r.quarters[quarter], 0), tds: rows.reduce((s, r) => s + (r.schedule.lines.find((l) => l.quarter === quarter)?.tds ?? 0), 0) })); return { deductees: rows.length, applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0), totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), crossed: rows.filter((r) => r.crossed).length, nearThreshold: rows.filter((r) => r.nearThreshold).length, declarations: rows.filter((r) => r.hasDeclaration).length, tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0), paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0), noPan: rows.filter((r) => !r.panOnRecord).length, byQuarter }; } // ─────────────────────── Report 2: remittance ─────────────────────── export interface TdsRemittanceRow extends TdsChallan { /** Statutory due date has passed with nothing deposited. */ overdue: boolean; /** Deposited, but after the due date. */ late: boolean; /** Months or part months of delay. */ delayMonths: number; /** Interest charged for depositing late. */ defaultInterest: number; /** Return the quarter's deduction is reported in. */ returnDueDate: string; } const RETURN_DUE: Record = { 1: '31 Jul', 2: '31 Oct', 3: '31 Jan', 4: '31 May' }; /** Tax deducted per quarter, from the engine, for one year. */ export function deductedByQuarter(fy: string): Record { const out: Record = { 1: 0, 2: 0, 3: 0, 4: 0 }; for (const row of tdsInterestRowsForFy(fy)) { for (const line of row.schedule.lines) { out[line.quarter] += line.tds; } } return out; } export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] { const deducted: Record> = {}; for (const year of Object.keys(FY_TAX_CONFIG)) { deducted[year] = deductedByQuarter(year); } const challans = tdsChallans(deducted)[fy] ?? []; return challans.map((c) => { const overdue = !c.depositedOn && c.dueDate < asAt; const late = !!c.depositedOn && c.depositedOn > c.dueDate; // While a payment is overdue the clock runs to today; once paid it stops on // the payment date. const settledOn = c.depositedOn ?? asAt; const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0; const defaultInterest = late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0; return { ...c, overdue, late, delayMonths, defaultInterest, returnDueDate: RETURN_DUE[c.quarter] }; }); } /** * The year-end position — what the quarterly TDS return has to reconcile to. * * `difference` is the control figure: tax the engine says was deducted, less what * actually reached the Government. Anything other than zero, once the pending * challans are settled, means something is wrong and must be found before filing. */ export interface Tds26qSummary { fy: string; deductees: number; totalInterestPaid: number; interestWithDeduction: number; /** Paid gross against a declaration — a mandatory disclosure in the return. */ interestWithoutDeduction: number; tdsAtStandardRate: number; tdsAtHigherRate: number; totalDeducted: number; totalDeposited: number; pendingDeposit: number; /** Deducted less deposited less still-pending. Must be zero. */ difference: number; defaultInterest: number; lateChallans: number; overdueChallans: number; } export function tds26qSummary(fy: string, asAt: string): Tds26qSummary { const rows = tdsInterestRowsForFy(fy); const challans = tdsRemittanceRows(fy, asAt); const standard = rows.filter((r) => r.panOnRecord); const higher = rows.filter((r) => !r.panOnRecord); const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0); const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0); const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0); return { fy, deductees: rows.length, totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0), interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0), tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0), tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0), totalDeducted, totalDeposited, pendingDeposit, difference: Math.round(totalDeducted - totalDeposited - pendingDeposit), defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0), lateChallans: challans.filter((c) => c.late).length, overdueChallans: challans.filter((c) => c.overdue).length }; }