467 lines
15 KiB
TypeScript
467 lines
15 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/.
|
||
*/
|
||
|
||
/**
|
||
* 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<string, DeclarationStatementLine[]>();
|
||
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<string, string> {
|
||
const out = new Map<string, string>();
|
||
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<Quarter, (startYear: number) => 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));
|
||
}
|