feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
@@ -80,7 +80,7 @@
<div class="tdsr__filters">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="filter.fy" (ngModelChange)="onFilterChanged()">
<select [(ngModel)]="filter.fy" (ngModelChange)="onYearChanged()">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
@@ -15,7 +15,8 @@ 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 { TDS_FYS } from './tds-reports-data';
import { setTdsDeductees, TDS_FYS } from './tds-reports-data';
import { TdsReportsService } from './tds-reports.service';
import {
NEAR_THRESHOLD_BAND,
TdsInterestRow,
@@ -37,6 +38,7 @@ import {
})
export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
readonly financialYears = TDS_FYS;
@@ -87,12 +89,54 @@ export class TdsInterestReportComponent implements OnInit {
private summaryCacheKey = '';
private cachedSummary: TdsInterestSummary | null = null;
loading = false;
loadError = '';
ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
}
this.loadDeductees();
}
/**
* Loads the interest the bank actually credited for the selected year.
*
* Everything below computes on whatever `setTdsDeductees` holds, so the load is the only place
* that knows where the figures come from.
*/
loadDeductees(): void {
this.loading = true;
this.loadError = '';
// 'FY 2026-27' → 2026, the opening calendar year the platform filters on.
const openingYear = Number((this.filter.fy.match(/(\d{4})/) || [])[1]) || undefined;
this.tdsService.getDepositInterest(openingYear).subscribe({
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.summaryCacheKey = '';
this.cachedSummary = null;
},
error: (err: any) => {
this.loading = false;
setTdsDeductees([]);
this.summaryCacheKey = '';
this.cachedSummary = null;
this.loadError =
err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.';
}
});
}
/**
* A different year needs a different set of interest credits, so the year re-reads from the
* platform rather than re-filtering the set already in hand.
*/
onYearChanged(): void {
this.onFilterChanged();
this.loadDeductees();
}
/**
+21 -166
View File
@@ -145,178 +145,33 @@ function makePan(rand: () => number, surname: string): string {
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
}
let deducteeCache: TdsDeductee[] | null = null;
/**
* The bank's deductees.
* The deductees the reports run on, loaded from the platform.
*
* The spread is deliberate so both reports have every case in them: customers
* comfortably under the trigger, customers sitting just below it, customers well
* over, senior citizens on the higher threshold, a few with no PAN on record
* (deducted at the 206AA rate), some joint deposits held as second holder, and
* some declarations that only came in mid-year.
* This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all.
* The computation over them is the real section 194A calculation, so the figures looked like a
* filing and were not one: not a rupee of it came from the bank's own books.
*
* It is set once by whoever loads the screen and read by the pure computation below, which is why
* this is a module-level holder rather than a service the compute would have to inject.
*/
export function tdsDeductees(): TdsDeductee[] {
if (deducteeCache) {
return deducteeCache;
}
const rand = lcg(20260801);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: TdsDeductee[] = [];
let liveDeductees: TdsDeductee[] = [];
for (let i = 0; i < 64; i++) {
const branch = branches[i % branches.length];
const first = pick(rand, FIRST_NAMES);
const last = pick(rand, SURNAMES);
const clientId = 5000 + i;
// A third of the book is senior citizens, which is about right for a
// co-operative bank's deposit base.
const senior = rand() < 0.34;
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
// Every seventeenth customer has no PAN on record, so tax comes off at the
// higher section 206AA rate. Fixed by index rather than drawn at random, so
// the case is always present.
const hasPan = i % 17 !== 3;
// Aim each customer's annual time-deposit interest at a band, so the report
// has a proper spread either side of the trigger.
const draw = rand();
let annualInterest: number;
const limit = senior ? 100_000 : 50_000;
if (draw < 0.3) {
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
} else if (draw < 0.45) {
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
} else if (draw < 0.8) {
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
} else {
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
}
// Push two of the no-PAN customers over the trigger. Left to chance they can
// all land under it, and then the 20% line of the return summary comes out
// empty — a report line that is never exercised is one nobody has checked.
if (!hasPan && (i === 3 || i === 37)) {
annualInterest = 70_000 + i * 400;
}
const accounts: TdsAccountInput[] = [];
const acctBase = 41_00_000 + i * 137;
// One or two time deposits, split across the year's quarter ends.
const depositCount = rand() < 0.4 ? 2 : 1;
for (let d = 0; d < depositCount; d++) {
const isRd = rand() < 0.3;
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
const credits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
// The current year is only part-run, so later quarters have no postings.
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
if (!ends.length) {
continue;
}
// The prior year ran in full; scale the current year to what has posted.
const perQuarter = Math.round((annualInterest * share) / 4);
for (const date of ends) {
credits.push({ date, amount: perQuarter });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: {
// A fifth of the book is on annual posting, so tax on those settles
// once at year end rather than quarterly.
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
},
credits
});
}
// Most customers also hold a savings account. Its interest must never reach
// the deduction aggregate — that is the point of including it here.
if (rand() < 0.75) {
const sbCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.savingsDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: sbCredits
});
}
// One in eight is the second holder on somebody else's deposit. Tax there is
// deducted against the first-named holder, so it must not land on this
// customer's aggregate.
if (rand() < 0.12) {
const jointCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
jointCredits.push({ date, amount: 16_000 });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'JOINT',
// Somebody else is first-named.
primaryClientId: clientId + 1,
clientId: clientId + 1,
jointHolders: [{ clientId }],
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: jointCredits
});
}
// Declarations. Only worth filing where tax would otherwise be deducted, so
// they cluster on the customers over the trigger.
const declarations: Record<string, Declaration> = {};
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
const kind: Form15Kind = senior ? '15H' : '15G';
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
// A quarter of them only came in mid-year, which leaves tax already
// deducted that the branch cannot reverse.
const midYear = rand() < 0.25;
declarations[fy] = {
kind,
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
fy,
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
};
}
}
out.push({
clientId,
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
name: `${first} ${last}`,
pan: hasPan ? makePan(rand, last) : '',
dob,
branchCode: branch.code,
declarations,
accounts
});
}
deducteeCache = out;
return out;
export function setTdsDeductees(deductees: TdsDeductee[]): void {
liveDeductees = deductees || [];
// The row cache in the compute is keyed on the financial year alone, so it has to be told that
// the underlying set changed or it would serve rows built from the previous load.
tdsDeducteesVersion++;
}
/** Bumped on every load, so memoised rows can tell one data set from the next. */
export let tdsDeducteesVersion = 0;
export function tdsDeductees(): TdsDeductee[] {
return liveDeductees;
}
/**
* Statutory date by which tax deducted in a given month must be deposited.
*
+6 -3
View File
@@ -35,7 +35,7 @@ import {
tdsDeducteeOf,
tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data';
// ─────────────────────────────── Filtering ───────────────────────────────
@@ -116,7 +116,10 @@ let rowCacheKey = '';
let rowCache: TdsInterestRow[] = [];
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
if (rowCacheKey === fy) {
// Keyed on the data set as well as the year: the rows are memoised, and a fresh load would
// otherwise keep serving the previous customer's figures under the same financial year.
const key = fy + '|' + tdsDeducteesVersion;
if (rowCacheKey === key) {
return rowCache;
}
@@ -190,7 +193,7 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
}
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
rowCacheKey = fy;
rowCacheKey = key;
rowCache = rows;
return rows;
}
@@ -0,0 +1,91 @@
/**
* 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/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
import { TdsDeductee } from './tds-reports-data';
/**
* Deposit interest for the TDS reports.
*
* The platform returns the interest it actually credited — nothing else. A deposit is the
* customer's own money arriving and a fee is a charge, so neither is income and neither is sent.
* Working the figure out here from a balance and a rate would produce a number the passbook does
* not show.
*/
@Injectable({ providedIn: 'root' })
export class TdsReportsService {
private http = inject(HttpClient);
/**
* @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027.
* Omit to let the platform use the year the business date falls in.
*/
getDepositInterest(financialYear?: number): Observable<TdsDeductee[]> {
const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined;
return this.http
.get<any[]>('/tds/deposit-interest', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r))));
}
/**
* Shapes one platform row into what the section 194A computation expects.
*
* Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation
* compares them as strings when deciding which financial year a credit falls in.
*/
private toDeductee(row: any): TdsDeductee {
return {
clientId: row.clientId,
customerId: row.customerId || '',
name: row.name || '',
pan: row.pan || '',
dob: toIsoDate(row.dateOfBirth),
branchCode: row.branchName || '',
// A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform
// carries the kind but not the acceptance date, so the year's start stands in — a
// declaration is operative for the year it was given, which is what the computation needs.
declarations: this.declarationsOf(row),
accounts: (row.accounts || []).map((a: any) => ({
id: a.id,
accountNo: a.accountNo || '',
depositTypeCode: a.depositTypeCode || '',
accountStatus: a.accountStatus || undefined,
clientId: a.clientId ?? row.clientId,
credits: (a.credits || []).map((c: any) => ({
date: toIsoDate(c.date),
amount: Number(c.amount) || 0,
accountNo: c.accountNo || a.accountNo || ''
}))
}))
};
}
private declarationsOf(row: any): Record<string, any> {
const kind = (row.form15Type || '').trim().toUpperCase();
if (kind !== '15G' && kind !== '15H') {
return {};
}
const out: Record<string, any> = {};
// Applied to the years the reports can show. Without an acceptance date on the record the
// declaration is treated as operative from the start of each year rather than mid-year.
for (const year of [
2025,
2026
]) {
const fy = `FY ${year}-${String(year + 1).slice(2)}`;
out[fy] = { kind, acceptedOn: `${year}-04-01`, fy };
}
return out;
}
}