Files
cbsnexora/src/app/tds-reports/tds-reports.compute.ts
T
2026-10-05 12:39:14 +05:30

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
};
}