feat: updates for cashier, tds reports, rbi reserve and dashboard

This commit is contained in:
2026-10-07 10:13:28 +05:30
parent 5ab4e9eaf0
commit 6226682b36
50 changed files with 3765 additions and 1636 deletions
+379
View File
@@ -0,0 +1,379 @@
/**
* 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));
}