Files
cbsnexora/src/app/tds-reports/tds-interest-export.ts
T

380 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
}