kyc exception at client creation time and some tds work
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Aggregation for the two TDS reports.
|
||||
*
|
||||
* Every rupee figure here comes out of `tds-15gh.compute` — the reports do no
|
||||
* tax arithmetic of their own. Writing it twice is how the register and the
|
||||
* return end up disagreeing, so the engine is the single authority and this
|
||||
* module only groups and totals what it returns.
|
||||
*/
|
||||
|
||||
import {
|
||||
DeclarationForm,
|
||||
FY_TAX_CONFIG,
|
||||
Quarter,
|
||||
TdsMode,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isSeniorInFy,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
lateDeductionInterest,
|
||||
monthsOrPartBetween,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data';
|
||||
|
||||
// ─────────────────────────────── Filtering ───────────────────────────────
|
||||
|
||||
export interface TdsReportFilter {
|
||||
fy: string;
|
||||
/** '' means every branch. */
|
||||
branchCode: string;
|
||||
/** 0 means the whole year. */
|
||||
quarter: 0 | Quarter;
|
||||
/** '' means both declaration types. */
|
||||
band: '' | 'GENERAL' | 'SENIOR';
|
||||
/** Free text over name, CIF and PAN. */
|
||||
search: string;
|
||||
/** Only customers whose aggregate crossed the deduction trigger. */
|
||||
crossedOnly: boolean;
|
||||
/** Only customers sitting just below the trigger — the follow-up list. */
|
||||
nearThresholdOnly: boolean;
|
||||
}
|
||||
|
||||
/** How close to the trigger counts as "worth chasing a declaration for". */
|
||||
export const NEAR_THRESHOLD_BAND = 6_000;
|
||||
|
||||
// ───────────────────────── Report 1: interest ─────────────────────────
|
||||
|
||||
export interface TdsInterestRow {
|
||||
deductee: TdsDeductee;
|
||||
branchCode: string;
|
||||
/** The form in force for the year — '121' from FY 2026-27. */
|
||||
form: DeclarationForm;
|
||||
/** Which deduction trigger applies. Age-based, even after the forms merged. */
|
||||
band: 'GENERAL' | 'SENIOR';
|
||||
/**
|
||||
* The 15G/15H label. A form distinction only up to FY 2025-26; from FY 2026-27
|
||||
* the two merged, so it duplicates `band` and is kept for pre-2026 years.
|
||||
*/
|
||||
kind: '15G' | '15H';
|
||||
age: number;
|
||||
/** Settlement timing, from the customer's deposit products. */
|
||||
mode: TdsMode;
|
||||
/** Interest on time deposits where this customer is the deductee, by quarter. */
|
||||
quarters: Record<Quarter, number>;
|
||||
/** Total of the above — the figure the deduction trigger is tested against. */
|
||||
tdsApplicableInterest: number;
|
||||
/** Savings interest, shown alongside but outside the charge on time deposits. */
|
||||
exemptInterest: number;
|
||||
/** Interest on deposits where somebody else is the first-named holder. */
|
||||
jointHeldInterest: number;
|
||||
threshold: number;
|
||||
crossed: boolean;
|
||||
/** The quarter the aggregate crossed the trigger, if it did. */
|
||||
crossedInQuarter: Quarter | null;
|
||||
/** How much more interest before the trigger bites. */
|
||||
headroom: number;
|
||||
nearThreshold: boolean;
|
||||
schedule: TdsSchedule;
|
||||
hasDeclaration: boolean;
|
||||
declarationUin: string | null;
|
||||
declarationAcceptedOn: string | null;
|
||||
/** Tax the declaration kept out of the customer's pocket. */
|
||||
tdsSaved: number;
|
||||
/** Interest paid gross on the strength of the declaration — reportable in the return. */
|
||||
paidWithoutDeduction: number;
|
||||
panOnRecord: boolean;
|
||||
rateApplied: number;
|
||||
}
|
||||
|
||||
function emptyQuarters(): Record<Quarter, number> {
|
||||
return { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per deductee for the selected year.
|
||||
*
|
||||
* Memoised on the year alone: building a row runs two schedules, and the getters
|
||||
* that feed the template are read many times per change-detection pass.
|
||||
*/
|
||||
let rowCacheKey = '';
|
||||
let rowCache: TdsInterestRow[] = [];
|
||||
|
||||
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
|
||||
if (rowCacheKey === fy) {
|
||||
return rowCache;
|
||||
}
|
||||
|
||||
const rows: TdsInterestRow[] = [];
|
||||
|
||||
for (const deductee of tdsDeductees()) {
|
||||
const ctx = {
|
||||
fy,
|
||||
dateOfBirth: deductee.dob,
|
||||
pan: deductee.pan,
|
||||
resident: true
|
||||
};
|
||||
|
||||
// Rules 8 to 12 live in the engine: savings interest dropped, joint deposits
|
||||
// attributed to the first-named holder, every branch aggregated.
|
||||
const credits = collectTdsCredits(deductee.clientId, deductee.accounts, fy);
|
||||
if (!credits.length && !deductee.accounts.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const declaration = deductee.declarations[fy] ?? null;
|
||||
const mode = overallModeFor(deductee);
|
||||
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||
const withoutDeclaration = declaration ? buildSchedule(mode, credits, { ...ctx, declaration: null }) : schedule;
|
||||
|
||||
const quarters = emptyQuarters();
|
||||
for (const line of schedule.lines) {
|
||||
quarters[line.quarter] = line.interest;
|
||||
}
|
||||
|
||||
let exemptInterest = 0;
|
||||
let jointHeldInterest = 0;
|
||||
for (const account of deductee.accounts) {
|
||||
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
|
||||
if (!isTdsApplicable(account)) {
|
||||
exemptInterest += inYear;
|
||||
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
|
||||
jointHeldInterest += inYear;
|
||||
}
|
||||
}
|
||||
|
||||
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
|
||||
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
|
||||
|
||||
rows.push({
|
||||
deductee,
|
||||
branchCode: deductee.branchCode,
|
||||
form: declarationFormFor(fy, deductee.dob),
|
||||
band: isSeniorInFy(deductee.dob, fy) ? 'SENIOR' : 'GENERAL',
|
||||
kind: formKindFor(deductee.dob, fy),
|
||||
age: ageInFy(deductee.dob, fy),
|
||||
mode,
|
||||
quarters,
|
||||
tdsApplicableInterest: schedule.totalInterest,
|
||||
exemptInterest,
|
||||
jointHeldInterest,
|
||||
threshold: schedule.threshold,
|
||||
crossed: schedule.totalInterest > schedule.threshold,
|
||||
crossedInQuarter: crossedLine?.quarter ?? null,
|
||||
headroom,
|
||||
nearThreshold: headroom > 0 && headroom <= NEAR_THRESHOLD_BAND,
|
||||
schedule,
|
||||
hasDeclaration: !!declaration,
|
||||
declarationUin: declaration?.uin ?? null,
|
||||
declarationAcceptedOn: declaration?.acceptedOn ?? null,
|
||||
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||
paidWithoutDeduction: schedule.shieldedInterest,
|
||||
panOnRecord: isWellFormedPan(deductee.pan),
|
||||
rateApplied: schedule.rateApplied
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
|
||||
rowCacheKey = fy;
|
||||
rowCache = rows;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* When tax settles for a customer whose deposits post interest on different
|
||||
* cycles. A quarterly deposit anywhere means a deduction falls due at the first
|
||||
* quarter end, so that governs.
|
||||
*/
|
||||
function overallModeFor(deductee: TdsDeductee): TdsMode {
|
||||
const applicable = deductee.accounts.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === deductee.clientId);
|
||||
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||
}
|
||||
|
||||
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
|
||||
const needle = filter.search.trim().toLowerCase();
|
||||
return tdsInterestRowsForFy(filter.fy).filter((row) => {
|
||||
if (filter.branchCode && row.branchCode !== filter.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.band && row.band !== filter.band) {
|
||||
return false;
|
||||
}
|
||||
if (filter.crossedOnly && !row.crossed) {
|
||||
return false;
|
||||
}
|
||||
if (filter.nearThresholdOnly && !row.nearThreshold) {
|
||||
return false;
|
||||
}
|
||||
// A quarter filter narrows to customers who actually had interest in it.
|
||||
if (filter.quarter && !row.quarters[filter.quarter]) {
|
||||
return false;
|
||||
}
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
const d = row.deductee;
|
||||
return (
|
||||
d.name.toLowerCase().includes(needle) ||
|
||||
d.customerId.toLowerCase().includes(needle) ||
|
||||
d.pan.toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface TdsInterestSummary {
|
||||
deductees: number;
|
||||
/** Interest on time deposits — the base the charge applies to. */
|
||||
applicableInterest: number;
|
||||
/** Savings interest, outside the section. */
|
||||
exemptInterest: number;
|
||||
totalTds: number;
|
||||
crossed: number;
|
||||
nearThreshold: number;
|
||||
declarations: number;
|
||||
tdsSaved: number;
|
||||
paidWithoutDeduction: number;
|
||||
noPan: number;
|
||||
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
|
||||
}
|
||||
|
||||
export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary {
|
||||
const rows = tdsInterestRows(filter);
|
||||
const byQuarter = ([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
] as Quarter[]).map((quarter) => ({
|
||||
quarter,
|
||||
interest: rows.reduce((s, r) => s + r.quarters[quarter], 0),
|
||||
tds: rows.reduce((s, r) => s + (r.schedule.lines.find((l) => l.quarter === quarter)?.tds ?? 0), 0)
|
||||
}));
|
||||
|
||||
return {
|
||||
deductees: rows.length,
|
||||
applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
|
||||
exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0),
|
||||
totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||
crossed: rows.filter((r) => r.crossed).length,
|
||||
nearThreshold: rows.filter((r) => r.nearThreshold).length,
|
||||
declarations: rows.filter((r) => r.hasDeclaration).length,
|
||||
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
|
||||
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
|
||||
noPan: rows.filter((r) => !r.panOnRecord).length,
|
||||
byQuarter
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────── Report 2: remittance ───────────────────────
|
||||
|
||||
export interface TdsRemittanceRow extends TdsChallan {
|
||||
/** Statutory due date has passed with nothing deposited. */
|
||||
overdue: boolean;
|
||||
/** Deposited, but after the due date. */
|
||||
late: boolean;
|
||||
/** Months or part months of delay. */
|
||||
delayMonths: number;
|
||||
/** Interest charged for depositing late. */
|
||||
defaultInterest: number;
|
||||
/** Return the quarter's deduction is reported in. */
|
||||
returnDueDate: string;
|
||||
}
|
||||
|
||||
const RETURN_DUE: Record<Quarter, string> = {
|
||||
1: '31 Jul',
|
||||
2: '31 Oct',
|
||||
3: '31 Jan',
|
||||
4: '31 May'
|
||||
};
|
||||
|
||||
/** Tax deducted per quarter, from the engine, for one year. */
|
||||
export function deductedByQuarter(fy: string): Record<number, number> {
|
||||
const out: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
for (const row of tdsInterestRowsForFy(fy)) {
|
||||
for (const line of row.schedule.lines) {
|
||||
out[line.quarter] += line.tds;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
|
||||
const deducted: Record<string, Record<number, number>> = {};
|
||||
for (const year of Object.keys(FY_TAX_CONFIG)) {
|
||||
deducted[year] = deductedByQuarter(year);
|
||||
}
|
||||
const challans = tdsChallans(deducted)[fy] ?? [];
|
||||
|
||||
return challans.map((c) => {
|
||||
const overdue = !c.depositedOn && c.dueDate < asAt;
|
||||
const late = !!c.depositedOn && c.depositedOn > c.dueDate;
|
||||
// While a payment is overdue the clock runs to today; once paid it stops on
|
||||
// the payment date.
|
||||
const settledOn = c.depositedOn ?? asAt;
|
||||
const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0;
|
||||
const defaultInterest =
|
||||
late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0;
|
||||
|
||||
return {
|
||||
...c,
|
||||
overdue,
|
||||
late,
|
||||
delayMonths,
|
||||
defaultInterest,
|
||||
returnDueDate: RETURN_DUE[c.quarter]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The year-end position — what the quarterly TDS return has to reconcile to.
|
||||
*
|
||||
* `difference` is the control figure: tax the engine says was deducted, less what
|
||||
* actually reached the Government. Anything other than zero, once the pending
|
||||
* challans are settled, means something is wrong and must be found before filing.
|
||||
*/
|
||||
export interface Tds26qSummary {
|
||||
fy: string;
|
||||
deductees: number;
|
||||
totalInterestPaid: number;
|
||||
interestWithDeduction: number;
|
||||
/** Paid gross against a declaration — a mandatory disclosure in the return. */
|
||||
interestWithoutDeduction: number;
|
||||
tdsAtStandardRate: number;
|
||||
tdsAtHigherRate: number;
|
||||
totalDeducted: number;
|
||||
totalDeposited: number;
|
||||
pendingDeposit: number;
|
||||
/** Deducted less deposited less still-pending. Must be zero. */
|
||||
difference: number;
|
||||
defaultInterest: number;
|
||||
lateChallans: number;
|
||||
overdueChallans: number;
|
||||
}
|
||||
|
||||
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
|
||||
const rows = tdsInterestRowsForFy(fy);
|
||||
const challans = tdsRemittanceRows(fy, asAt);
|
||||
const standard = rows.filter((r) => r.panOnRecord);
|
||||
const higher = rows.filter((r) => !r.panOnRecord);
|
||||
const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0);
|
||||
const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0);
|
||||
const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0);
|
||||
|
||||
return {
|
||||
fy,
|
||||
deductees: rows.length,
|
||||
totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
|
||||
interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0),
|
||||
interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
|
||||
tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||
tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||
totalDeducted,
|
||||
totalDeposited,
|
||||
pendingDeposit,
|
||||
difference: Math.round(totalDeducted - totalDeposited - pendingDeposit),
|
||||
defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0),
|
||||
lateChallans: challans.filter((c) => c.late).length,
|
||||
overdueChallans: challans.filter((c) => c.overdue).length
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user