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
@@ -13,12 +13,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
TdsAccountLine,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
@@ -40,7 +40,12 @@ export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
/**
* Office names the loaded customers belong to, for Head Office's branch selector. Read off the
* data rather than a fixed list, which named branches this bank does not have and left out the
* ones it does.
*/
branches: string[] = [];
/** Filled by the route resolver from Organization › TDS Configuration. */
readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
@@ -92,16 +97,26 @@ export class TdsInterestReportComponent implements OnInit {
loading = false;
loadError = '';
/** The "how to read this page" panel. */
showHelp = true;
readonly accountTypeLabel: Record<TdsAccountLine['type'], string> = {
FD: 'Fixed deposit',
RD: 'Recurring deposit',
SB: 'Savings',
OTHER: 'Other'
};
ngOnInit(): void {
if (!this.financialYears.length) {
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
return;
}
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
// A branch login sees only its own book and cannot widen the scope. The platform labels each
// customer with their office's name, so the login's office name is the filter — the GL branch
// code list does not know every office and turned a branch login into an empty report.
if (!this.scope.isHeadOffice) {
this.filter.branchCode = this.scope.officeName;
}
this.loadDeductees();
}
@@ -121,6 +136,7 @@ export class TdsInterestReportComponent implements OnInit {
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.branches = this.branchesOf(deductees);
this.invalidateCaches();
},
error: (err: any) => {
@@ -138,6 +154,11 @@ export class TdsInterestReportComponent implements OnInit {
* only the summary left the KPIs counting the new data while the tables kept the empty list
* cached on the first render, before the load returned.
*/
private branchesOf(deductees: TdsDeductee[]): string[] {
const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n));
return [...names].sort((a, b) => a.localeCompare(b));
}
private invalidateCaches(): void {
this.rowsCacheKey = '';
this.summaryCacheKey = '';
@@ -168,12 +189,54 @@ export class TdsInterestReportComponent implements OnInit {
};
}
/** The limits and rates for the selected year, for the help panel. */
get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null {
const cfg = FY_TAX_CONFIG[this.filter.fy];
if (!cfg) {
return null;
}
return {
general: cfg.thresholdGeneral,
senior: cfg.thresholdSenior,
rate: cfg.rate * 100,
rateNoPan: cfg.rateNoPan * 100
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches';
}
/**
* One plain-language status per customer, so a reader does not have to work it out from the
* threshold, crossed and saved columns together.
*/
statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } {
if (!row.trackedAccounts) {
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
}
if (row.schedule.totalTds > 0) {
return { label: 'Over the limit — TDS due', tone: 'warn' };
}
if (row.hasDeclaration && row.crossed) {
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
}
if (row.nearThreshold) {
return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' };
}
return { label: 'Below the limit', tone: 'ok' };
}
trackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => a.tracked);
}
untrackedOf(row: TdsInterestRow): TdsAccountLine[] {
return row.accounts.filter((a) => !a.tracked);
}
/** Recomputes the book only when the filter actually changed. Side-effect free. */
@@ -315,10 +378,6 @@ export class TdsInterestReportComponent implements OnInit {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
@@ -337,6 +396,9 @@ export class TdsInterestReportComponent implements OnInit {
'CIF',
'PAN',
'Branch',
'Accounts tracked for TDS',
'Accounts not tracked',
'Status',
'Form',
'Band',
'Age',
@@ -361,7 +423,14 @@ export class TdsInterestReportComponent implements OnInit {
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.branchCode,
this.trackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.untrackedOf(r)
.map((a) => `${a.accountNo} (${a.type})`)
.join(', '),
this.statusOf(r).label,
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
@@ -389,6 +458,9 @@ export class TdsInterestReportComponent implements OnInit {
`${s.deductees} deductees`,
'',
'',
`${s.trackedCustomers} tracked`,
'',
'',
'',
'',
'',