Merge branch 'dev-latest' into feature/advanceLoanProduct
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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 & 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 & 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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user