kyc exception at client creation time and some tds work
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user