Files
cbsnexora/src/app/tds-reports/tds-statutory.compute.ts
T
keshav 569f17ef46
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
kyc exception at client creation time and some tds work
2026-08-05 17:23:56 +05:30

467 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { TDS_AS_AT } from './tds-reports-data';
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 = TDS_AS_AT): 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));
}