feat: interest posting

This commit is contained in:
2026-10-05 12:39:14 +05:30
parent 1ffac4b1e5
commit 4fc6784695
10 changed files with 915 additions and 189 deletions
+74 -4
View File
@@ -56,6 +56,25 @@ export interface TdsReportFilter {
nearThresholdOnly: boolean;
}
/**
* One of the customer's accounts as the report sees it: whether its interest is being counted
* toward the deduction, and if not, why not. A customer can hold savings, fixed and recurring
* accounts together, and the branch has to see which of them the deduction is actually watching.
*/
export interface TdsAccountLine {
accountNo: string;
type: 'FD' | 'RD' | 'SB' | 'OTHER';
/** True when the account's interest counts toward this customer's threshold. */
tracked: boolean;
/** Plain-language reason, shown beside the account. */
reason: string;
/** Interest credited in the year. */
interest: number;
/** How many interest postings made up that figure. */
postings: number;
lastPostedOn: string | null;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
@@ -101,6 +120,10 @@ export interface TdsInterestRow {
paidWithoutDeduction: number;
panOnRecord: boolean;
rateApplied: number;
/** Every account the customer holds, tracked or not. */
accounts: TdsAccountLine[];
/** How many of them count toward the deduction. */
trackedAccounts: number;
}
function emptyQuarters(): Record<Quarter, number> {
@@ -154,14 +177,35 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
let exemptInterest = 0;
let jointHeldInterest = 0;
const accountLines: TdsAccountLine[] = [];
for (const account of deductee.accounts) {
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
const yearCredits = account.credits.filter((c) => fyOf(c.date) === fy);
const inYear = yearCredits.reduce((s, c) => s + c.amount, 0);
const type = accountTypeOf(account.depositTypeCode);
let tracked = false;
let reason: string;
if (!isTdsApplicable(account)) {
exemptInterest += inYear;
reason = type === 'SB' ? 'Savings account — no TDS on savings interest' : 'Not a time deposit — no TDS';
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
jointHeldInterest += inYear;
reason = 'Joint deposit — counted under the first-named holder';
} else {
tracked = true;
reason = 'Counted toward the TDS limit';
}
accountLines.push({
accountNo: account.accountNo || '',
type,
tracked,
reason,
interest: inYear,
postings: yearCredits.length,
lastPostedOn: yearCredits.reduce<string | null>((last, c) => (!last || c.date > last ? c.date : last), null)
});
}
// Tracked accounts first, so the deposits that drive the figure lead the list.
accountLines.sort((a, b) => Number(b.tracked) - Number(a.tracked));
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
@@ -190,7 +234,9 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied
rateApplied: schedule.rateApplied,
accounts: accountLines,
trackedAccounts: accountLines.filter((a) => a.tracked).length
});
}
@@ -210,10 +256,29 @@ function overallModeFor(deductee: TdsDeductee): TdsMode {
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
function accountTypeOf(code: string | undefined): TdsAccountLine['type'] {
const c = (code || '').toLowerCase();
if (c.includes('fixeddeposit')) {
return 'FD';
}
if (c.includes('recurringdeposit')) {
return 'RD';
}
if (c.includes('savings')) {
return 'SB';
}
return 'OTHER';
}
/** Branches are compared by office name, case and spacing aside — the platform and the session both carry the name. */
function sameBranch(a: string, b: string): boolean {
return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
}
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) {
if (filter.branchCode && !sameBranch(row.branchCode, filter.branchCode)) {
return false;
}
if (filter.band && row.band !== filter.band) {
@@ -254,6 +319,8 @@ export interface TdsInterestSummary {
tdsSaved: number;
paidWithoutDeduction: number;
noPan: number;
/** Customers holding at least one account whose interest counts toward the deduction. */
trackedCustomers: number;
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
}
@@ -280,7 +347,10 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
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,
// Only customers with a deposit the deduction watches: a savings-only customer without a PAN
// is never deducted at the higher rate, so counting them overstated the problem.
noPan: rows.filter((r) => !r.panOnRecord && r.trackedAccounts > 0).length,
trackedCustomers: rows.filter((r) => r.trackedAccounts > 0).length,
byQuarter
};
}