380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
/**
|
||
* Copyright since 2025 Mifos Initiative
|
||
*
|
||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
*/
|
||
|
||
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));
|
||
}
|