471 lines
17 KiB
TypeScript
471 lines
17 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/.
|
|
*/
|
|
|
|
/**
|
|
* 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,
|
|
fyTaxConfigVersion,
|
|
isSeniorInFy,
|
|
isTdsApplicable,
|
|
isWellFormedPan,
|
|
lateDeductionInterest,
|
|
monthsOrPartBetween,
|
|
tdsDeducteeOf,
|
|
tdsModeFor
|
|
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
|
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } 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;
|
|
}
|
|
|
|
/**
|
|
* One of the customer's accounts as the report sees it: whether its interest is being counted
|
|
* toward the deduction, and if not, why not. A customer can hold savings, fixed and recurring
|
|
* accounts together, and the branch has to see which of them the deduction is actually watching.
|
|
*/
|
|
export interface TdsAccountLine {
|
|
accountNo: string;
|
|
type: 'FD' | 'RD' | 'SB' | 'OTHER';
|
|
/** True when the account's interest counts toward this customer's threshold. */
|
|
tracked: boolean;
|
|
/** Plain-language reason, shown beside the account. */
|
|
reason: string;
|
|
/** Interest credited in the year. */
|
|
interest: number;
|
|
/** How many interest postings made up that figure. */
|
|
postings: number;
|
|
lastPostedOn: string | null;
|
|
}
|
|
|
|
/** 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<Quarter, number>;
|
|
/** 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;
|
|
/** Every account the customer holds, tracked or not. */
|
|
accounts: TdsAccountLine[];
|
|
/** How many of them count toward the deduction. */
|
|
trackedAccounts: number;
|
|
}
|
|
|
|
function emptyQuarters(): Record<Quarter, number> {
|
|
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[] {
|
|
// Keyed on the data set as well as the year: the rows are memoised, and a fresh load would
|
|
// otherwise keep serving the previous customer's figures under the same financial year.
|
|
// The tax configuration too: an administrator's save must not be hidden behind rows built on the old rates.
|
|
const key = fy + '|' + tdsDeducteesVersion + '|' + fyTaxConfigVersion;
|
|
if (rowCacheKey === key) {
|
|
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;
|
|
const accountLines: TdsAccountLine[] = [];
|
|
for (const account of deductee.accounts) {
|
|
const yearCredits = account.credits.filter((c) => fyOf(c.date) === fy);
|
|
const inYear = yearCredits.reduce((s, c) => s + c.amount, 0);
|
|
const type = accountTypeOf(account.depositTypeCode);
|
|
let tracked = false;
|
|
let reason: string;
|
|
if (!isTdsApplicable(account)) {
|
|
exemptInterest += inYear;
|
|
reason = type === 'SB' ? 'Savings account — no TDS on savings interest' : 'Not a time deposit — no TDS';
|
|
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
|
|
jointHeldInterest += inYear;
|
|
reason = 'Joint deposit — counted under the first-named holder';
|
|
} else {
|
|
tracked = true;
|
|
reason = 'Counted toward the TDS limit';
|
|
}
|
|
accountLines.push({
|
|
accountNo: account.accountNo || '',
|
|
type,
|
|
tracked,
|
|
reason,
|
|
interest: inYear,
|
|
postings: yearCredits.length,
|
|
lastPostedOn: yearCredits.reduce<string | null>((last, c) => (!last || c.date > last ? c.date : last), null)
|
|
});
|
|
}
|
|
// Tracked accounts first, so the deposits that drive the figure lead the list.
|
|
accountLines.sort((a, b) => Number(b.tracked) - Number(a.tracked));
|
|
|
|
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,
|
|
accounts: accountLines,
|
|
trackedAccounts: accountLines.filter((a) => a.tracked).length
|
|
});
|
|
}
|
|
|
|
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
|
|
rowCacheKey = key;
|
|
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';
|
|
}
|
|
|
|
function accountTypeOf(code: string | undefined): TdsAccountLine['type'] {
|
|
const c = (code || '').toLowerCase();
|
|
if (c.includes('fixeddeposit')) {
|
|
return 'FD';
|
|
}
|
|
if (c.includes('recurringdeposit')) {
|
|
return 'RD';
|
|
}
|
|
if (c.includes('savings')) {
|
|
return 'SB';
|
|
}
|
|
return 'OTHER';
|
|
}
|
|
|
|
/** Branches are compared by office name, case and spacing aside — the platform and the session both carry the name. */
|
|
function sameBranch(a: string, b: string): boolean {
|
|
return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
|
|
}
|
|
|
|
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
|
|
const needle = filter.search.trim().toLowerCase();
|
|
return tdsInterestRowsForFy(filter.fy).filter((row) => {
|
|
if (filter.branchCode && !sameBranch(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;
|
|
/** Customers holding at least one account whose interest counts toward the deduction. */
|
|
trackedCustomers: 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),
|
|
// Only customers with a deposit the deduction watches: a savings-only customer without a PAN
|
|
// is never deducted at the higher rate, so counting them overstated the problem.
|
|
noPan: rows.filter((r) => !r.panOnRecord && r.trackedAccounts > 0).length,
|
|
trackedCustomers: rows.filter((r) => r.trackedAccounts > 0).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<Quarter, string> = {
|
|
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<number, number> {
|
|
const out: Record<number, number> = { 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<string, Record<number, number>> = {};
|
|
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
|
|
};
|
|
}
|