/** * 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/. */ /** * The three things the bank actually has to file or hand over. * * 1. The consolidated statement of declarations received — monthly from * FY 2026-27, due on the 7th of the following month, carrying the UIN the * branch allotted to each Form 121. * 2. The deductee annexure of the quarterly return (Form 140), which needs the * UIN against every payee paid gross so the department can tie a * nil-deduction back to a declaration. * 3. The certificate issued to each deductee (Form 131), which is what the * customer uses to claim credit in their own return. * * All figures come from the TDS engine. Nothing here recomputes tax — a return * that disagrees with the register is worse than no return at all. */ import { FY_TAX_CONFIG, Quarter, buildUin, declarationStatementDueDate, isWellFormedPan, normaliseFy, quarterEnd, quarterOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute'; /** The bank's own deductor particulars, which head every filing. */ export interface DeductorParticulars { tan: string; pan: string; name: string; branchAddress: string; responsiblePerson: string; designation: string; } /** * Placeholder particulars for the demo book. In a live install these come from * the organisation's tax registration, not from a constant. */ export const DEDUCTOR: DeductorParticulars = { tan: 'MUMC12345E', pan: 'AAACC1234C', name: 'CBS Nexora Co-operative Bank Ltd.', branchAddress: 'Head Office, Mumbai 400001', responsiblePerson: 'Chief Financial Officer', designation: 'Principal Officer' }; // ───────────────── 1. Consolidated statement of declarations ───────────────── export interface DeclarationStatementLine { /** Running number within the statement. */ serial: number; uin: string; payeeName: string; pan: string; customerId: string; branchCode: string; /** Date the branch accepted the declaration. */ acceptedOn: string; /** * Income the declaration covers, as estimated when it was accepted. The * statement reports the amount the payer expects to pay gross. */ estimatedIncome: number; /** Interest actually paid without deduction so far. */ paidWithoutDeduction: number; /** Whether the declaration would survive scrutiny on the branch's own record. */ eligible: boolean; } export interface DeclarationStatementPeriod { /** 'MONTHLY' periods are keyed '2026-04'; quarterly ones 'Q1'. */ key: string; label: string; /** Statutory upload deadline for this period. */ dueDate: string; overdue: boolean; lines: DeclarationStatementLine[]; totalPaidWithoutDeduction: number; } export interface DeclarationStatement { fy: string; form: string; rule: string; frequency: 'MONTHLY' | 'QUARTERLY'; periods: DeclarationStatementPeriod[]; totalDeclarations: number; /** Declarations on record that the branch could not lawfully have accepted. */ ineligibleCount: number; /** Declarations with no UIN allotted — these cannot be filed at all. */ missingUinCount: number; } function monthLabel(key: string): string { const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; return `${months[Number(key.slice(5, 7)) - 1]} ${key.slice(0, 4)}`; } /** * Groups every declaration on record for the year into the periods the payer has * to upload, and flags the ones that are already past their due date. * * A declaration with no UIN is counted separately rather than filed with a blank: * 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): DeclarationStatement { const year = normaliseFy(fy); const cfg = FY_TAX_CONFIG[year]; if (!cfg) { throw new Error(`No tax configuration for FY ${year}`); } const declared = tdsInterestRowsForFy(year).filter((r) => r.hasDeclaration && r.declarationAcceptedOn); const buckets = new Map(); let serial = 0; let ineligibleCount = 0; let missingUinCount = 0; for (const row of declared) { const acceptedOn = row.declarationAcceptedOn as string; const key = cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`; if (!row.declarationUin) { missingUinCount++; } // The branch cannot compute income earned elsewhere, so eligibility here is // only what the register can see: a valid PAN on an individual. const eligible = isWellFormedPan(row.deductee.pan); if (!eligible) { ineligibleCount++; } const line: DeclarationStatementLine = { serial: ++serial, uin: row.declarationUin ?? '', payeeName: row.deductee.name, pan: row.deductee.pan, customerId: row.deductee.customerId, branchCode: row.branchCode, acceptedOn, estimatedIncome: row.tdsApplicableInterest, paidWithoutDeduction: row.paidWithoutDeduction, eligible }; const list = buckets.get(key); if (list) { list.push(line); } else { buckets.set(key, [line]); } } const periods: DeclarationStatementPeriod[] = [...buckets.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map( ([ key, lines ]) => { const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year); return { key, label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key, dueDate, overdue: dueDate < asAt, lines, totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0) }; } ); return { fy: year, form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', rule: cfg.declarationRule, frequency: cfg.declarationStatementFrequency, periods, totalDeclarations: declared.length, ineligibleCount, missingUinCount }; } /** * Allots UINs to declarations that do not have one. * * Sequence numbers continue from the highest already in use for the year, so a * re-run does not renumber declarations that have already been filed. */ export function allotMissingUins(rows: TdsInterestRow[], fy: string, tan: string = DEDUCTOR.tan): Map { const out = new Map(); let next = rows.reduce((max, r) => { const seq = r.declarationUin ? Number(r.declarationUin.slice(1, 10)) : 0; return Number.isFinite(seq) && seq > max ? seq : max; }, 0); for (const row of rows) { if (row.hasDeclaration && !row.declarationUin) { out.set(row.deductee.customerId, buildUin(++next, fy, tan)); } } return out; } // ──────────────────── 2. Deductee annexure of the return ──────────────────── /** One deductee line in the quarterly return's annexure. */ export interface ReturnAnnexureLine { serial: number; payeeName: string; pan: string; /** 'Individual' or 'HUF', from the fourth character of the PAN. */ payeeType: string; /** Amount paid or credited in the quarter. */ amountPaid: number; /** Tax deducted in the quarter. */ taxDeducted: number; rate: number; /** * Reason the payer deducted nothing or less than the standard rate. * Blank where tax was deducted normally. */ remark: string; /** UIN of the declaration, required wherever the remark cites one. */ uin: string; branchCode: string; } export interface ReturnAnnexure { fy: string; quarter: Quarter; form: string; rule: string; section: string; /** Last day of the quarter the return covers. */ periodEnd: string; dueDate: string; lines: ReturnAnnexureLine[]; totalPaid: number; totalDeducted: number; /** Lines carrying a nil-deduction remark — these need a UIN. */ nilDeductionLines: number; /** Nil-deduction lines with no UIN. The portal will reject these. */ missingUinLines: number; } const RETURN_DUE_DATE: Record string> = { 1: (y) => `${y}-07-31`, 2: (y) => `${y}-10-31`, 3: (y) => `${y + 1}-01-31`, 4: (y) => `${y + 1}-05-31` }; /** * Remark codes for a nil or lower deduction. * * The department's file specification assigns a letter per reason. The letters * have been revised more than once, so they live here as named constants and * must be checked against the current specification before a file is generated — * a wrong code fails validation at upload, or worse, passes and misreports. */ export const NIL_DEDUCTION_REMARK = { /** Payee filed a declaration for receipt without deduction. */ DECLARATION: 'B', /** Aggregate stayed within the threshold, so nothing was deductible. */ BELOW_THRESHOLD: 'Y', /** Higher rate applied because PAN was not furnished. */ NO_PAN: 'C' }; function payeeTypeOf(pan: string): string { const p = (pan || '').toUpperCase(); if (!isWellFormedPan(p)) { return 'Unknown'; } return p[3] === 'H' ? 'HUF' : p[3] === 'P' ? 'Individual' : 'Entity'; } /** * The deductee annexure for one quarter. * * Every payee who was paid interest in the quarter appears, whether or not tax * came off — a payee paid gross against a declaration is precisely what the * department cross-checks, so omitting them is the error to avoid. */ export function returnAnnexure(fy: string, quarter: Quarter): ReturnAnnexure { const year = normaliseFy(fy); const cfg = FY_TAX_CONFIG[year]; if (!cfg) { throw new Error(`No tax configuration for FY ${year}`); } const startYear = Number(year.slice(0, 4)); const lines: ReturnAnnexureLine[] = []; let serial = 0; for (const row of tdsInterestRowsForFy(year)) { const line = row.schedule.lines.find((l) => l.quarter === quarter); const amountPaid = line?.interest ?? 0; const taxDeducted = line?.tds ?? 0; if (amountPaid === 0 && taxDeducted === 0) { continue; } // The remark explains why the deduction is not simply rate × amount. let remark = ''; if (taxDeducted <= 0 && amountPaid > 0) { if (row.hasDeclaration && line?.declarationActive) { remark = NIL_DEDUCTION_REMARK.DECLARATION; } else if (!row.crossed) { remark = NIL_DEDUCTION_REMARK.BELOW_THRESHOLD; } } else if (taxDeducted > 0 && !row.panOnRecord) { remark = NIL_DEDUCTION_REMARK.NO_PAN; } lines.push({ serial: ++serial, payeeName: row.deductee.name, pan: row.deductee.pan, payeeType: payeeTypeOf(row.deductee.pan), amountPaid, taxDeducted, rate: row.rateApplied, remark, uin: row.declarationUin ?? '', branchCode: row.branchCode }); } const nilDeduction = lines.filter((l) => l.remark === NIL_DEDUCTION_REMARK.DECLARATION); return { fy: year, quarter, form: cfg.tdsStatementForm, rule: cfg.tdsStatementRule, section: cfg.interestTdsSection, periodEnd: quarterEnd(quarter, year), dueDate: RETURN_DUE_DATE[quarter](startYear), lines, totalPaid: lines.reduce((s, l) => s + l.amountPaid, 0), totalDeducted: lines.reduce((s, l) => s + l.taxDeducted, 0), nilDeductionLines: nilDeduction.length, missingUinLines: nilDeduction.filter((l) => !l.uin).length }; } // ───────────────────── 3. Certificate for the deductee ───────────────────── export interface TdsCertificateQuarterLine { quarter: Quarter; label: string; amountPaid: number; taxDeducted: number; /** Tax the bank has actually remitted for this quarter. */ taxDeposited: number; } export interface TdsCertificate { fy: string; form: string; section: string; /** Certificate number, unique per deductee per year. */ certificateNo: string; deductor: DeductorParticulars; payeeName: string; pan: string; customerId: string; branchCode: string; lines: TdsCertificateQuarterLine[]; totalPaid: number; totalDeducted: number; /** Interest paid gross against a declaration, disclosed on the certificate. */ paidWithoutDeduction: number; declarationUin: string | null; rate: number; /** A certificate cannot be issued without a valid PAN to issue it against. */ issuable: boolean; blockingReason: string | null; } /** * The TDS certificate for one deductee. * * Quarterly deposits are taken as equal to the quarter's deduction: the branch * remits what it deducts, and the remittance report is where any gap between the * two is surfaced. Wiring the certificate to actual challans is the right next * step once challan records come from the backend rather than the seed. */ export function tdsCertificate(row: TdsInterestRow, fy: string): TdsCertificate { const year = normaliseFy(fy); const cfg = FY_TAX_CONFIG[year]; if (!cfg) { throw new Error(`No tax configuration for FY ${year}`); } const lines: TdsCertificateQuarterLine[] = row.schedule.lines.map((l) => ({ quarter: l.quarter, label: l.label, amountPaid: l.interest, taxDeducted: l.tds, taxDeposited: Math.max(0, l.tds) })); const issuable = isWellFormedPan(row.deductee.pan); return { fy: year, form: cfg.certificateForm, section: cfg.interestTdsSection, certificateNo: `${DEDUCTOR.tan}/${uinSafe(year)}/${row.deductee.customerId}`, deductor: DEDUCTOR, payeeName: row.deductee.name, pan: row.deductee.pan, customerId: row.deductee.customerId, branchCode: row.branchCode, lines, totalPaid: row.tdsApplicableInterest, totalDeducted: row.schedule.totalTds, paidWithoutDeduction: row.paidWithoutDeduction, declarationUin: row.declarationUin, rate: row.rateApplied, issuable, blockingReason: issuable ? null : `No valid PAN on record. A certificate cannot be issued without one — the deductee would be unable to claim credit.` }; } function uinSafe(fy: string): string { return normaliseFy(fy).replace('-', ''); } /** Certificates for every deductee who had tax deducted in the year. */ export function tdsCertificates(fy: string): TdsCertificate[] { return tdsInterestRowsForFy(normaliseFy(fy)) .filter((r) => r.schedule.totalTds > 0 || r.paidWithoutDeduction > 0) .map((r) => tdsCertificate(r, fy)); }