Merge branch 'dev-latest' into feature/advanceLoanProduct

This commit is contained in:
2026-10-07 15:44:36 +05:30
238 changed files with 21432 additions and 3047 deletions
@@ -0,0 +1,164 @@
/**
* @jest-environment-options {"customExportConditions": ["node", "node-addons"]}
*/
/**
* 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/.
*/
import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture';
import { buildTdsInterestWorkbook } from './tds-interest-export';
import { TdsDeductee, setTdsDeductees } from './tds-reports-data';
import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute';
installTdsConfigFixture();
const FY = '2025-26';
const Q = [
'2025-06-30',
'2025-09-30',
'2025-12-31',
'2026-03-31'
];
const BOOK: TdsDeductee[] = [
{
clientId: 1,
customerId: 'C1',
name: 'Ramesh',
pan: 'ABCPR1234K',
dob: '1980-05-01',
branchCode: 'Head Office',
declarations: {},
accounts: [
{
id: 11,
accountNo: 'FD11',
depositTypeCode: 'depositAccountType.fixedDeposit',
clientId: 1,
credits: Q.map((date) => ({ date, amount: 15_000 })),
deductions: [{ date: Q[3], amount: 6_000 }]
},
{
id: 12,
accountNo: 'SB12',
depositTypeCode: 'depositAccountType.savingsDeposit',
clientId: 1,
credits: [{ date: Q[1], amount: 1_200 }],
deductions: []
}
]
},
{
clientId: 4,
customerId: 'C4',
name: 'Kamala',
pan: 'ABCPK9012M',
dob: '1979-07-07',
branchCode: 'bhopal',
declarations: { [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } },
accounts: [
{
id: 41,
accountNo: 'FD41',
depositTypeCode: 'depositAccountType.fixedDeposit',
clientId: 4,
credits: Q.map((date) => ({ date, amount: 20_000 })),
deductions: []
}
]
}
];
function build() {
setTdsDeductees(BOOK);
const filter = {
fy: FY,
branchCode: '',
quarter: 0 as const,
band: '' as const,
search: '',
crossedOnly: false,
nearThresholdOnly: false
};
const rows = tdsInterestRows(filter);
return buildTdsInterestWorkbook({
fy: FY,
scopeLabel: 'All branches',
rules: { general: 50_000, senior: 100_000, rate: 10, rateNoPan: 20 },
statement: 'Form 26Q',
rows,
declaredRows: rows.filter((r) => r.hasDeclaration),
summary: tdsInterestSummary(filter),
statusOf: () => ({ label: 'status' })
});
}
describe('TDS register workbook', () => {
it('has the four sheets a reader expects, in order', () => {
expect(build().worksheets.map((w) => w.name)).toEqual([
'Summary',
'Customers',
'Accounts',
'Form 121 given'
]);
});
it('lists one row per customer, with the figures as numbers and a live total', () => {
const ws = build().getWorksheet('Customers')!;
expect(ws.getRow(3).getCell(2).value).toBe('Customer name');
const at = [
4,
5
].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!;
const ramesh = ws.getRow(at);
expect(ramesh.getCell(2).value).toBe('Ramesh');
expect(ramesh.getCell(7).value).toBe(60_000);
expect(ramesh.getCell(7).numFmt).toContain('#,##0.00');
expect(ramesh.getCell(11).value).toBe(6_000);
expect(ramesh.getCell(12).value).toBe(6_000);
expect(ramesh.getCell(13).value).toEqual({ formula: `K${at}-L${at}` });
const total = ws.getRow(6);
expect(total.getCell(2).value).toBe('Total');
expect(total.getCell(7).value).toEqual({ formula: 'SUM(G4:G5)' });
});
it('lists every account behind the figures, saying which ones count', () => {
const ws = build().getWorksheet('Accounts')!;
const byAccount = new Map(
[
4,
5,
6
].map((n) => [
ws.getRow(n).getCell(4).value,
ws.getRow(n)
])
);
expect([...byAccount.keys()].sort()).toEqual([
'FD11',
'FD41',
'SB12'
]);
expect(byAccount.get('SB12')!.getCell(6).value).toBe('No');
expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes');
expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date);
});
it('puts the declarants on their own sheet with the TDS the form saved', () => {
const ws = build().getWorksheet('Form 121 given')!;
const kamala = ws.getRow(4);
expect(kamala.getCell(2).value).toBe('Kamala');
expect(kamala.getCell(7).value).toBe('UIN0001');
expect(kamala.getCell(10).value).toBe(40_000);
expect(kamala.getCell(11).value).toBe(4_000);
});
it('writes a file Excel can open', async () => {
const buffer = await build().xlsx.writeBuffer();
expect(buffer.byteLength).toBeGreaterThan(5_000);
});
});
+453
View File
@@ -0,0 +1,453 @@
/**
* 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/.
*/
import * as ExcelJS from 'exceljs';
import { TdsAccountLine, TdsInterestRow, TdsInterestSummary } from './tds-reports.compute';
/** What the workbook needs from the screen, so it prints exactly what the user is looking at. */
export interface TdsExportInput {
fy: string;
scopeLabel: string;
/** The financial-year rules, from Organization › TDS Configuration. */
rules: { general: number; senior: number; rate: number; rateNoPan: number } | null;
/** The quarterly return the declarants' gross-paid interest is reported in. */
statement: string;
rows: TdsInterestRow[];
declaredRows: TdsInterestRow[];
summary: TdsInterestSummary;
statusOf: (row: TdsInterestRow) => { label: string };
}
const MONEY = '"₹"#,##0.00';
const DATE = 'dd-mmm-yyyy';
const NAVY = 'FF1F4E79';
const STRIPE = 'FFF3F6FA';
const LINE = 'FFD0D7E2';
const GREEN = 'FF15803D';
const AMBER = 'FFB45309';
const ACCOUNT_TYPE: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
/** A column: its heading, width and, for figures, the cell format. */
interface Col {
header: string;
width: number;
fmt?: string;
/** Totalled in the closing row. */
sum?: boolean;
}
/**
* The Deposit Interest & TDS register as a workbook a branch can read without the screen beside it:
* a one-page summary, one row per customer, one row per account, and the declarants on their own.
*/
export function buildTdsInterestWorkbook(input: TdsExportInput): ExcelJS.Workbook {
const wb = new ExcelJS.Workbook();
wb.creator = 'CBS Nexora';
wb.created = new Date();
summarySheet(wb, input);
customersSheet(wb, input);
accountsSheet(wb, input);
declarationsSheet(wb, input);
return wb;
}
// ─────────────────────────────── Sheets ───────────────────────────────
function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
ws.columns = [
{ width: 46 },
{ width: 22 }];
title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2);
ws.addRow([`Branch: ${input.scopeLabel}`]);
ws.addRow([
`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`
]);
ws.getRow(2).font = { color: { argb: 'FF555555' } };
ws.getRow(3).font = { color: { argb: 'FF555555' } };
ws.addRow([]);
const s = input.summary;
section(ws, 'Key figures');
const figures: [string, number, string?][] = [
[
'Customers in this report',
s.deductees
],
[
' of whom have an FD / RD being tracked',
s.trackedCustomers
],
[
'FD / RD interest received this year',
s.applicableInterest,
MONEY
],
[
'Savings interest (shown only — no TDS)',
s.exemptInterest,
MONEY
],
[
'Customers over the TDS limit',
s.crossed
],
[
'TDS due by the rules',
s.totalTds,
MONEY
],
[
'TDS actually deducted from accounts',
s.actualTds,
MONEY
],
[
'Difference (due − deducted) — should be 0',
s.totalTds - s.actualTds,
MONEY
],
[
'Customers with Form 121 / 15G / 15H',
s.declarations
],
[
'TDS not deducted because of those forms',
s.tdsSaved,
MONEY
],
[
'Interest paid without TDS (report in the return)',
s.paidWithoutDeduction,
MONEY
]
];
for (const [
label,
value,
fmt
] of figures) {
const r = ws.addRow([
label,
value
]);
r.getCell(2).numFmt = fmt ?? '#,##0';
r.getCell(2).alignment = { horizontal: 'right' };
boxRow(r, 2);
}
// The control figure stands out when it is not zero.
const diffRow = ws.getRow(ws.rowCount - 3);
if (Math.round(s.totalTds - s.actualTds) !== 0) {
diffRow.font = { bold: true, color: { argb: AMBER } };
}
ws.addRow([]);
if (input.rules) {
section(ws, 'Rules for this year');
const rules: [string, string][] = [
[
'TDS limit — below 60',
rupees(input.rules.general)
],
[
'TDS limit — senior citizen (60+)',
rupees(input.rules.senior)
],
[
'Rate with PAN',
`${input.rules.rate}%`
],
[
'Rate without PAN',
`${input.rules.rateNoPan}%`
]
];
for (const [
label,
value
] of rules) {
const r = ws.addRow([
label,
value
]);
r.getCell(2).alignment = { horizontal: 'right' };
boxRow(r, 2);
}
ws.addRow([]);
}
section(ws, 'How to read this workbook');
const notes = [
'Only interest from fixed and recurring deposits counts toward the limit — on the day it reaches savings, or when paid out at closure.',
"A savings account's own interest is shown for information and never attracts TDS.",
"Once a customer's interest for the year crosses the limit, TDS is due on the whole year's interest, not only the excess.",
'A Form 121 / 15G / 15H stops TDS on interest received after the form was accepted.',
'Sheet "Customers": one row per customer. Sheet "Accounts": every account behind those figures.',
'Sheet "Form 121 given": customers with a declaration, and the TDS the form saved them.'
];
for (const n of notes) {
const r = ws.addRow([`• ${n}`]);
ws.mergeCells(r.number, 1, r.number, 2);
r.alignment = { wrapText: true, vertical: 'top' };
r.height = 30;
}
}
function customersSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const cols: Col[] = [
{ header: 'S.No', width: 6 },
{ header: 'Customer name', width: 26 },
{ header: 'Customer ID', width: 13 },
{ header: 'PAN', width: 13 },
{ header: 'Branch', width: 14 },
{ header: 'Age group', width: 12 },
{ header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true },
{ header: 'TDS limit (₹)', width: 13, fmt: MONEY },
{ header: 'Over the limit?', width: 10 },
{ header: 'Rate', width: 7 },
{ header: 'TDS due (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'Difference (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'Form 121 given?', width: 10 },
{ header: 'Status', width: 42 }
];
const ws = table(wb, 'Customers', `Customers — FY ${input.fy} · ${input.scopeLabel}`, cols);
input.rows.forEach((r, i) => {
const line = ws.addRow([
i + 1,
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'Not on record',
r.branchCode,
r.band === 'SENIOR' ? 'Senior (60+)' : 'Below 60',
r.tdsApplicableInterest,
r.threshold,
r.crossed ? 'Yes' : 'No',
`${Math.round(r.rateApplied * 100)}%`,
r.schedule.totalTds,
r.actualTds,
null,
r.hasDeclaration ? 'Yes' : 'No',
input.statusOf(r).label
]);
// Due − deducted as a formula, so an edited figure keeps the check honest.
line.getCell(13).value = { formula: `K${line.number}-L${line.number}` };
styleDataRow(line, cols, i);
if (!r.deductee.pan) {
line.getCell(4).font = { color: { argb: AMBER } };
}
if (r.crossed) {
line.getCell(9).font = { bold: true, color: { argb: AMBER } };
}
if (Math.round(r.schedule.totalTds - r.actualTds) !== 0) {
line.getCell(13).font = { bold: true, color: { argb: AMBER } };
}
});
totals(ws, cols, input.rows.length);
}
function accountsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const cols: Col[] = [
{ header: 'S.No', width: 6 },
{ header: 'Customer name', width: 26 },
{ header: 'Customer ID', width: 13 },
{ header: 'Account no.', width: 19 },
{ header: 'Account type', width: 17 },
{ header: 'Counted for TDS?', width: 10 },
{ header: 'Why', width: 46 },
{ header: 'Interest this year (₹)', width: 15, fmt: MONEY, sum: true },
{ header: 'Credits', width: 8 },
{ header: 'Last credited', width: 13, fmt: DATE },
{ header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true },
{ header: 'Account held by', width: 22 }
];
const ws = table(wb, 'Accounts', `Accounts — FY ${input.fy} · ${input.scopeLabel}`, cols);
let n = 0;
for (const r of input.rows) {
for (const a of r.accounts) {
const line = ws.addRow([
n + 1,
r.deductee.name,
r.deductee.customerId,
a.accountNo,
ACCOUNT_TYPE[a.type],
a.tracked ? 'Yes' : 'No',
a.reason,
a.interest,
a.postings,
a.lastPostedOn ? isoToDate(a.lastPostedOn) : '—',
a.tdsDeducted,
a.holderName ?? 'Same customer'
]);
styleDataRow(line, cols, n);
if (a.tracked) {
line.getCell(6).font = { bold: true, color: { argb: GREEN } };
}
n++;
}
}
totals(ws, cols, n);
}
function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
const cols: Col[] = [
{ header: 'S.No', width: 6 },
{ header: 'Customer name', width: 26 },
{ header: 'Customer ID', width: 13 },
{ header: 'PAN', width: 13 },
{ header: 'Branch', width: 14 },
{ header: 'Form', width: 10 },
{ header: 'UIN', width: 18 },
{ header: 'Accepted on', width: 13, fmt: DATE },
{ header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true },
{ header: `Paid without TDS — report in ${input.statement} (₹)`, width: 20, fmt: MONEY, sum: true },
{ header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true },
{ header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true }
];
const ws = table(
wb,
'Form 121 given',
`Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`,
cols
);
input.declaredRows.forEach((r, i) => {
const line = ws.addRow([
i + 1,
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'Not on record',
r.branchCode,
`Form ${r.form}`,
r.declarationUin ?? 'Not allotted',
r.declarationAcceptedOn ? isoToDate(r.declarationAcceptedOn) : '—',
r.tdsApplicableInterest,
r.paidWithoutDeduction,
r.tdsSaved,
r.actualTds
]);
styleDataRow(line, cols, i);
if (!r.declarationUin) {
line.getCell(7).font = { color: { argb: AMBER } };
}
});
if (input.declaredRows.length === 0) {
const r = ws.addRow([
'',
'No customer has a declaration on record for this year.'
]);
r.font = { italic: true, color: { argb: 'FF777777' } };
} else {
totals(ws, cols, input.declaredRows.length);
}
}
// ─────────────────────────────── Styling ───────────────────────────────
/** A sheet with a title, a styled header row, frozen headings and a filter. */
function table(wb: ExcelJS.Workbook, name: string, heading: string, cols: Col[]): ExcelJS.Worksheet {
const ws = wb.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 3, showGridLines: false }] });
ws.columns = cols.map((c) => ({ width: c.width }));
title(ws, heading, cols.length);
ws.addRow([]);
const head = ws.addRow(cols.map((c) => c.header));
head.height = 34;
head.eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = border();
});
ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: cols.length } };
return ws;
}
function title(ws: ExcelJS.Worksheet, text: string, span: number): void {
const r = ws.addRow([text]);
ws.mergeCells(r.number, 1, r.number, span);
r.font = { bold: true, size: 14, color: { argb: NAVY } };
r.height = 24;
}
function section(ws: ExcelJS.Worksheet, text: string): void {
const r = ws.addRow([text]);
r.font = { bold: true, color: { argb: 'FFFFFFFF' } };
r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
r.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
}
function styleDataRow(row: ExcelJS.Row, cols: Col[], index: number): void {
cols.forEach((c, i) => {
const cell = row.getCell(i + 1);
cell.border = border();
cell.alignment = { vertical: 'middle', wrapText: i === cols.length - 1 || c.width > 30 };
if (c.fmt) {
cell.numFmt = c.fmt;
cell.alignment = { ...cell.alignment, horizontal: 'right' };
}
if (index % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: STRIPE } };
}
});
}
/** A closing row that totals the money columns with live SUM formulas. */
function totals(ws: ExcelJS.Worksheet, cols: Col[], count: number): void {
if (count === 0) {
return;
}
const first = 4;
const last = first + count - 1;
const r = ws.addRow(cols.map((c, i) => (i === 1 ? 'Total' : null)));
cols.forEach((c, i) => {
const cell = r.getCell(i + 1);
if (c.sum) {
const letter = ws.getColumn(i + 1).letter;
cell.value = { formula: `SUM(${letter}${first}:${letter}${last})` };
cell.numFmt = c.fmt ?? '#,##0';
cell.alignment = { horizontal: 'right' };
}
cell.font = { bold: true };
cell.border = { top: { style: 'medium', color: { argb: NAVY } }, bottom: { style: 'thin', color: { argb: LINE } } };
});
}
function boxRow(row: ExcelJS.Row, span: number): void {
for (let i = 1; i <= span; i++) {
row.getCell(i).border = border();
}
}
function border(): Partial<ExcelJS.Borders> {
const side = { style: 'thin' as const, color: { argb: LINE } };
return { top: side, left: side, bottom: side, right: side };
}
function rupees(n: number): string {
return `₹${Math.round(n).toLocaleString('en-IN')}`;
}
/** '2026-10-05' as a real date, so Excel sorts and filters it as one. */
function isoToDate(iso: string): Date {
const [
y,
m,
d
] = iso.slice(0, 10).split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d));
}
@@ -13,68 +13,132 @@
<div>
<h2 class="tdsr__title">Deposit Interest &amp; TDS</h2>
<div class="tdsr__sub">
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
{{ scopeLabel }}
Interest each customer earned this year, and whether TDS has to be deducted on it ·
<strong>Showing: {{ scopeLabel }}</strong>
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
<div class="tdsr__head-r">
<button type="button" class="tdsr__btn" (click)="showHelp = !showHelp">
<fa-icon icon="question-circle"></fa-icon>
{{ showHelp ? 'Hide help' : 'How to read this page' }}
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
@if (loadError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ loadError }}</div>
</div>
}
<!-- Help -->
@if (showHelp) {
<div class="tdsr__help">
<div class="tdsr__help-title"><fa-icon icon="info-circle"></fa-icon> How to read this page</div>
<ol class="tdsr__help-list">
<li>
Every customer with an account is listed. Each account is either
<span class="tdsr__pill tdsr__pill--ok">Tracked</span> — its interest counts toward the TDS limit — or
<span class="tdsr__pill">Not tracked</span>. Open a row (<fa-icon icon="chevron-down"></fa-icon>) to see which
account is which, and why.
</li>
<li>
Only the interest from
<strong>fixed and recurring deposits that reaches the customer's savings account</strong>
is tracked — counted on the day it arrives. The savings account's own interest is shown for information but
never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to
savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is
not taxed.
</li>
@if (rules) {
<li>
A customer's tracked interest for the whole year, across every branch, is checked against the limit:
<strong>₹{{ rules.general | number: '1.0-0' }}</strong
>, or <strong>₹{{ rules.senior | number: '1.0-0' }}</strong> for a senior citizen (60+).
</li>
<li>
Once over the limit, TDS is <strong>{{ rules.rate | number: '1.0-2' }}%</strong> of the
<em>whole</em> year's interest — <strong>{{ rules.rateNoPan | number: '1.0-2' }}%</strong> if no PAN is on
record.
</li>
}
<li>
A customer who has submitted Form 15G/15H is not deducted on interest received after the form was accepted.
</li>
<li>
TDS is <strong>debited from the savings account</strong> at the moment the FD/RD interest that takes the
customer over the limit arrives, and credited to the TDS payable account. The FD/RD itself is never debited.
The <strong>TDS</strong> column shows what is due by these rules and what was actually deducted — the two
should match.
</li>
</ol>
</div>
}
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees</div>
<div class="tdsr__kpi-l">Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on FD / RD</div>
<div class="tdsr__kpi-l">FD / RD interest received in savings — counts toward the limit</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS payable</div>
<div class="tdsr__kpi-l">Savings interest — shown only, no TDS</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
<div class="tdsr__kpi-l">Crossed the threshold</div>
<div class="tdsr__kpi-l">Customers over the limit</div>
</div>
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
</button>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS due by the rules</div>
</div>
@if (summary.noPan > 0) {
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
<div
class="tdsr__kpi"
[class.tdsr__kpi--ok]="summary.actualTds === summary.totalTds"
[class.tdsr__kpi--warn]="summary.actualTds !== summary.totalTds"
>
<div class="tdsr__kpi-n">₹{{ summary.actualTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">
Actually deducted from accounts
@if (summary.actualTds !== summary.totalTds) {
— ₹{{ summary.totalTds - summary.actualTds | number: '1.0-0' }} apart
}
</div>
}
</div>
</div>
<!-- Quarter strip -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
<div class="tdsr__qstrip">
@for (q of summary.byQuarter; track q.quarter) {
<div class="tdsr__qcell">
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
</div>
<!-- Things worth acting on, only when there are any -->
@if (summary.nearThreshold || summary.declarations || summary.noPan) {
<div class="tdsr__chips">
@if (summary.nearThreshold) {
<button type="button" class="tdsr__chip tdsr__chip--near" (click)="showNearThreshold()">
{{ summary.nearThreshold | number }} near the limit (within ₹{{ nearBand | number: '1.0-0' }}) — ask for
15G/15H
</button>
}
@if (summary.declarations) {
<span class="tdsr__chip tdsr__chip--ok">
{{ summary.declarations | number }} with 15G/15H — ₹{{ summary.tdsSaved | number: '1.0-0' }} not deducted
</span>
}
@if (summary.noPan) {
<span class="tdsr__chip tdsr__chip--warn">
{{ summary.noPan | number }} FD/RD customers without PAN — higher rate applies
</span>
}
</div>
</div>
}
<!-- Filters -->
<div class="tdsr__filters">
@@ -92,8 +156,8 @@
<span>Branch</span>
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
@@ -110,32 +174,32 @@
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<span>Age group</span>
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="">Everyone</option>
<option value="GENERAL">Below 60</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<span>Search customer</span>
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
placeholder="Name, customer ID or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
<span>Over the limit only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
<span>Near the limit only</span>
</label>
@if (hasFilters) {
@@ -143,7 +207,7 @@
}
</div>
<!-- Tabs Selector -->
<!-- Tabs -->
<div class="tdsr__tabs">
<button
type="button"
@@ -151,7 +215,15 @@
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
TDS deducted / due ({{ payingRows.length }})
</button>
<button
type="button"
class="tdsr__tab"
[class.tdsr__tab--active]="selectedTab === 'declared'"
(click)="selectTab('declared')"
>
Form 121 / 15G / 15H given ({{ declaredRows.length }})
</button>
<button
type="button"
@@ -159,97 +231,131 @@
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
No TDS ({{ notPayingRows.length }})
</button>
</div>
@if (selectedTab === 'declared') {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
Customers who gave a declaration for FY {{ filter.fy }}. <strong>TDS not deducted</strong> is the tax that would
have been taken without the form; <strong>Paid without TDS</strong> is the interest received after the form was
accepted — it must still be reported in the {{ statute.statement }} return. Interest received before the form
stays taxable, and any TDS taken on it is shown as <strong>deducted before the form</strong>.
</div>
</div>
}
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Customer</th>
<th>Branch</th>
<th>Form · Band</th>
<th class="num">Q1</th>
<th class="num">Q2</th>
<th class="num">Q3</th>
<th class="num">Q4</th>
<th class="num">Year total</th>
<th class="num">Threshold</th>
<th>Crossed</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
@if (selectedTab === 'declared') {
<th>Form · UIN · accepted on</th>
<th class="num">FD / RD interest received · paid without TDS</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS not deducted · deducted before the form</th>
} @else {
<th>Accounts</th>
<th class="num">FD / RD interest received this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
}
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<tr class="tdsr__row--click" [class.tdsr__row--near]="row.nearThreshold" (click)="toggleExpanded(row)">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
ID {{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
PAN {{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
@if (row.band === 'SENIOR') {
<span class="tdsr__pill">Senior 60+</span>
}
</div>
</td>
<td class="small">{{ branchName(row.branchCode) }}</td>
<td>
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
{{ row.form === '121' ? '121' : row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
@if (row.hasDeclaration) {
<div class="mono small muted">{{ row.declarationUin }}</div>
}
</td>
@for (q of quarters; track q) {
<td class="num small">
@if (row.quarters[q]) {
₹{{ row.quarters[q] | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
<td class="small">{{ row.branchCode || '—' }}</td>
@if (selectedTab === 'declared') {
<td class="small">
<span class="tdsr__pill tdsr__pill--ok">Form {{ row.form }}</span>
<div class="mono muted">{{ row.declarationUin || 'no UIN yet' }}</div>
<div class="muted">accepted {{ row.declarationAcceptedOn | date: 'd MMM y' }}</div>
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
<div class="small tdsr__ok">₹{{ row.paidWithoutDeduction | number: '1.0-0' }} paid without TDS</div>
</td>
} @else {
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</td>
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</div>
}
</td>
}
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
@if (row.crossedInQuarter) {
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
} @else if (row.nearThreshold) {
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
} @else {
<span class="muted">—</span>
}
<span
class="tdsr__pill"
[class.tdsr__pill--warn]="statusOf(row).tone === 'warn'"
[class.tdsr__pill--near]="statusOf(row).tone === 'near'"
[class.tdsr__pill--ok]="statusOf(row).tone === 'ok'"
>
{{ statusOf(row).label }}
</span>
</td>
<td class="num">
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved) {
@if (selectedTab === 'declared') {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
<div class="small muted">not deducted</div>
@if (row.actualTds || row.schedule.totalTds) {
<div class="small">₹{{ row.actualTds | number: '1.0-0' }} deducted before the form</div>
}
} @else if (row.schedule.totalTds || row.actualTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">due @ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
<div
class="small"
[class.tdsr__ok]="row.actualTds === row.schedule.totalTds"
[class.tdsr__warn-text]="row.actualTds !== row.schedule.totalTds"
>
₹{{ row.actualTds | number: '1.0-0' }} deducted
</div>
} @else if (row.tdsSaved) {
<span class="small tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }} saved</span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<button
type="button"
class="tdsr__icon-btn"
[attr.aria-label]="isExpanded(row) ? 'Hide details' : 'Show details'"
(click)="$event.stopPropagation(); toggleExpanded(row)"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
@@ -257,89 +363,154 @@
@if (isExpanded(row)) {
<tr class="tdsr__row--detail">
<td colspan="13">
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
is a joint holder but not the first-named one. Tax there is deducted against the first-named
holder, so it is excluded from the figures above.
</div>
</div>
}
<td colspan="8">
<!-- 1. Which accounts are tracked -->
<div class="tdsr__detail-h">Accounts of {{ row.deductee.name }}</div>
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
<th>Account no.</th>
<th>Type</th>
<th>Tracked for TDS?</th>
<th class="num">Interest this year (FD/RD: received in savings)</th>
<th class="num">Credits</th>
<th>Last credited</th>
<th class="num">TDS deducted</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
@for (a of row.accounts; track a.accountNo) {
<tr>
<td class="mono small">
{{ a.accountNo }}
@if (a.holderName) {
<div class="muted">held by {{ a.holderName }}</div>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="small">{{ accountTypeLabel[a.type] }}</td>
<td class="small">
@if (a.tracked) {
<span class="tdsr__pill tdsr__pill--ok"><fa-icon icon="check"></fa-icon> Yes</span>
} @else {
<span class="tdsr__pill">No</span>
}
<span class="muted"> {{ a.reason }}</span>
</td>
<td class="num">₹{{ a.interest | number: '1.2-2' }}</td>
<td class="num small">{{ a.postings }}</td>
<td class="small muted">
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'Nothing received yet' }}
</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
@if (a.tdsDeducted) {
₹{{ a.tdsDeducted | number: '1.2-2' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
<div class="tdsr__meta">
<span
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
the deposit product)</span
>
@if (row.declarationAcceptedOn) {
<span
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
>
<!-- 2. How the TDS was worked out -->
@if (row.trackedAccounts) {
<div class="tdsr__detail-h">How the TDS is worked out, quarter by quarter</div>
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.schedule.lines.length) {
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest in quarter</th>
<th class="num">Total so far</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">limit crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">15G/15H active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">
₹{{ line.cumInterest | number: '1.0-0' }}
<span class="small muted">of ₹{{ row.threshold | number: '1.0-0' }}</span>
</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="small muted">No interest has been posted on the tracked deposits this year yet.</div>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
<div class="tdsr__meta">
<span>
Paid without deduction because of 15G/15H:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — still reported in
{{ statute.statement }}
</span>
</div>
}
</div>
} @else {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings
interest is shown for information only.
</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest is on joint deposits where this
customer is not the first-named holder, so it is counted under that holder instead.
</div>
</div>
}
</td>
</tr>
}
}
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
<td colspan="8" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
@if (loading) {
Loading…
} @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else if (selectedTab === 'declared') {
No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's Form
121 tab.
} @else {
No customers match these filters.
}
</td>
</tr>
}
@@ -352,8 +523,7 @@
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
<strong>{{ visibleRows.length | number }}</strong> customers
</span>
<label class="tdsr__pager-sizel">
Rows
@@ -10,15 +10,15 @@ import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { setTdsDeductees, TDS_FYS } from './tds-reports-data';
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
import { buildTdsInterestWorkbook } from './tds-interest-export';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
TdsAccountLine,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
@@ -26,6 +26,9 @@ import {
tdsInterestSummary
} from './tds-reports.compute';
/** The three answers the register gives: TDS taken, not taken because of a declaration, none due. */
type TdsTab = 'paying' | 'declared' | 'not-paying';
@Component({
selector: 'mifosx-tds-interest-report',
templateUrl: './tds-interest-report.component.html',
@@ -40,8 +43,14 @@ export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
readonly financialYears = TDS_FYS;
/**
* Office names the loaded customers belong to, for Head Office's branch selector. Read off the
* data rather than a fixed list, which named branches this bank does not have and left out the
* ones it does.
*/
branches: string[] = [];
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
readonly nearBand = NEAR_THRESHOLD_BAND;
readonly quarters: Quarter[] = [
@@ -52,7 +61,7 @@ export class TdsInterestReportComponent implements OnInit {
];
filter: TdsReportFilter = {
fy: TDS_FYS[0],
fy: tdsFys()[0] ?? '',
branchCode: '',
quarter: 0,
band: '',
@@ -64,7 +73,7 @@ export class TdsInterestReportComponent implements OnInit {
/** Which customer's quarter-wise detail is open. */
expandedId: string | null = null;
exporting = false;
selectedTab: 'paying' | 'not-paying' = 'paying';
selectedTab: TdsTab = 'paying';
/** Rows per page; 0 shows the whole list. */
readonly pageSizeOptions = [
@@ -86,17 +95,32 @@ export class TdsInterestReportComponent implements OnInit {
private allRows: TdsInterestRow[] = [];
private paying: TdsInterestRow[] = [];
private notPaying: TdsInterestRow[] = [];
private declared: TdsInterestRow[] = [];
private summaryCacheKey = '';
private cachedSummary: TdsInterestSummary | null = null;
loading = false;
loadError = '';
/** The "how to read this page" panel. */
showHelp = true;
readonly accountTypeLabel: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
if (!this.financialYears.length) {
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
return;
}
// A branch login sees only its own book and cannot widen the scope. The platform labels each
// customer with their office's name, so the login's office name is the filter — the GL branch
// code list does not know every office and turned a branch login into an empty report.
if (!this.scope.isHeadOffice) {
this.filter.branchCode = this.scope.officeName;
}
this.loadDeductees();
}
@@ -116,20 +140,35 @@ export class TdsInterestReportComponent implements OnInit {
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.summaryCacheKey = '';
this.cachedSummary = null;
this.branches = this.branchesOf(deductees);
this.invalidateCaches();
},
error: (err: any) => {
this.loading = false;
setTdsDeductees([]);
this.summaryCacheKey = '';
this.cachedSummary = null;
this.invalidateCaches();
this.loadError =
err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.';
}
});
}
/**
* Both caches are keyed on the filter alone, so a fresh load must drop them together. Clearing
* only the summary left the KPIs counting the new data while the tables kept the empty list
* cached on the first render, before the load returned.
*/
private branchesOf(deductees: TdsDeductee[]): string[] {
const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n));
return [...names].sort((a, b) => a.localeCompare(b));
}
private invalidateCaches(): void {
this.rowsCacheKey = '';
this.summaryCacheKey = '';
this.cachedSummary = null;
}
/**
* A different year needs a different set of interest credits, so the year re-reads from the
* platform rather than re-filtering the set already in hand.
@@ -154,12 +193,80 @@ export class TdsInterestReportComponent implements OnInit {
};
}
/** The limits and rates for the selected year, for the help panel. */
get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null {
const cfg = FY_TAX_CONFIG[this.filter.fy];
if (!cfg) {
return null;
}
return {
general: cfg.thresholdGeneral,
senior: cfg.thresholdSenior,
rate: cfg.rate * 100,
rateNoPan: cfg.rateNoPan * 100
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches';
}
/**
* One plain-language status per customer, so a reader does not have to work it out from the
* threshold, crossed and saved columns together.
*/
statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } {
if (!row.trackedAccounts) {
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
}
if (row.hasDeclaration) {
const beforeForm = Math.round(row.schedule.totalTds - row.actualTds);
if (beforeForm > 0) {
return {
label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`,
tone: 'warn'
};
}
if (row.tdsSaved > 0) {
return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' };
}
return { label: `Form ${row.form} on record — below the limit so far`, tone: 'ok' };
}
if (row.schedule.totalTds > 0) {
const gap = Math.round(row.schedule.totalTds - row.actualTds);
if (gap > 0) {
return { label: `Over the limit — ₹${gap.toLocaleString('en-IN')} not yet deducted`, tone: 'warn' };
}
if (gap < 0) {
return { label: `Deducted ₹${(-gap).toLocaleString('en-IN')} more than due — check`, tone: 'warn' };
}
return { label: 'Over the limit — TDS deducted', tone: 'ok' };
}
if (row.actualTds > 0) {
return {
label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`,
tone: 'warn'
};
}
if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
}
if (row.nearThreshold) {
return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' };
}
return { label: 'Below the limit', tone: 'ok' };
}
trackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => a.tracked);
}
untrackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => !a.tracked);
}
/** Recomputes the book only when the filter actually changed. Side-effect free. */
@@ -170,8 +277,12 @@ export class TdsInterestReportComponent implements OnInit {
}
this.rowsCacheKey = key;
this.allRows = tdsInterestRows(this.filter);
this.paying = this.allRows.filter((r) => r.schedule.totalTds > 0);
this.notPaying = this.allRows.filter((r) => r.schedule.totalTds === 0);
// Three plain answers: whose TDS was taken, whose was not because they gave a declaration, and who
// owed none. A declarant whose tax was partly taken before the form is shown with the declarants,
// where both figures sit side by side.
this.declared = this.allRows.filter((r) => r.hasDeclaration);
this.paying = this.allRows.filter((r) => !r.hasDeclaration && (r.schedule.totalTds > 0 || r.actualTds > 0));
this.notPaying = this.allRows.filter((r) => !r.hasDeclaration && r.schedule.totalTds === 0 && r.actualTds === 0);
}
get rows(): TdsInterestRow[] {
@@ -189,6 +300,11 @@ export class TdsInterestReportComponent implements OnInit {
return this.notPaying;
}
get declaredRows(): TdsInterestRow[] {
this.ensureRows();
return this.declared;
}
get summary(): TdsInterestSummary {
const key = JSON.stringify(this.filter);
if (key !== this.summaryCacheKey || !this.cachedSummary) {
@@ -200,6 +316,9 @@ export class TdsInterestReportComponent implements OnInit {
/** Every row of the tab on screen, before paging. */
get visibleRows(): TdsInterestRow[] {
if (this.selectedTab === 'declared') {
return this.declaredRows;
}
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
}
@@ -260,7 +379,7 @@ export class TdsInterestReportComponent implements OnInit {
this.expandedId = null;
}
selectTab(tab: 'paying' | 'not-paying'): void {
selectTab(tab: TdsTab): void {
this.selectedTab = tab;
this.onFilterChanged();
}
@@ -301,10 +420,6 @@ export class TdsInterestReportComponent implements OnInit {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
@@ -312,92 +427,17 @@ export class TdsInterestReportComponent implements OnInit {
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
ws.addRow([`Scope: ${this.scopeLabel}`]);
ws.addRow([]);
ws.addRow([
'Customer',
'CIF',
'PAN',
'Branch',
'Form',
'Band',
'Age',
'Q1',
'Q2',
'Q3',
'Q4',
'Interest (FD/RD)',
'Savings interest (exempt)',
'Threshold',
'Crossed in',
'Rate %',
'TDS payable',
'Declaration',
'UIN',
'TDS saved',
'Paid without deduction'
]);
for (const r of this.rows) {
ws.addRow([
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
r.quarters[1],
r.quarters[2],
r.quarters[3],
r.quarters[4],
r.tdsApplicableInterest,
r.exemptInterest,
r.threshold,
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
r.rateApplied * 100,
r.schedule.totalTds,
r.hasDeclaration ? 'Yes' : 'No',
r.declarationUin ?? '',
r.tdsSaved,
r.paidWithoutDeduction
]);
}
const s = this.summary;
ws.addRow([]);
ws.addRow([
'TOTAL',
`${s.deductees} deductees`,
'',
'',
'',
'',
'',
s.byQuarter[0].interest,
s.byQuarter[1].interest,
s.byQuarter[2].interest,
s.byQuarter[3].interest,
s.applicableInterest,
s.exemptInterest,
'',
'',
'',
s.totalTds,
`${s.declarations} filed`,
'',
s.tdsSaved,
s.paidWithoutDeduction
]);
ws.getRow(1).font = { bold: true, size: 13 };
ws.getRow(4).font = { bold: true };
ws.columns.forEach((c) => (c.width = 18));
const wb = buildTdsInterestWorkbook({
fy: this.filter.fy,
scopeLabel: this.scopeLabel,
rules: this.rules,
statement: this.statute.statement,
// What the screen shows under the current filters, every tab included.
rows: this.rows,
declaredRows: this.declaredRows,
summary: this.summary,
statusOf: (row) => this.statusOf(row)
});
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@@ -405,7 +445,7 @@ export class TdsInterestReportComponent implements OnInit {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
a.download = `TDS-Register-FY${this.filter.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
@@ -13,225 +13,336 @@
<div>
<h2 class="tdsr__title">TDS Remitted to Government</h2>
<div class="tdsr__sub">
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
{{ asAt | date: 'd MMM y' }}
TDS deducted from customers' deposits each month, and the challans Head Office paid it with · Bank as a whole
@if (asAt) {
· as at {{ asAt | date: 'd MMM y' }}
}
</div>
</div>
</div>
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
@if (isHeadOffice) {
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="onYearChanged()">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="openForm()">
<fa-icon icon="plus"></fa-icon> Record challan
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting || !summary">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
}
</div>
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
@if (!isHeadOffice) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
<strong>This report is for Head Office.</strong> TDS is paid to the Government by Head Office for the whole
bank, under one TAN. Your branch's customers and the TDS deducted from them are on
<em>Reports › Deposit Interest &amp; TDS</em>.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
return.
@if (loadError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ loadError }}</div>
</div>
</div>
}
}
@if (summary.overdueChallans > 0) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
<!-- Challan form -->
@if (showForm) {
<div class="tdsr__card tdsr__form">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Record a challan — FY {{ fy }}</div>
<div class="tdsr__filters tdsr__filters--flat">
<label class="tdsr__filter">
<span>Deduction month *</span>
<input type="month" [(ngModel)]="form.month" />
</label>
<label class="tdsr__filter">
<span>Amount (₹) *</span>
<input type="number" min="0" step="1" [(ngModel)]="form.amount" />
</label>
<label class="tdsr__filter">
<span>Challan no. *</span>
<input type="text" [(ngModel)]="form.challanNo" placeholder="e.g. 00123" />
</label>
<label class="tdsr__filter">
<span>BSR code *</span>
<input type="text" maxlength="7" [(ngModel)]="form.bsrCode" placeholder="7 digits" />
</label>
<label class="tdsr__filter">
<span>Paid on *</span>
<input type="date" [(ngModel)]="form.depositedOn" />
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Paid from account *</span>
<select [(ngModel)]="form.paidFromGlAccountId">
<option [ngValue]="0" disabled>Choose bank / cash account</option>
@for (o of paidFromOptions; track o.id) {
<option [ngValue]="o.id">{{ o.glCode }} · {{ o.name }}</option>
}
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Remarks</span>
<input type="text" [(ngModel)]="form.remarks" />
</label>
</div>
<div class="tdsr__foot">
Saving posts <strong>Dr TDS payable / Cr the account paid from</strong> at Head Office, dated the day it was
paid.
</div>
@if (formError) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ formError }}</div>
</div>
}
<div class="tdsr__form-actions">
<button type="button" class="tdsr__btn" (click)="showForm = false" [disabled]="saving">Cancel</button>
<button type="button" class="tdsr__btn tdsr__btn--primary" (click)="saveChallan()" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save challan' }}
</button>
</div>
</div>
</div>
}
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Total TDS deducted</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Deposited with Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on late deposit</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees in the return</div>
</div>
</div>
<!-- Part A: challans -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Amount</th>
<th>Due date</th>
<th>Deposited on</th>
<th>Challan · BSR</th>
<th>Status</th>
<th class="num">Late-deposit interest</th>
<th>{{ statute.statement }} due</th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td>
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small">{{ row.month }}</td>
<td class="num">
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="small">
@if (row.depositedOn) {
{{ row.depositedOn | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="mono small">
@if (row.challanNo) {
{{ row.challanNo }}
<div class="muted">BSR {{ row.bsrCode }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
} @else if (row.depositedOn) {
<span class="tdsr__pill tdsr__pill--ok">On time</span>
} @else {
<span class="tdsr__pill">Within due date</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ row.returnDueDate }}</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="9" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
</div>
</div>
<!-- Part B: return summary -->
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Deposited with Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
@if (summary) {
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax paid plus tax still within its due date.
@if (summary.overdueAmount > 0) {
₹{{ summary.overdueAmount | number: '1.0-0' }} is past its due date and not yet paid.
}
</div>
</div>
}
@if (summary.calculatedTds !== summary.totalDeducted) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but ₹{{
summary.totalDeducted | number: '1.0-0'
}}
was actually deducted from accounts. Open <em>Deposit Interest &amp; TDS</em> to see which customers differ.
</div>
</div>
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS deducted from accounts</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Paid to Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">To pay — still within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="summary.overdueAmount > 0">
<div class="tdsr__kpi-n">₹{{ summary.overdueAmount | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Overdue — pay now</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="summary.defaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ summary.defaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest for paying late</div>
</div>
</div>
}
<!-- Part A: month by month -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — month by month</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Deducted</th>
<th>Pay by</th>
<th>Challans</th>
<th class="num">Paid</th>
<th class="num">Still to pay</th>
<th>Status</th>
<th class="num">Late interest</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td class="small">
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small">
{{ row.month + '-01' | date: 'MMM y' }}
<div class="muted">{{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}</div>
</td>
<td class="num">
<strong>₹{{ row.deducted | number: '1.0-2' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="mono small">
@for (c of row.challans; track c.id) {
<div>
{{ c.challanNo }} · BSR {{ c.bsrCode }} · {{ c.depositedOn | date: 'd MMM y' }} · ₹{{
c.amount | number: '1.0-2'
}}
<button type="button" class="tdsr__link" (click)="reverseChallan(c)">reverse</button>
</div>
} @empty {
<span class="muted">—</span>
}
</td>
<td class="num">₹{{ row.deposited | number: '1.0-2' }}</td>
<td class="num">
@if (row.outstanding) {
<strong>₹{{ row.outstanding | number: '1.0-2' }}</strong>
} @else if (row.excess) {
<span class="tdsr__warn-text small">₹{{ row.excess | number: '1.0-2' }} paid extra</span>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Paid late by {{ row.delayMonths }}m</span>
} @else if (row.outstanding) {
<span class="tdsr__pill">Within due date</span>
} @else {
<span class="tdsr__pill tdsr__pill--ok">Paid on time</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
@if (row.outstanding) {
<button type="button" class="tdsr__btn tdsr__btn--sm" (click)="openForm(row)">Pay</button>
}
</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="10" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
{{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
Interest runs at 1.5% for every month or part of a month it is late.
</div>
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
<!-- Part B: return summary -->
@if (summary) {
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Deductees (customers with an FD/RD)</span>
<span class="num">{{ summary.deductees | number }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Paid to Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
</div>
}
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in
the quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement
of declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
}
}
</div>
@@ -6,17 +6,25 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { forkJoin } from 'rxjs';
import { BranchScopeService } from '../core/branch-scope.service';
import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT, TDS_FYS } from './tds-reports-data';
import { TdsChallan, TdsDeductedMonth, setTdsDeductees, tdsFys } from './tds-reports-data';
import { NewTdsChallan, TdsReportsService } from './tds-reports.service';
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
/**
* Tax deducted against tax remitted, month by month, for Head Office.
*
* The deducted side is the withhold-tax postings on deposits; the remitted side is the challans
* entered here. Both are records — nothing on this page is worked out from a rate.
*/
@Component({
selector: 'mifosx-tds-remittance-report',
templateUrl: './tds-remittance-report.component.html',
@@ -27,21 +35,83 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro
FormsModule
]
})
export class TdsRemittanceReportComponent {
export class TdsRemittanceReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly financialYears = TDS_FYS;
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
readonly asAt = TDS_AS_AT;
fy = TDS_FYS[0];
fy = tdsFys()[0] ?? '';
exporting = false;
loading = false;
loadError = '';
/** The business date the platform is on — what "overdue" is judged against. */
asAt = '';
private deducted: TdsDeductedMonth[] = [];
challans: TdsChallan[] = [];
rows: TdsRemittanceRow[] = [];
summary: Tds26qSummary | null = null;
paidFromOptions: { id: number; glCode: string; name: string }[] = [];
showForm = false;
saving = false;
formError = '';
form: NewTdsChallan = this.blankForm();
/** Challans are remitted by Head Office under the bank's one TAN; a branch has nothing to enter here. */
get isHeadOffice(): boolean {
return this.scope.isHeadOffice;
}
ngOnInit(): void {
if (!this.isHeadOffice || !this.fy) {
return;
}
this.load();
this.tdsService.getChallanTemplate().subscribe({
next: (t) => (this.paidFromOptions = t?.paidFromOptions || []),
error: () => (this.paidFromOptions = [])
});
}
load(): void {
this.loading = true;
this.loadError = '';
const openingYear = Number(this.fy.slice(0, 4));
forkJoin({
deducted: this.tdsService.getDeducted(this.fy),
challans: this.tdsService.getChallans(this.fy),
// Part B summarises the year per customer, so the interest register is loaded too.
deductees: this.tdsService.getDepositInterest(openingYear)
}).subscribe({
next: ({ deducted, challans, deductees }) => {
this.loading = false;
this.asAt = deducted.asAt;
this.deducted = deducted.months;
this.challans = challans;
setTdsDeductees(deductees);
this.recompute();
},
error: (err: any) => {
this.loading = false;
this.loadError = err?.error?.defaultUserMessage || 'Could not load the remittance position.';
}
});
}
private recompute(): void {
this.rows = tdsRemittanceRows(this.fy, this.deducted, this.challans, this.asAt);
this.summary = tds26qSummary(this.fy, this.rows, this.asAt);
}
onYearChanged(): void {
this.showForm = false;
this.load();
}
/**
* Provision and return references for the selected year. The quarterly return
* became Form 140 under the 2025 Act, so the labels are read from the config
* rather than written into the template.
*/
get statute(): { section: string; statement: string; frequency: string } {
const cfg = FY_TAX_CONFIG[this.fy];
return {
@@ -51,53 +121,95 @@ export class TdsRemittanceReportComponent {
};
}
get scopeLabel(): string {
return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName;
}
get rows(): TdsRemittanceRow[] {
return tdsRemittanceRows(this.fy, this.asAt);
}
get summary(): Tds26qSummary {
return tds26qSummary(this.fy, this.asAt);
}
/**
* The control that matters. Tax deducted must equal tax deposited plus tax
* still within its due date. Anything else means something has gone missing
* and must be found before the return is filed.
*/
get reconciled(): boolean {
return this.summary.difference === 0;
return !!this.summary && Math.abs(this.summary.difference) < 1;
}
get totalDefaultInterest(): number {
return this.summary.defaultInterest;
/** Opens the challan form, pre-filled for a month that still has tax outstanding. */
openForm(row?: TdsRemittanceRow): void {
this.form = this.blankForm();
if (row) {
this.form.month = row.month;
this.form.amount = row.outstanding;
}
this.formError = '';
this.showForm = true;
}
saveChallan(): void {
this.formError = '';
const f = this.form;
if (!f.month || !f.amount || !f.challanNo || !f.bsrCode || !f.depositedOn || !f.paidFromGlAccountId) {
this.formError = 'Fill in every field marked *.';
return;
}
this.saving = true;
this.tdsService.createChallan({ ...f, fy: this.fy, amount: Number(f.amount) }).subscribe({
next: (challans) => {
this.saving = false;
this.showForm = false;
this.challans = challans;
this.recompute();
},
error: (err: any) => {
this.saving = false;
this.formError =
err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
}
});
}
reverseChallan(c: TdsChallan): void {
if (!confirm(`Reverse challan ${c.challanNo} for ₹${c.amount}? Its accounting entry will be undone.`)) {
return;
}
this.tdsService.reverseChallan(c.id).subscribe({
next: (challans) => {
this.challans = challans;
this.recompute();
},
error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not reverse the challan.')
});
}
private blankForm(): NewTdsChallan {
return {
fy: this.fy,
month: '',
amount: 0,
challanNo: '',
bsrCode: '',
depositedOn: '',
paidFromGlAccountId: 0,
remarks: ''
};
}
trackRow(_: number, row: TdsRemittanceRow): string {
return `${row.fy}-${row.quarter}`;
return row.month;
}
async exportWorkbook(): Promise<void> {
const s = this.summary;
if (!s) {
return;
}
this.exporting = true;
try {
const s = this.summary;
const wb = new ExcelJS.Workbook();
const challan = wb.addWorksheet('Challans');
challan.addRow([`TDS remitted to Government — FY ${this.fy}`]);
challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]);
challan.addRow([`Bank as a whole · as at ${this.asAt}`]);
challan.addRow([]);
challan.addRow([
'Quarter',
'Deduction month',
'Amount deducted',
'Deducted',
'Due date',
'Deposited on',
'Challan no.',
'BSR code',
'Deposited',
'Challans',
'Outstanding',
'Status',
'Delay (months)',
'Late-deposit interest',
@@ -107,12 +219,12 @@ export class TdsRemittanceReportComponent {
challan.addRow([
`Q${r.quarter}`,
r.month,
r.amount,
r.deducted,
r.dueDate,
r.depositedOn ?? 'PENDING',
r.challanNo ?? '',
r.bsrCode ?? '',
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
r.deposited,
r.challans.map((c) => `${c.challanNo} / BSR ${c.bsrCode} / ${c.depositedOn}`).join('; '),
r.outstanding,
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.outstanding ? 'Within due date' : 'Deposited',
r.delayMonths || '',
r.defaultInterest || '',
r.returnDueDate
@@ -151,7 +263,11 @@ export class TdsRemittanceReportComponent {
s.tdsAtHigherRate
],
[
'Total TDS deducted',
'TDS due by the rules',
s.calculatedTds
],
[
'Total TDS actually deducted',
s.totalDeducted
],
[
@@ -162,6 +278,10 @@ export class TdsRemittanceReportComponent {
'Still within due date',
s.pendingDeposit
],
[
'Overdue',
s.overdueAmount
],
[
'DIFFERENCE (must be zero)',
s.difference
@@ -169,14 +289,6 @@ export class TdsRemittanceReportComponent {
[
'Interest payable for late deposit',
s.defaultInterest
],
[
'Challans deposited late',
s.lateChallans
],
[
'Challans overdue',
s.overdueChallans
]
];
for (const line of lines) {
+32 -173
View File
@@ -7,33 +7,33 @@
*/
/**
* Deductees and their deposit interest, for the two TDS reports.
* Deductees, their deposit interest and the challans, for the two TDS reports.
*
* The bank's deduction obligation runs on time deposits, so the seed
* gives each customer a mix of fixed and recurring deposits — and some savings
* accounts, which are outside the section and must stay out of the aggregate.
*
* Everything is generated from a fixed seed so the figures do not move between
* loads, and the challan schedule is deliberately imperfect: one quarter is
* deposited late so the default-interest column has something to show.
* Everything here is what the platform returned. The challans used to be generated from the
* computed deductions - challan numbers, BSR codes and deposit dates made up, one quarter
* deliberately late - so the remittance report reconciled against figures nobody had entered.
*/
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
import {
configuredFys,
Declaration,
Form15Kind,
InterestCredit,
TdsAccountInput
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */
export const TDS_FYS = [
'2026-27',
'2025-26'
];
/** The years the reports can show: every year configured on Organization › TDS Configuration. */
export function tdsFys(): string[] {
return configuredFys();
}
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
export const TDS_AS_AT = '2026-08-01';
/** A deposit as the platform reports it: the interest credited and the TDS actually withheld. */
export interface TdsReportAccount extends TdsAccountInput {
/** TDS the platform actually deducted from this account in the year. */
deductions: InterestCredit[];
/** The account's own holder, when the tax is deducted against somebody else (minor, joint). */
holderClientId?: number;
holderName?: string;
}
export interface TdsDeductee {
clientId: number;
@@ -44,115 +44,35 @@ export interface TdsDeductee {
branchCode: string;
/** Declarations on record, keyed by financial year. */
declarations: Record<string, Declaration>;
accounts: TdsAccountInput[];
accounts: TdsReportAccount[];
}
/** One month's remittance of deducted tax to the Government. */
/** One challan Head Office entered: tax remitted to the Government. */
export interface TdsChallan {
id: number;
fy: string;
/** Quarter the deduction belongs to. */
quarter: 1 | 2 | 3 | 4;
/** Month the tax was deducted in, e.g. '2026-06'. */
month: string;
amount: number;
/** Statutory date by which it had to be deposited. */
dueDate: string;
/** When it actually reached the Government — null while still pending. */
depositedOn: string | null;
challanNo: string | null;
bsrCode: string | null;
challanNo: string;
bsrCode: string;
depositedOn: string;
paidFrom: string;
remarks: string | null;
reversed: boolean;
}
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh'
];
/**
* Quarter-end dates for a financial year. Interest is credited on these, which
* is what makes the deduction fall due.
*/
function quarterEnds(fy: string): string[] {
const y = Number(fy.slice(0, 4));
return [
`${y}-06-30`,
`${y}-09-30`,
`${y}-12-31`,
`${y + 1}-03-31`
];
}
/** A well-formed individual PAN — fourth character 'P'. */
function makePan(rand: () => number, surname: string): string {
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
const digits = `${1000 + Math.floor(rand() * 9000)}`;
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
/** Tax the platform withheld in one month. */
export interface TdsDeductedMonth {
month: string;
amount: number;
postings: number;
}
/**
* The deductees the reports run on, loaded from the platform.
*
* This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all.
* The computation over them is the real section 194A calculation, so the figures looked like a
* filing and were not one: not a rupee of it came from the bank's own books.
*
* It is set once by whoever loads the screen and read by the pure computation below, which is why
* It is set once by whoever loads the screen and read by the pure computation, which is why
* this is a module-level holder rather than a service the compute would have to inject.
*/
let liveDeductees: TdsDeductee[] = [];
@@ -171,7 +91,6 @@ export function tdsDeductees(): TdsDeductee[] {
return liveDeductees;
}
/**
* Statutory date by which tax deducted in a given month must be deposited.
*
@@ -189,63 +108,3 @@ export function depositDueDate(month: string): string {
const nextM = m === 12 ? 1 : m + 1;
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
}
let challanCache: Record<string, TdsChallan[]> | null = null;
/**
* The remittance schedule, derived from what the engine says was deducted.
*
* Deductions land at each quarter end, so there is one challan per quarter. The
* Q2 challan of the prior year is deliberately deposited a month and a bit late
* so the late-deposit interest column is exercised, and the current year's latest
* quarter is left pending, which is the ordinary state of a live book.
*/
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
if (challanCache) {
return challanCache;
}
const out: Record<string, TdsChallan[]> = {};
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
const months: Record<number, string> = {
1: `${y}-06`,
2: `${y}-09`,
3: `${y}-12`,
4: `${y + 1}-03`
};
const rows: TdsChallan[] = [];
for (const q of [
1,
2,
3,
4
] as (1 | 2 | 3 | 4)[]) {
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
if (amount <= 0) {
continue;
}
const month = months[q];
const dueDate = depositDueDate(month);
// Anything falling after the business date has not been deposited yet.
const pending = dueDate > TDS_AS_AT;
const late = fy === '2025-26' && q === 2;
rows.push({
fy,
quarter: q,
month,
amount,
dueDate,
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
bsrCode: pending ? null : '0510308'
});
}
out[fy] = rows;
}
challanCache = out;
return out;
}
+299 -222
View File
@@ -6,12 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data';
import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture';
import { TdsChallan, TdsDeductee, depositDueDate, setTdsDeductees } from './tds-reports-data';
import {
NEAR_THRESHOLD_BAND,
TdsReportFilter,
deductedByQuarter,
tds26qSummary,
tdsInterestRows,
tdsInterestRowsForFy,
@@ -19,12 +17,135 @@ import {
tdsRemittanceRows
} from './tds-reports.compute';
/** The complete year in the seed — the one a return would be filed for. */
const CLOSED_FY = '2025-26';
installTdsConfigFixture();
/**
* A small book with every case the reports must handle once, so each figure below can be worked
* out by hand. The reports used to run on sixty-four generated customers; the tests asserted
* shapes ("some row crossed") because nobody could know the numbers.
*/
const FY = '2025-26';
const Q = [
'2025-06-30',
'2025-09-30',
'2025-12-31',
'2026-03-31'
];
const fd = (id: number, amounts: number[], extra: object = {}) => ({
id,
accountNo: `FD${id}`,
depositTypeCode: 'depositAccountType.fixedDeposit',
credits: amounts.map((amount, i) => ({ date: Q[i], amount, accountNo: `FD${id}` })),
deductions: [] as { date: string; amount: number }[],
...extra
});
const sb = (id: number, amount: number) => ({
id,
accountNo: `SB${id}`,
depositTypeCode: 'depositAccountType.savingsDeposit',
credits: [{ date: '2025-09-30', amount, accountNo: `SB${id}` }],
deductions: [] as { date: string; amount: number }[]
});
function person(
clientId: number,
name: string,
dob: string,
pan: string,
branch: string,
accounts: any[],
declarations = {}
): TdsDeductee {
return {
clientId,
customerId: `C${clientId}`,
name,
pan,
dob,
branchCode: branch,
declarations,
accounts: accounts.map((a) => ({ clientId, ...a }))
};
}
const BOOK: TdsDeductee[] = [
// Over the limit in Q4, plus a savings account that must stay outside the figure.
person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [
{
...fd(
11,
[
15_000,
15_000,
15_000,
15_000
]
),
deductions: [{ date: Q[3], amount: 6_000 }]
},
sb(12, 1_200)
]),
// Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little.
person(2, 'Sunita', '1955-01-01', '', 'bhopal', [
{
...fd(
21,
[
30_000,
30_000,
30_000,
30_000
]
),
deductions: [{ date: Q[3], amount: 20_000 }]
}
]),
// ₹6,000 short of the limit: the customer to ask for a declaration.
person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [
fd(
31,
[
22_000,
22_000
]
)
]),
// Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross.
person(
4,
'Kamala',
'1979-07-07',
'ABCPK9012M',
'Head Office',
[
fd(
41,
[
20_000,
20_000,
20_000,
20_000
]
)
],
{
[FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' }
}
),
// A minor's deposit, already listed under the guardian by the platform.
person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [
{ ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }]),
// Savings only, no PAN: never deducted, so never counted as a no-PAN problem.
person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)])
];
setTdsDeductees(BOOK);
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
return {
fy: CLOSED_FY,
fy: FY,
branchCode: '',
quarter: 0,
band: '',
@@ -35,115 +156,94 @@ function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
};
}
describe('the seed has every case the reports need to show', () => {
it('produces deductees on both sides of the threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
expect(rows.length).toBeGreaterThan(20);
expect(rows.some((r) => r.crossed)).toBe(true);
expect(rows.some((r) => !r.crossed)).toBe(true);
expect(rows.some((r) => r.nearThreshold)).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
const row = (name: string) => tdsInterestRowsForFy(FY).find((r) => r.deductee.name === name)!;
describe('which accounts are tracked', () => {
it('counts the FD and leaves the savings account out, saying why', () => {
const r = row('Ramesh');
expect(r.tdsApplicableInterest).toBe(60_000);
expect(r.exemptInterest).toBe(1_200);
expect(r.trackedAccounts).toBe(1);
const savings = r.accounts.find((a) => a.type === 'SB')!;
expect(savings.tracked).toBe(false);
expect(savings.reason).toContain('Savings');
expect(r.accounts[0].type).toBe('FD');
});
it('gives senior citizens the higher threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
it('shows whose account a deposit is when the tax falls on the guardian', () => {
const line = row('Ganpat').accounts[0];
expect(line.tracked).toBe(true);
expect(line.holderName).toBe('Baby Ganpat');
});
it('deducts at the higher rate wherever PAN is missing', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
}
it('marks a savings-only customer as not tracked', () => {
expect(row('Shobha').trackedAccounts).toBe(0);
});
});
describe('the reports follow the Act that governed each year', () => {
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
const old = tdsInterestRowsForFy('2025-26');
expect(old.some((r) => r.form === '15G')).toBe(true);
expect(old.some((r) => r.form === '15H')).toBe(true);
expect(old.some((r) => r.form === '121')).toBe(false);
// The two forms merged on 01 April 2026.
const current = tdsInterestRowsForFy('2026-27');
expect(current.every((r) => r.form === '121')).toBe(true);
describe('the threshold and the rate', () => {
it('deducts on the whole year once the general limit is crossed', () => {
const r = row('Ramesh');
expect(r.threshold).toBe(50_000);
expect(r.crossedInQuarter).toBe(4);
expect(r.schedule.totalTds).toBe(6_000);
});
it('keeps the age-based threshold band in both years', () => {
for (const fy of TDS_FYS) {
const rows = tdsInterestRowsForFy(fy);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
}
it('gives a senior the higher limit, and a missing PAN the higher rate', () => {
const r = row('Sunita');
expect(r.band).toBe('SENIOR');
expect(r.threshold).toBe(100_000);
expect(r.rateApplied).toBe(0.2);
expect(r.schedule.totalTds).toBe(24_000);
});
it('flags a customer just under the limit', () => {
const r = row('Vitthal');
expect(r.crossed).toBe(false);
expect(r.nearThreshold).toBe(true);
expect(r.headroom).toBe(6_000);
expect(r.schedule.totalTds).toBe(0);
});
});
describe('savings interest stays out of the 194A aggregate', () => {
it('reports it separately and never inside the applicable interest', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const withSavings = rows.filter((r) => r.exemptInterest > 0);
expect(withSavings.length).toBeGreaterThan(0);
describe('a mid-year declaration', () => {
it('taxes only the interest credited before it was accepted', () => {
const r = row('Kamala');
expect(r.hasDeclaration).toBe(true);
// ₹40,000 credited before 15 Oct is liable once the year's ₹80,000 crosses the limit.
expect(r.schedule.totalTds).toBe(4_000);
expect(r.tdsSaved).toBe(4_000);
expect(r.paidWithoutDeduction).toBe(40_000);
});
});
for (const row of withSavings) {
// The applicable figure must equal only the time-deposit interest where
// this customer is the deductee.
const expected = row.deductee.accounts
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.tdsApplicableInterest).toBe(expected);
}
describe('due against actually deducted', () => {
it('agrees where the platform deducted the right amount', () => {
expect(row('Ramesh').actualTds).toBe(6_000);
});
it('leaves the savings totals out of the summary base', () => {
it('shows the gap where it did not', () => {
const r = row('Sunita');
expect(r.actualTds).toBe(20_000);
expect(r.schedule.totalTds - r.actualTds).toBe(4_000);
});
it('totals both in the summary', () => {
const s = tdsInterestSummary(filter());
expect(s.exemptInterest).toBeGreaterThan(0);
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
expect(s.totalTds).toBe(6_000 + 24_000 + 4_000);
expect(s.actualTds).toBe(26_000);
});
});
describe('joint deposits are attributed to the first-named holder only', () => {
it('excludes second-holder interest from the aggregate and shows it apart', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
expect(secondHolders.length).toBeGreaterThan(0);
for (const row of secondHolders) {
const jointAccounts = row.deductee.accounts.filter(
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
);
expect(jointAccounts.length).toBeGreaterThan(0);
// None of that interest may appear in the figure the threshold is tested
// against, or one deposit would be taxed against two people.
const jointTotal = jointAccounts
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.jointHeldInterest).toBe(jointTotal);
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
}
});
});
describe('quarter figures add up to the year', () => {
it('per row', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
expect(quarterInterest).toBe(row.tdsApplicableInterest);
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
}
describe('the summary', () => {
it('counts a missing PAN only where a deposit is being tracked', () => {
const s = tdsInterestSummary(filter());
expect(s.noPan).toBe(1);
expect(s.trackedCustomers).toBe(5);
expect(s.exemptInterest).toBe(2_100);
});
it('and across the summary', () => {
it('adds the quarters up to the year', () => {
const s = tdsInterestSummary(filter());
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
@@ -151,151 +251,128 @@ describe('quarter figures add up to the year', () => {
});
describe('filters', () => {
it("narrows to a branch without changing anyone's figures", () => {
const all = tdsInterestRows(filter());
const branch = all[0].branchCode;
const scoped = tdsInterestRows(filter({ branchCode: branch }));
expect(scoped.length).toBeGreaterThan(0);
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
// A branch view must not re-aggregate — the threshold is computed bank-wide.
const sample = scoped[0];
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
it('narrows to a branch by office name, whatever its case, without re-aggregating', () => {
const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' }));
expect(scoped.map((r) => r.deductee.name).sort()).toEqual([
'Sunita',
'Vitthal'
]);
expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000);
});
it('finds the customers worth chasing a declaration from', () => {
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
expect(near.length).toBeGreaterThan(0);
for (const row of near) {
expect(row.crossed).toBe(false);
expect(row.headroom).toBeGreaterThan(0);
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
}
it('finds the customers near the limit and those over it', () => {
expect(tdsInterestRows(filter({ nearThresholdOnly: true })).map((r) => r.deductee.name)).toEqual(['Vitthal']);
expect(
tdsInterestRows(filter({ crossedOnly: true }))
.map((r) => r.deductee.name)
.sort()
).toEqual([
'Kamala',
'Ramesh',
'Sunita'
]);
});
it('narrows to customers who crossed', () => {
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
expect(crossed.length).toBeGreaterThan(0);
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
});
it('searches on name, CIF and PAN', () => {
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
it('searches on name, customer ID and PAN', () => {
expect(tdsInterestRows(filter({ search: 'C3' })).length).toBe(1);
expect(tdsInterestRows(filter({ search: 'abcpk9012m' }))[0].deductee.name).toBe('Kamala');
});
});
describe('declarations', () => {
it('save tax and record the interest paid gross', () => {
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
expect(declared.length).toBeGreaterThan(0);
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
// be carried even though no tax came off it.
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
for (const row of declared) {
expect(row.declarationUin).toBeTruthy();
}
describe('remittance', () => {
const challan = (over: Partial<TdsChallan>): TdsChallan => ({
id: 1,
fy: FY,
month: '2026-03',
amount: 0,
challanNo: '00001',
bsrCode: '0510308',
depositedOn: '2026-04-20',
paidFrom: '',
remarks: null,
reversed: false,
...over
});
it('leaves tax deducted before a mid-year declaration in place', () => {
// Some declarations in the seed were accepted in October. Tax already taken
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
);
expect(midYear.length).toBeGreaterThan(0);
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
});
});
describe('remittance schedule', () => {
it('deposits by the seventh of the following month, and 30 April for March', () => {
it('pays by the seventh of the following month, and 30 April for March', () => {
expect(depositDueDate('2025-06')).toBe('2025-07-07');
expect(depositDueDate('2025-12')).toBe('2026-01-07');
expect(depositDueDate('2026-03')).toBe('2026-04-30');
});
it('remits exactly what the engine says was deducted', () => {
const deducted = deductedByQuarter(CLOSED_FY);
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
for (const row of rows) {
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
}
it('marks a month paid in full and on time', () => {
const [r] = tdsRemittanceRows(
FY,
[{ month: '2026-03', amount: 26_000, postings: 2 }],
[challan({ amount: 26_000 })],
'2026-06-01'
);
expect(r.quarter).toBe(4);
expect(r.outstanding).toBe(0);
expect(r.late).toBe(false);
expect(r.overdue).toBe(false);
expect(r.defaultInterest).toBe(0);
});
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
const late = rows.filter((r) => r.late);
expect(late.length).toBeGreaterThan(0);
for (const row of late) {
expect(row.delayMonths).toBeGreaterThan(0);
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
}
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
expect(row.defaultInterest).toBe(0);
}
it('charges interest on a late challan', () => {
const [r] = tdsRemittanceRows(
FY,
[{ month: '2026-03', amount: 26_000, postings: 2 }],
[challan({ amount: 26_000, depositedOn: '2026-05-02' })],
'2026-06-01'
);
expect(r.late).toBe(true);
expect(r.delayMonths).toBeGreaterThan(0);
expect(r.defaultInterest).toBeGreaterThan(0);
});
});
describe('the 26Q reconciliation control', () => {
it.each(TDS_FYS)('balances for FY %s', (fy) => {
const s = tds26qSummary(fy, TDS_AS_AT);
// Deducted must equal deposited plus what is still within its due date.
// Anything else means tax has gone missing between the ledger and the
// Government, and the return must not be filed until it is found.
it('flags an unpaid month past its due date, and not one still within it', () => {
const rows = tdsRemittanceRows(
FY,
[
{ month: '2025-12', amount: 5_000, postings: 1 },
{ month: '2026-03', amount: 7_000, postings: 1 }
],
[],
'2026-04-10'
);
const dec = rows.find((r) => r.month === '2025-12')!;
const mar = rows.find((r) => r.month === '2026-03')!;
expect(dec.overdue).toBe(true);
expect(dec.defaultInterest).toBeGreaterThan(0);
expect(mar.overdue).toBe(false);
expect(mar.outstanding).toBe(7_000);
});
it('ignores a reversed challan', () => {
const [r] = tdsRemittanceRows(
FY,
[{ month: '2026-03', amount: 26_000, postings: 2 }],
[challan({ amount: 26_000, reversed: true })],
'2026-04-10'
);
expect(r.deposited).toBe(0);
expect(r.outstanding).toBe(26_000);
});
it('reconciles deducted against paid plus still-within-due', () => {
const asAt = '2026-04-10';
const rows = tdsRemittanceRows(
FY,
[
{ month: '2025-12', amount: 5_000, postings: 1 },
{ month: '2026-03', amount: 21_000, postings: 1 }
],
[challan({ month: '2025-12', amount: 5_000, depositedOn: '2026-01-05' })],
asAt
);
const s = tds26qSummary(FY, rows, asAt);
expect(s.totalDeducted).toBe(26_000);
expect(s.totalDeposited).toBe(5_000);
expect(s.pendingDeposit).toBe(21_000);
expect(s.difference).toBe(0);
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
});
it('splits the tax between the standard and the no-PAN rate without loss', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
});
it('splits interest into the deducted and the paid-gross halves', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
// The gross-paid half is the Form 121 disclosure and must not be zero here,
// or the report would never exercise that line.
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
});
it('agrees with the interest report on the same year', () => {
const summary = tdsInterestSummary(filter());
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
});
});
describe('the part-run current year', () => {
it('has postings only up to the business date', () => {
for (const deductee of tdsDeductees()) {
for (const account of deductee.accounts) {
for (const credit of account.credits) {
expect(credit.date <= TDS_AS_AT).toBe(true);
}
}
}
});
it('shows the closed quarter as settled and future obligations as not yet due', () => {
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
expect(rows.some((r) => r.quarter === 1)).toBe(true);
// Q3 cannot appear: no interest has posted there and nothing settles there.
expect(rows.some((r) => r.quarter === 3)).toBe(false);
// Q4 *can* appear even in a part-run year — deposits on annual interest
// posting settle once, at year end — but that is a future obligation, so it
// must read as pending and must never be flagged overdue.
for (const row of rows.filter((r) => r.quarter === 4)) {
expect(row.depositedOn).toBeNull();
expect(row.overdue).toBe(false);
expect(row.defaultInterest).toBe(0);
}
// The year's figures come from the same book as the interest report.
expect(s.calculatedTds).toBe(34_000);
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(26_000);
});
});
+216 -52
View File
@@ -17,7 +17,6 @@
import {
DeclarationForm,
FY_TAX_CONFIG,
Quarter,
TdsMode,
TdsSchedule,
@@ -27,6 +26,7 @@ import {
declarationFormFor,
formKindFor,
fyOf,
fyTaxConfigVersion,
isSeniorInFy,
isTdsApplicable,
isWellFormedPan,
@@ -35,7 +35,15 @@ import {
tdsDeducteeOf,
tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data';
import {
TdsChallan,
TdsDeductedMonth,
TdsDeductee,
TdsReportAccount,
depositDueDate,
tdsDeductees,
tdsDeducteesVersion
} from './tds-reports-data';
// ─────────────────────────────── Filtering ───────────────────────────────
@@ -55,6 +63,29 @@ export interface TdsReportFilter {
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;
/** TDS the platform actually withheld from this account in the year. */
tdsDeducted: number;
/** Set when the tax is deducted against someone other than the account's own holder. */
holderName: string | null;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
@@ -100,6 +131,15 @@ export interface TdsInterestRow {
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;
/**
* TDS the platform actually withheld this year. `schedule.totalTds` is what this report works
* out is due; the two should agree, and a difference is the first thing to investigate.
*/
actualTds: number;
}
function emptyQuarters(): Record<Quarter, number> {
@@ -118,7 +158,8 @@ 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.
const key = fy + '|' + tdsDeducteesVersion;
// 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;
}
@@ -152,14 +193,39 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
let exemptInterest = 0;
let jointHeldInterest = 0;
const accountLines: TdsAccountLine[] = [];
for (const account of deductee.accounts) {
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
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 = 'Its interest received in savings counts toward the TDS limit';
}
const report = account as TdsReportAccount;
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),
tdsDeducted: (report.deductions || []).filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0),
holderName:
report.holderClientId && report.holderClientId !== deductee.clientId ? (report.holderName ?? null) : 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);
@@ -188,7 +254,10 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied
rateApplied: schedule.rateApplied,
accounts: accountLines,
trackedAccounts: accountLines.filter((a) => a.tracked).length,
actualTds: accountLines.reduce((s, a) => s + a.tdsDeducted, 0)
});
}
@@ -208,10 +277,29 @@ function overallModeFor(deductee: TdsDeductee): TdsMode {
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 && row.branchCode !== filter.branchCode) {
if (filter.branchCode && !sameBranch(row.branchCode, filter.branchCode)) {
return false;
}
if (filter.band && row.band !== filter.band) {
@@ -246,12 +334,16 @@ export interface TdsInterestSummary {
/** Savings interest, outside the section. */
exemptInterest: number;
totalTds: number;
/** TDS the platform actually withheld — set against `totalTds`, which is what is due. */
actualTds: 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 }[];
}
@@ -273,26 +365,50 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
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),
actualTds: rows.reduce((s, r) => s + r.actualTds, 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,
// 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. */
/**
* One deduction month: what the platform withheld, and what Head Office remitted against it.
*
* Driven by the withhold-tax postings and the challans entered, never by this report's own
* computation — a return reconciles what was deducted, not what should have been.
*/
export interface TdsRemittanceRow {
fy: string;
quarter: Quarter;
/** Month the tax was deducted in, e.g. '2026-06'. */
month: string;
deducted: number;
postings: number;
/** Live challans entered against this month. */
challans: TdsChallan[];
deposited: number;
lastDepositedOn: string | null;
dueDate: string;
/** Deducted but not yet remitted. */
outstanding: number;
/** Remitted beyond what was deducted — a challan entered against the wrong month, usually. */
excess: number;
/** Something is still outstanding and the due date has passed. */
overdue: boolean;
/** Deposited, but after the due date. */
/** A challan reached the Government after the due date. */
late: boolean;
/** Months or part months of delay. */
delayMonths: number;
/** Interest charged for depositing late. */
/** Interest the bank owes for depositing late. */
defaultInterest: number;
/** Return the quarter's deduction is reported in. */
returnDueDate: string;
@@ -305,41 +421,83 @@ const RETURN_DUE: Record<Quarter, string> = {
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;
}
export function quarterOfMonth(month: string): Quarter {
const m = Number(month.slice(5, 7));
if (m >= 4 && m <= 6) {
return 1;
}
return out;
if (m >= 7 && m <= 9) {
return 2;
}
if (m >= 10) {
return 3;
}
return 4;
}
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] ?? [];
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
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;
export function tdsRemittanceRows(
fy: string,
deducted: TdsDeductedMonth[],
challans: TdsChallan[],
asAt: string
): TdsRemittanceRow[] {
const live = challans.filter((c) => !c.reversed && c.fy === fy);
const months = [
...new Set([
...deducted.map((d) => d.month),
...live.map((c) => c.month)
])
].sort();
return months.map((month) => {
const d = deducted.find((x) => x.month === month);
const amount = d?.amount ?? 0;
const forMonth = live.filter((c) => c.month === month);
const deposited = round2(forMonth.reduce((s, c) => s + c.amount, 0));
const lastDepositedOn = forMonth.reduce<string | null>(
(last, c) => (!last || c.depositedOn > last ? c.depositedOn : last),
null
);
const dueDate = depositDueDate(month);
const outstanding = Math.max(0, round2(amount - deposited));
const excess = Math.max(0, round2(deposited - amount));
const overdue = outstanding > 0 && dueDate < asAt;
const lateChallans = forMonth.filter((c) => c.depositedOn > dueDate);
const late = lateChallans.length > 0;
// Interest runs per challan from the due date to the day it was paid, and on whatever is still
// outstanding from the due date to today.
let defaultInterest = lateChallans.reduce(
(s, c) => s + lateDeductionInterest(c.amount, dueDate, c.depositedOn, 'DEPOSIT', fy).interest,
0
);
if (overdue) {
defaultInterest += lateDeductionInterest(outstanding, dueDate, asAt, 'DEPOSIT', fy).interest;
}
const settledOn = overdue ? asAt : lastDepositedOn;
const delayMonths = (late || overdue) && settledOn ? monthsOrPartBetween(dueDate, settledOn) : 0;
return {
...c,
fy,
quarter: quarterOfMonth(month),
month,
deducted: amount,
postings: d?.postings ?? 0,
challans: forMonth,
deposited,
lastDepositedOn,
dueDate,
outstanding,
excess,
overdue,
late,
delayMonths,
defaultInterest,
returnDueDate: RETURN_DUE[c.quarter]
returnDueDate: RETURN_DUE[quarterOfMonth(month)]
};
});
}
@@ -347,9 +505,9 @@ export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[]
/**
* 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.
* `difference` is the control figure: tax actually deducted, less what reached the Government,
* less what is still within its due date. Anything other than zero means a month is overdue or
* a challan sits against the wrong month.
*/
export interface Tds26qSummary {
fy: string;
@@ -360,9 +518,13 @@ export interface Tds26qSummary {
interestWithoutDeduction: number;
tdsAtStandardRate: number;
tdsAtHigherRate: number;
/** What this report works out was due. */
calculatedTds: number;
/** What the platform actually withheld from accounts. */
totalDeducted: number;
totalDeposited: number;
pendingDeposit: number;
overdueAmount: number;
/** Deducted less deposited less still-pending. Must be zero. */
difference: number;
defaultInterest: number;
@@ -370,29 +532,31 @@ export interface Tds26qSummary {
overdueChallans: number;
}
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], 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);
const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0));
const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0));
const pendingDeposit = round2(remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0));
const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0));
return {
fy,
deductees: rows.length,
deductees: rows.filter((r) => r.trackedAccounts > 0).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),
tdsAtStandardRate: standard.reduce((s, r) => s + r.actualTds, 0),
tdsAtHigherRate: higher.reduce((s, r) => s + r.actualTds, 0),
calculatedTds: rows.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
overdueAmount,
difference: round2(totalDeducted - totalDeposited - pendingDeposit),
defaultInterest: remittance.reduce((s, r) => s + r.defaultInterest, 0),
lateChallans: remittance.filter((r) => r.late).length,
overdueChallans: remittance.filter((r) => r.overdue).length
};
}
+143 -35
View File
@@ -13,15 +13,52 @@ import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
import { TdsDeductee } from './tds-reports-data';
import { Declaration, formKindFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductedMonth, TdsDeductee } from './tds-reports-data';
/** A declaration as recorded on the platform. */
export interface TdsDeclarationRecord {
id: number;
clientId: number;
clientName?: string;
fy: string;
formType: '15G' | '15H' | '121';
uin: string | null;
acceptedOn: string;
estimatedIncome: number | null;
revoked: boolean;
revokedOn: string | null;
revokeReason: string | null;
}
export interface NewTdsDeclaration {
clientId: number;
fy: string;
formType: string;
acceptedOn: string;
uin?: string;
estimatedIncome?: number | null;
}
export interface NewTdsChallan {
fy: string;
month: string;
amount: number;
challanNo: string;
bsrCode: string;
depositedOn: string;
paidFromGlAccountId: number;
remarks?: string;
}
/**
* Deposit interest for the TDS reports.
* Deposit interest, declarations and challans for the TDS screens.
*
* The platform returns the interest it actually credited — nothing else. A deposit is the
* customer's own money arriving and a fee is a charge, so neither is income and neither is sent.
* Working the figure out here from a balance and a rate would produce a number the passbook does
* not show.
* On an FD or RD the platform returns the interest that reached the customer's savings account and
* the TDS debited there for it — TDS is tracked and taken where the interest is received. A deposit
* is the customer's own money arriving and a fee is a charge, so neither is income and neither is
* sent. Working the figure out here from a balance and a rate would produce a number the passbook
* does not show.
*/
@Injectable({ providedIn: 'root' })
export class TdsReportsService {
@@ -31,60 +68,131 @@ export class TdsReportsService {
* @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027.
* Omit to let the platform use the year the business date falls in.
*/
getDepositInterest(financialYear?: number): Observable<TdsDeductee[]> {
const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined;
getDepositInterest(financialYear?: number, clientId?: number): Observable<TdsDeductee[]> {
let params = new HttpParams();
if (financialYear) {
params = params.set('financialYear', String(financialYear));
}
if (clientId) {
params = params.set('clientId', String(clientId));
}
return this.http
.get<any[]>('/tds/deposit-interest', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r))));
}
getDeclarations(clientId: number): Observable<TdsDeclarationRecord[]> {
return this.http.get<TdsDeclarationRecord[]>('/tds/declarations', {
params: new HttpParams().set('clientId', String(clientId))
});
}
createDeclaration(declaration: NewTdsDeclaration): Observable<TdsDeclarationRecord[]> {
return this.http.post<TdsDeclarationRecord[]>('/tds/declarations', declaration);
}
revokeDeclaration(id: number, reason: string): Observable<TdsDeclarationRecord[]> {
return this.http.post<TdsDeclarationRecord[]>(`/tds/declarations/${id}/revoke`, { reason });
}
/** Tax actually withheld per month, and the business date the books are on. */
getDeducted(fy: string): Observable<{ asAt: string; months: TdsDeductedMonth[] }> {
return this.http.get<any>('/tds/deducted', { params: new HttpParams().set('fy', fy) }).pipe(
map((res) => ({
asAt: res?.asAt ?? '',
months: (res?.months || []).map((m: any) => ({
month: m.month,
amount: Number(m.amount) || 0,
postings: Number(m.postings) || 0
}))
}))
);
}
getChallans(fy: string): Observable<TdsChallan[]> {
return this.http
.get<any[]>('/tds/challans', { params: new HttpParams().set('fy', fy) })
.pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
}
getChallanTemplate(): Observable<{ paidFromOptions: { id: number; glCode: string; name: string }[] }> {
return this.http.get<any>('/tds/challans/template');
}
createChallan(challan: NewTdsChallan): Observable<TdsChallan[]> {
return this.http
.post<any[]>('/tds/challans', challan)
.pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
}
reverseChallan(id: number): Observable<TdsChallan[]> {
return this.http
.post<any[]>(`/tds/challans/${id}/reverse`, {})
.pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
}
private toChallan(r: any): TdsChallan {
return {
id: r.id,
fy: r.fy,
month: r.month,
amount: Number(r.amount) || 0,
challanNo: r.challanNo,
bsrCode: r.bsrCode,
depositedOn: (r.depositedOn || '').slice(0, 10),
paidFrom: r.paidFrom || '',
remarks: r.remarks ?? null,
reversed: !!r.reversed
};
}
/**
* Shapes one platform row into what the section 194A computation expects.
* Shapes one platform row into what the TDS computation expects.
*
* Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation
* compares them as strings when deciding which financial year a credit falls in.
* compares them as strings when deciding which financial year a credit falls in. The platform
* has already grouped a minor's deposit under the guardian and a joint deposit under its primary
* holder, so every account here belongs to the deductee it is listed under.
*/
private toDeductee(row: any): TdsDeductee {
const dob = toIsoDate(row.dateOfBirth);
const credit = (a: any) => (c: any) => ({
date: toIsoDate(c.date),
amount: Number(c.amount) || 0,
accountNo: c.accountNo || a.accountNo || ''
});
return {
clientId: row.clientId,
customerId: row.customerId || '',
name: row.name || '',
pan: row.pan || '',
dob: toIsoDate(row.dateOfBirth),
dob,
branchCode: row.branchName || '',
// A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform
// carries the kind but not the acceptance date, so the year's start stands in — a
// declaration is operative for the year it was given, which is what the computation needs.
declarations: this.declarationsOf(row),
declarations: this.declarationsOf(row.declarations || [], dob),
accounts: (row.accounts || []).map((a: any) => ({
id: a.id,
accountNo: a.accountNo || '',
depositTypeCode: a.depositTypeCode || '',
accountStatus: a.accountStatus || undefined,
// The deductee, not the holder: the grouping above already resolved whose PAN it is.
clientId: a.clientId ?? row.clientId,
credits: (a.credits || []).map((c: any) => ({
date: toIsoDate(c.date),
amount: Number(c.amount) || 0,
accountNo: c.accountNo || a.accountNo || ''
}))
holderClientId: a.holderClientId ?? undefined,
holderName: a.holderName ?? undefined,
credits: (a.credits || []).map(credit(a)),
deductions: (a.deductions || []).map(credit(a))
}))
};
}
private declarationsOf(row: any): Record<string, any> {
const kind = (row.form15Type || '').trim().toUpperCase();
if (kind !== '15G' && kind !== '15H') {
return {};
}
const out: Record<string, any> = {};
// Applied to the years the reports can show. Without an acceptance date on the record the
// declaration is treated as operative from the start of each year rather than mid-year.
for (const year of [
2025,
2026
]) {
const fy = `FY ${year}-${String(year + 1).slice(2)}`;
out[fy] = { kind, acceptedOn: `${year}-04-01`, fy };
/**
* Declarations keyed by year, the form the computation looks them up by ('2026-27'). A Form 121
* carries no G/H split, so the band follows the customer's age in that year.
*/
private declarationsOf(rows: any[], dob: string): Record<string, Declaration> {
const out: Record<string, Declaration> = {};
for (const d of rows) {
const fy = d.fy;
const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(dob, fy);
out[fy] = { kind, acceptedOn: toIsoDate(d.acceptedOn), fy, uin: d.uin || undefined };
}
return out;
}
+119
View File
@@ -647,3 +647,122 @@
color: var(--tdsr-ink);
white-space: nowrap;
}
/* ── Help panel (interest report) ── */
.tdsr__help {
margin-bottom: 16px;
padding: 14px 18px;
border: 1px solid color-mix(in srgb, var(--tdsr-blue) 25%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--tdsr-blue) 5%, transparent);
line-height: 1.6;
}
.tdsr__help-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
font-weight: 700;
color: var(--tdsr-blue);
}
.tdsr__help-list {
margin: 0;
padding-left: 20px;
li + li {
margin-top: 2px;
}
}
/* ── Action chips under the summary ── */
.tdsr__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -6px 0 16px;
}
.tdsr__chip {
padding: 5px 12px;
border: 1px solid var(--tdsr-line);
border-radius: 999px;
background: var(--tdsr-bg);
font: inherit;
font-size: 12px;
font-weight: 600;
&--near {
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
color: var(--tdsr-blue);
cursor: pointer;
}
&--ok {
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
color: var(--tdsr-green);
}
&--warn {
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
color: var(--tdsr-amber);
}
}
.tdsr__row--click {
cursor: pointer;
&:hover > td {
background: var(--tdsr-bg);
}
}
.tdsr__detail-h {
margin: 4px 0 8px;
font-weight: 700;
.tdsr__table--inner + & {
margin-top: 16px;
}
}
/* ── Challan form (remittance report) ── */
.tdsr__form {
padding-bottom: 14px;
}
.tdsr__filters--flat {
margin: 0;
border: 0;
box-shadow: none;
}
.tdsr__form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 0 16px;
}
.tdsr__btn--primary {
border-color: var(--tdsr-blue);
background: var(--tdsr-blue);
color: #fff;
}
.tdsr__btn--sm {
padding: 3px 10px;
font-size: 11.5px;
}
.tdsr__link {
margin-left: 6px;
padding: 0;
border: 0;
background: none;
color: var(--tdsr-amber);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
+1 -2
View File
@@ -32,7 +32,6 @@ import {
quarterEnd,
quarterOf
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT } from './tds-reports-data';
import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute';
/** The bank's own deductor particulars, which head every filing. */
@@ -131,7 +130,7 @@ function monthLabel(key: string): string {
* the portal rejects the line, and a silently dropped row is how a bank ends up
* reporting fewer declarations than it holds.
*/
export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement {
export function declarationStatement(fy: string, asAt: string): DeclarationStatement {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {