kyc exception at client creation time and some tds work
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

This commit is contained in:
2026-08-05 17:23:56 +05:30
parent cc5cd4c6ab
commit 569f17ef46
37 changed files with 9800 additions and 738 deletions
@@ -0,0 +1,466 @@
/**
* 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));
}