/** * 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 = { 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 { 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)); }