From 89bb76455e4f5c1687ff576b8b3b23568f770a3a Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 6 Aug 2026 15:12:06 +0530 Subject: [PATCH] fix: restore content silently dropped from the dev merge (KYC exception, TDS reports, Form 121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo-backup merge's git 3-way auto-resolve dropped several hunks without flagging conflicts, wherever demo had independently touched adjacent lines: the KYC Exception feature (client-kyc-step, kyc-tab, create-client, clients.service — checkbox, derivation logic, banners, contact-log endpoints), TDS report routes/menu links (reports-routing, shell), the Form 121 client tab (clients-routing, clients-view), the kyc-management exceptions dashboard, and the Form 121/15G-H register's real-data view integration. Restored by diffing demo-backup against dev per file and re-applying each genuine gap while preserving demo-backup's own independent work (Approval Center tab style, Loan Origination/Investment menus, form-97-98/risk-review report routes). --- .../client-general-step.component.ts | 32 ++ .../client-kyc-step.component.html | 47 ++ .../client-kyc-step.component.ts | 57 +- src/app/clients/clients-routing.module.ts | 6 + .../clients-view/clients-view.component.html | 9 + .../kyc-tab/kyc-tab.component.html | 52 ++ .../clients-view/kyc-tab/kyc-tab.component.ts | 216 ++++--- src/app/clients/clients.service.ts | 43 ++ .../create-client/create-client.component.ts | 38 +- src/app/core/shell/shell.component.html | 14 + .../kyc-management.component.html | 528 ++++++++++++++++++ .../kyc-management.component.scss | 79 +++ .../kyc-management.component.ts | 441 ++++++++++++++- .../certificates/certificates.shared.scss | 69 +++ .../form-15gh/form-15gh.component.html | 471 +++++++++++----- .../form-15gh/form-15gh.component.ts | 338 ++++++++--- src/app/reports/reports-routing.module.ts | 12 + 17 files changed, 2136 insertions(+), 316 deletions(-) diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 9d76fa2f7..5e59781f2 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -966,6 +966,38 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.linkMonthlyAnnual('monthlyIncome', 'annualIncome'); this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover'); + this.linkIncomeToTurnover(); + } + + /** + * Auto-calculates turnover based on income (doubling monthly income for monthly turnover, + * and annual income for annual/yearly turnover). + */ + private linkIncomeToTurnover(): void { + const monthlyIncome = this.createClientForm.get('monthlyIncome'); + const monthlyTurnover = this.createClientForm.get('monthlyTurnover'); + const annualIncome = this.createClientForm.get('annualIncome'); + const annualTurnover = this.createClientForm.get('annualTurnover'); + + if (monthlyIncome && monthlyTurnover) { + monthlyIncome.valueChanges.subscribe((value) => { + if (this.suppressMonthlyAnnualLink) { + return; + } + const num = parseFloat(value); + monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true }); + }); + } + + if (annualIncome && annualTurnover) { + annualIncome.valueChanges.subscribe((value) => { + if (this.suppressMonthlyAnnualLink) { + return; + } + const num = parseFloat(value); + annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true }); + }); + } } /** diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index b8644c83a..db335767e 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -723,6 +723,53 @@ PAN verification requires client to be created first. Complete General tab and click Next to save. } + + +
+
+ + KYC Exception Flag +
+
+ Mark as KYC Exception + + @if (kycForm.get('isException')?.value) { + + Exception Reasons + + @if (!isNonIndividual) { + PAN Card Verification Failed + Aadhaar Verification Failed + } @else { + Enterprise PAN Verification Failed + GSTIN Verification Failed + CIN Verification Failed + } + + + } +
+
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 708188704..d708f7135 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; +import { deriveKycExceptionFields } from '../../kyc-exception.util'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component'; @@ -152,7 +153,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { kycDocumentName: 'KYC Document Type', kycDocumentNumber: 'KYC Document Number', amlCheckStatus: 'AML Check Status', - bannedEntityMatch: 'Banned Entity List Match' + bannedEntityMatch: 'Banned Entity List Match', + remarks: 'Reason for Re-edit' }; /** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value @@ -274,6 +276,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { riskCalculationReason = ''; showRiskRulesTable = false; + /** + * Exception state as last read back from the server. Needed to tell "this client never had an + * exception" from "this client had one and has now cleared it" — only the latter should be + * stamped resolved and surface in the Resolved tab. + */ + persistedIsException = false; + persistedExceptionReason: string | null = null; + /** PAN verification */ panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE'; panVerifying = false; @@ -423,8 +433,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { aadhaarNumber: [ '', [ - Validators.pattern(/^\d{12}$/), - Validators.maxLength(12) + Validators.pattern(/^(\d{12}|[X•*\s\d]{12,14})$/), + Validators.maxLength(14) ] ], panAadhaarSeedingStatus: [''], @@ -468,6 +478,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { allowRiskOverride: [false], allowAmlOverride: [false], remarks: [''], + isException: [false], + exceptionReason: [[]], amlCheckStatus: [ 'CLEAR', Validators.required @@ -610,8 +622,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { dormantFlag: 'ACTIVE', accountStatus: 'ACTIVE', form60Type: 'FORM_60', - form15Type: '15G' + form15Type: '15G', + isException: false, + exceptionReason: [] }); + this.persistedIsException = false; + this.persistedExceptionReason = null; this.kycId = ''; this.kycLoaded = false; this.kycStatus = 0; @@ -942,8 +958,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form60Date: parseDate(data.form60Date), form15Applicable: data.form15Applicable || false, form15Type: data.form15Type || '15G', - form15FY: data.form15FY || '' + form15FY: data.form15FY || '', + isException: data.isException || false, + exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); + // Set remarks as required if KYC is verified + if (this.isKycVerified) { + this.kycForm.get('remarks')?.setValidators(Validators.required); + this.kycForm.get('remarks')?.updateValueAndValidity(); + this.kycForm.get('remarks')?.markAsTouched(); // Force red underline so user knows it's blocking + } + this.persistedIsException = data.isException === true; + this.persistedExceptionReason = data.exceptionReason || null; // Sync chip-picker selection with the prefilled document name if (data.kycDocumentName) { this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null; @@ -1843,7 +1869,23 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form15Applicable: v.form15Applicable || false, form15Type: v.form15Type || null, form15FY: v.form15FY || null, - remarks: v.remarks || null + remarks: v.remarks || null, + // Exception state is DERIVED from verification status, not from a checkbox — an unverified + // or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag + // it. See deriveKycExceptionFields() for the raise / narrow / close rules. + ...deriveKycExceptionFields({ + panVerified: this.isNonIndividual + ? this.entPanVerifyStatus === 'VERIFIED' + : this.panVerifyStatus === 'VERIFIED', + aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED', + // Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every + // company with a permanent exception. + aadhaarApplicable: !this.isNonIndividual, + hadException: this.persistedIsException, + previousReason: this.persistedExceptionReason, + manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [], + todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat) + }) }; } @@ -1856,7 +1898,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { v.enterprisePan || v.cinNumber || v.kycDocumentName || - v.form15Applicable + v.form15Applicable || + v.isException ); } diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index 60dc5b155..d1d486fe8 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component'; import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component'; import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component'; +import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component'; import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component'; import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard'; @@ -193,6 +194,11 @@ const routes: Routes = [ component: Form60TabComponent, data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false } }, + { + path: 'form121', + component: Form121TabComponent, + data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false } + }, { path: 'documents', component: DocumentsTabComponent, diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 2bc58ab72..b14258f63 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -545,6 +545,15 @@ > {{ 'labels.inputs.Form 60' | translate }} + + Form 121 / TDS + @if (!isEditMode && kycData) { + @if (kycData.isException && !kycData.exceptionResolved) { +
+ warning +
+ KYC Exception Active +

+ Account is open under a temporary KYC Exception. Reason: + {{ kycData.exceptionReason || 'Other' }}. Please contact the customer and complete verification as soon as possible. +

+
+
+ } +
@@ -740,6 +766,32 @@ + +
+ Mark as KYC Exception + + @if (kycForm.get('isException')?.value) { + + Exception Reasons + + @if (!isNonIndividual) { + PAN Card Verification Failed + Aadhaar Verification Failed + } @else { + Enterprise PAN Verification Failed + GSTIN Verification Failed + CIN Verification Failed + } + + + } +
+ {{ 'labels.inputs.Remarks' | translate }} diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts index 2c8e5cef2..4ad0cee36 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts @@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs'; /** Custom Services */ import { ClientsService } from '../../clients.service'; +import { deriveKycExceptionFields } from '../../kyc-exception.util'; import { Dates } from 'app/core/utils/dates'; import { jsPDF } from 'jspdf'; import { MatIcon } from '@angular/material/icon'; @@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy { '', [Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)] ], - remarks: [''] + remarks: [''], + isException: [false], + exceptionReason: [[]] }); ngOnInit(): void { @@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy { // the full 12-digit number to re-verify, so don't pre-fill. aadhaarNumber: '', gstinNumber: data.gstinNumber || '', - remarks: data.remarks || '' + remarks: data.remarks || '', + isException: data.isException || false, + exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] }); } }); @@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy { } } + /** + * Exception columns derived from verification status — see `deriveKycExceptionFields()`. + * + * A field counts as verified if it was verified in this session OR is already verified on the + * stored record; either way the customer has cleared that check. Verifying one of two + * outstanding items narrows the exception to the remaining one rather than closing it. + */ + private buildExceptionFields() { + const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; + const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; + + return deriveKycExceptionFields({ + panVerified, + aadhaarVerified, + aadhaarApplicable: !this.isNonIndividual, + hadException: this.kycData?.isException === true, + previousReason: this.kycData?.exceptionReason || null, + manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [], + todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy') + }); + } + saveKyc(): void { if (this.kycForm.invalid) { return; @@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy { panNumber: this.kycForm.value.panNumber || null, aadhaarNumberMasked: aadhaarMasked, gstinNumber: this.kycForm.value.gstinNumber || null, - remarks: this.kycForm.value.remarks || null + remarks: this.kycForm.value.remarks || null, + // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. + dateFormat: 'dd MMMM yyyy', + ...this.buildExceptionFields() }; if (!this.kycId) { @@ -244,6 +274,38 @@ export class KycTabComponent implements OnInit, OnDestroy { }); } + private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void { + if (this.isEditMode) { + const aadhaarRaw = this.kycForm.value.aadhaarNumber || ''; + const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null; + const payload: any = { + locale: 'en', + panNumber: this.kycForm.value.panNumber || null, + aadhaarNumberMasked: aadhaarMasked, + gstinNumber: this.kycForm.value.gstinNumber || null, + remarks: this.kycForm.value.remarks || null, + riskCategory: this.kycForm.value.riskCategory || 1, + dateFormat: 'dd MMMM yyyy', + ...this.buildExceptionFields() + }; + + this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({ + next: () => { + this.loadKycData(); + callback(); + }, + error: (err: any) => { + this.verifying[field] = false; + this.verificationStatus[field] = 'FAILED'; + this.verificationError[field] = + err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`; + } + }); + } else { + callback(); + } + } + verifyPAN(): void { if (!this.kycId) { this.verificationError.pan = 'KYC record not found. Save KYC details first.'; @@ -254,31 +316,33 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.pan = null; this.verificationDetails.pan = null; - this.clientsService - .verifyPAN(this.clientId, this.kycId) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.verifying.pan = false; - if (response?.changes?.panVerified) { - this.verificationStatus.pan = 'VERIFIED'; - this.verificationDetails.pan = response.changes; - this.loadKycData(); - } else { + this.saveKycBeforeVerify(() => { + this.clientsService + .verifyPAN(this.clientId, this.kycId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: any) => { + this.verifying.pan = false; + if (response?.changes?.panVerified) { + this.verificationStatus.pan = 'VERIFIED'; + this.verificationDetails.pan = response.changes; + this.loadKycData(); + } else { + this.verificationStatus.pan = 'FAILED'; + this.verificationError.pan = 'PAN verification failed.'; + } + }, + error: (err: any) => { + this.verifying.pan = false; this.verificationStatus.pan = 'FAILED'; - this.verificationError.pan = 'PAN verification failed.'; + this.verificationError.pan = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'PAN verification failed. Please try again.'; } - }, - error: (err: any) => { - this.verifying.pan = false; - this.verificationStatus.pan = 'FAILED'; - this.verificationError.pan = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'PAN verification failed. Please try again.'; - } - }); + }); + }, 'pan'); } /** Show Aadhaar input in summary view for re-verification */ @@ -305,31 +369,33 @@ export class KycTabComponent implements OnInit, OnDestroy { this.aadhaarOtpSent = false; this.aadhaarOtp = ''; - this.clientsService - .sendAadhaarOtp(this.clientId, this.kycId, aadhaar) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.verifying.aadhaar = false; - const refNumber = - response?.changes?.aadhaarRefNumber || - response?.aadhaarRefNumber || - response?.changes?.referenceNumber || - response?.resourceId; - this.aadhaarOtpSent = true; - this.aadhaarOtpReferenceId = refNumber || null; - this.verificationStatus.aadhaar = 'PENDING'; - }, - error: (err: any) => { - this.verifying.aadhaar = false; - this.verificationStatus.aadhaar = 'FAILED'; - this.verificationError.aadhaar = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'Failed to send OTP. Please try again.'; - } - }); + this.saveKycBeforeVerify(() => { + this.clientsService + .sendAadhaarOtp(this.clientId, this.kycId, aadhaar) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: any) => { + this.verifying.aadhaar = false; + const refNumber = + response?.changes?.aadhaarRefNumber || + response?.aadhaarRefNumber || + response?.changes?.referenceNumber || + response?.resourceId; + this.aadhaarOtpSent = true; + this.aadhaarOtpReferenceId = refNumber || null; + this.verificationStatus.aadhaar = 'PENDING'; + }, + error: (err: any) => { + this.verifying.aadhaar = false; + this.verificationStatus.aadhaar = 'FAILED'; + this.verificationError.aadhaar = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'Failed to send OTP. Please try again.'; + } + }); + }, 'aadhaar'); } /** Step 2: Verify OTP entered by user */ @@ -380,31 +446,33 @@ export class KycTabComponent implements OnInit, OnDestroy { this.verificationError.gstin = null; this.verificationDetails.gstin = null; - this.clientsService - .verifyGSTIN(this.clientId, this.kycId) - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: any) => { - this.verifying.gstin = false; - if (response?.changes?.gstinVerified) { - this.verificationStatus.gstin = 'VERIFIED'; - this.verificationDetails.gstin = response.changes; - this.loadKycData(); - } else { + this.saveKycBeforeVerify(() => { + this.clientsService + .verifyGSTIN(this.clientId, this.kycId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: any) => { + this.verifying.gstin = false; + if (response?.changes?.gstinVerified) { + this.verificationStatus.gstin = 'VERIFIED'; + this.verificationDetails.gstin = response.changes; + this.loadKycData(); + } else { + this.verificationStatus.gstin = 'FAILED'; + this.verificationError.gstin = 'GSTIN verification failed.'; + } + }, + error: (err: any) => { + this.verifying.gstin = false; this.verificationStatus.gstin = 'FAILED'; - this.verificationError.gstin = 'GSTIN verification failed.'; + this.verificationError.gstin = + err?.error?.developerMessage || + err?.error?.defaultUserMessage || + err?.message || + 'GSTIN verification failed. Please try again.'; } - }, - error: (err: any) => { - this.verifying.gstin = false; - this.verificationStatus.gstin = 'FAILED'; - this.verificationError.gstin = - err?.error?.developerMessage || - err?.error?.defaultUserMessage || - err?.message || - 'GSTIN verification failed. Please try again.'; - } - }); + }); + }, 'gstin'); } formatDate(date: any): string { diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 14a2efc49..b230aa19e 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -123,6 +123,20 @@ export class ClientsService { return this.http.post(`/clients/${clientId}/edit/reject`, { rejectReason: rejectReason || '' }); } + /** Fetches the exact email content (subject, body, sentAt, openedAt) for a given pending edit. + * Only populated after the Checker has approved and the send succeeded. */ + getClientEditEmailPreview(clientId: string | number, pendingEditId: string | number) { + return this.http.get<{ + emailSubject: string | null; + emailBody: string | null; + emailSent: boolean; + emailSentAt: string | null; + emailOpenedAt: string | null; + emailFrom: string | null; + emailTo: string | null; + }>(`/clients/${clientId}/edit/${pendingEditId}/email-preview`); + } + getClientDataAndTemplate(clientId: string) { const httpParams = new HttpParams().set('template', 'true').set('staffInSelectedOfficeOnly', 'true'); return this.http.get(`/clients/${clientId}`, { params: httpParams }); @@ -958,4 +972,33 @@ export class ClientsService { dispatchWelcomeKit(clientId: string | number, data: any): Observable { return this.http.post(`/clients/${clientId}/welcomekit`, data); } + + updateWelcomeKitStatus(welcomeKitId: number, status: string): Observable { + return this.http.put(`/welcomekit/${welcomeKitId}`, { status }); + } + + getKycExceptions(resolved = false): Observable { + return this.http.get(`/kyc/exceptions?resolved=${resolved}`); + } + + /** + * Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and + * WHATSAPP channels — actually transmits the notice. + * + * The destination is resolved server-side from the client record; it is deliberately not part of + * this payload, so a tampered request cannot redirect a KYC notice elsewhere. + * + * Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the + * response rather than assuming success, since the log row is written either way. + */ + saveKycContactLog( + clientId: string | number, + data: { channel: string; notes: string; officerName?: string } + ): Observable { + return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data); + } + + getKycContactLog(clientId: string | number): Observable { + return this.http.get(`/kyc/exceptions/${clientId}/contact-log`); + } } diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 52535f087..2f144a5fe 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -430,8 +430,22 @@ export class CreateClientComponent { } this.validationError = ''; // Save KYC - if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { - this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} }); + if (this.clientKycStep?.hasKycData) { + if (!this.clientKycStep.kycId) { + this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ + next: (res: any) => { + if (this.clientKycStep) { + this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || ''; + } + }, + error: () => {} + }); + } else { + const { kycType, ...updatePayload } = this.clientKycStep.kycData; + this.clientsService + .updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload) + .subscribe({ error: () => {} }); + } } // Upload photo & signature if (this.clientKycStep?.photographFile) { @@ -696,12 +710,20 @@ export class CreateClientComponent { // Persist the KYC document picker selection + uploaded files as a client identifier this.saveKycDocProof(clientId); - // Save KYC only if it wasn't already created during the KYC step - if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { - this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ - next: () => navigateToClient(), - error: () => navigateToClient() - }); + // Save/update KYC to ensure latest exception fields are persisted + if (this.clientKycStep?.hasKycData) { + if (!this.clientKycStep.kycId) { + this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ + next: () => navigateToClient(), + error: () => navigateToClient() + }); + } else { + const { kycType, ...updatePayload } = this.clientKycStep.kycData; + this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({ + next: () => navigateToClient(), + error: () => navigateToClient() + }); + } } else { navigateToClient(); } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 3ebc470d8..75515e868 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -618,6 +618,12 @@ + + + @if (activeSection === 'exceptions') { +
+
+ KYC Exceptions Dashboard +
+ +
+
+ +
+
+
{{ exceptionRows.length }}
+
Pending Exceptions
+
Immediate attention
+
+
+
+ {{ notYetContactedCount }} +
+
Not Yet Contacted
+
Action required
+
+
+
+ {{ panMismatchesCount }} +
+
PAN Mismatches
+
Requires physical review
+
+
+
+ {{ aadhaarFailuresCount }} +
+
Aadhaar Failures
+
Re-KYC OTP needed
+
+
+ +
+ +
+
+
+ +
+
Exceptions Register
+
+ +
+ + +
+
+ + + @if (activeExceptionSubTab === 'pending') { + @if (pendingExceptions.length === 0) { +
+
🎉
+
All exceptions resolved!
+
+ There are no active client KYC exceptions on record. +
+
+ } @else { + + + + + + + + + + + + + + @for (c of pendingExceptions; track c.id) { + + + + + + + + + + } + +
Client / AccountBranchException Reason & DateRiskContact StatusContact LogsActions
+
+ {{ c.name }} +
+
{{ c.account }}
+
{{ c.contact.branch }} +
{{ c.exceptionReason }}
+
Opened: {{ c.exceptionDate }}
+
+ {{ c.riskLabel }} + + @if (c.contactHistory && c.contactHistory.length > 0) { + Contacted +
+ Last: {{ c.contactHistory[c.contactHistory.length - 1].date }} +
+ } @else { + Not Contacted + } +
+ @if (c.contactHistory && c.contactHistory.length > 0) { +
+ @for (log of c.contactHistory; track $index) { +
+ {{ log.date }} ({{ log.action }}): {{ log.notes }} + — {{ log.officer }} + @if (log.platformSent) { + @if (log.delivered) { + Sent {{ log.destinationMasked }} + } @else { + Not sent + } + } +
+ } +
+ } @else { + No logs recorded + } +
+
+ + +
+
+ } + } + + + @if (activeExceptionSubTab === 'resolved') { + @if (resolvedExceptions.length === 0) { +
+
📋
+
+ No resolved exceptions +
+
+ Resolved KYC exceptions will appear here once verification is completed. +
+
+ } @else { + + + + + + + + + + + + + @for (c of resolvedExceptions; track c.id) { + + + + + + + + + } + +
Client / AccountBranchException Reason & ResolutionRiskContact LogsStatus
+
+ {{ c.name }} +
+
{{ c.account }}
+
{{ c.contact.branch }} +
+ {{ c.exceptionReason }} +
+
Resolved On: {{ c.exceptionResolvedOn }}
+
+ {{ c.riskLabel }} + + @if (c.contactHistory && c.contactHistory.length > 0) { +
+ @for (log of c.contactHistory; track $index) { +
+ {{ log.date }} ({{ log.action }}): {{ log.notes }} + — {{ log.officer }} + @if (log.platformSent) { + @if (log.delivered) { + Sent {{ log.destinationMasked }} + } @else { + Not sent + } + } +
+ } +
+ } @else { + No logs recorded + } +
+ + Exception Resolved + +
+ } + } +
+
+ }
@@ -1165,6 +1448,251 @@
} + + @if (contactDialogOpen && current) { +
+
+
+
{{ current.av }}
+
+
Log Customer Contact
+
{{ current.name }} · {{ current.account }}
+
+
✕
+
+ +
+
+
Customer Contact Info
+
{{ current.name }}
+
+ {{ current.contact.mobile }} +
+
+ {{ current.contact.email }} +
+
+ +
+ + + @if (selectedChannelSends) { +
+ + A KYC notice will be sent to the customer when you save. +
+ } @else { +
Records an action you already took. Nothing is sent to the customer.
+ } + @if (selectedChannelUnavailable) { +
+ This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on + file — choose a different channel. +
+ } +
+ +
+ + +
+ +
+ + +
+ + @if (contactError) { + + } +
+ + +
+
+ } + + + @if (resolveDialogOpen && current) { +
+
+
+
{{ current.av }}
+
+
Resolve KYC Exception
+
{{ current.name }} · {{ current.account }}
+
+
✕
+
+ +
+
+ Pending Reason: {{ current.exceptionReason }} + @if (current.exceptionRemarks) { +
"{{ current.exceptionRemarks }}"
+ } +
+ + @if (showPanResolution) { +
+
+ + +
+ @if (resolvePanActive) { +
+ + + @if (resolvePanNumber && resolvePanNumber.length !== 10) { +
PAN must be exactly 10 characters.
+ } +
+ } +
+ } + + @if (showAadhaarResolution) { +
+
+ + +
+ @if (resolveAadhaarActive) { +
+ + + @if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) { +
Aadhaar must be exactly 12 digits.
+ } +
+ } +
+ } + + @if (showGeneralResolution) { +
+
+ + +
+ @if (resolveGeneralActive) { +
+
+ + +
+
+ + +
+
+ } +
+ } + +
+ + +
+
+ + +
+
+ } +
{{ toastMsg }}
diff --git a/src/app/kyc-management/kyc-management.component.scss b/src/app/kyc-management/kyc-management.component.scss index bef8750f6..22a9dae64 100644 --- a/src/app/kyc-management/kyc-management.component.scss +++ b/src/app/kyc-management/kyc-management.component.scss @@ -1588,6 +1588,58 @@ min-height: 60px; } +/* Inline note under a field. The default (neutral) form states that nothing is transmitted; + the -send and -error variants call out the two cases the officer must not misread. */ +.hv-hint { + font-size: 11px; + line-height: 1.45; + color: var(--km-g500); + display: flex; + align-items: flex-start; + gap: 6px; +} + +.hv-hint-send { + color: #0f766e; + background: #f0fdfa; + border: 1px solid #99f6e4; + border-radius: 4px; + padding: 6px 8px; + font-weight: 500; +} + +.hv-hint-error { + color: #b91c1c; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 4px; + padding: 6px 8px; + font-weight: 500; +} + +/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are + different facts, so they never share a colour. */ +.km-sent-tag { + display: inline-block; + margin-left: 6px; + padding: 0 5px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + vertical-align: baseline; +} + +.km-sent-ok { + background: #e6f4ea; + color: #137333; +} + +.km-sent-fail { + background: #fef2f2; + color: #b91c1c; +} + .hv-footer { padding: 14px 22px; border-top: 1px solid var(--km-g200); @@ -1648,3 +1700,30 @@ color: #0f172a; word-break: break-all; } + +/* KYC Exceptions Styles */ +.km-ci-e { + background: var(--km-amber-l) !important; + color: var(--km-amber) !important; +} + +.contact-logs-list { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 6px 10px; + font-family: inherit; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 2px; + } +} diff --git a/src/app/kyc-management/kyc-management.component.ts b/src/app/kyc-management/kyc-management.component.ts index b3aa34bae..e861a8b57 100644 --- a/src/app/kyc-management/kyc-management.component.ts +++ b/src/app/kyc-management/kyc-management.component.ts @@ -6,10 +6,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { ClientsService } from '../clients/clients.service'; interface KmNotice { no: string; @@ -31,6 +32,24 @@ interface KmEvidence { docket?: string; } +/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */ +interface KmContactLog { + date: string; + /** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */ + action: string; + notes: string; + officer: string; + /** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */ + platformSent?: boolean; + /** True only when the notice actually reached the gateway. */ + delivered?: boolean; + /** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */ + deliveryStatus?: string; + /** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */ + deliveryMessage?: string; + destinationMasked?: string; +} + interface KmClient { id: string; av: string; @@ -66,6 +85,17 @@ interface KmClient { pendingActions: string[]; actionTaken: string[]; evidence: KmEvidence | null; + isException?: boolean; + exceptionReason?: string; + exceptionRemarks?: string; + exceptionDate?: string; + exceptionResolved?: boolean; + exceptionResolvedOn?: string; + contactHistory?: KmContactLog[]; + kycId?: string; + /** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */ + hasMobile?: boolean; + hasEmail?: boolean; } @Component({ @@ -79,7 +109,8 @@ interface KmClient { ] }) export class KycManagementComponent implements OnInit { - activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts'; + private clientsService = inject(ClientsService); + activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts'; modalOpen = false; modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview'; @@ -529,15 +560,419 @@ export class KycManagementComponent implements OnInit { }; alertRows: KmClient[] = []; + exceptionRows: KmClient[] = []; + pendingExceptions: KmClient[] = []; + resolvedExceptions: KmClient[] = []; + activeExceptionSubTab: 'pending' | 'resolved' = 'pending'; + selectedExceptionReason = ''; + selectedExceptionContactStatus = ''; + + // Dialog properties for exceptions + contactDialogOpen = false; + contactChannel = 'PHONE_CALL'; + contactNotes = ''; + contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)'; + /** In-flight guard — a double-click must not send the customer two notices. */ + contactSaving = false; + /** Populated when the save itself failed (not when the save worked but delivery didn't). */ + contactError = ''; + + /** + * Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving + * transmits a real notice to the customer, as opposed to recording an action the officer already + * took offline — the dialog labels and the confirm button both key off it. + */ + readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [ + { value: 'PHONE_CALL', label: 'Phone Call', sends: false }, + { value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' }, + { value: 'EMAIL', label: 'Email', sends: true, requires: 'email' }, + { value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' }, + { value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false }, + { value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false } + ]; + + resolveDialogOpen = false; + resolveDocumentType = 'PAN'; + resolveDocumentNumber = ''; + resolveRemarks = ''; + + resolvePanActive = false; + resolvePanNumber = ''; + resolveAadhaarActive = false; + resolveAadhaarNumber = ''; + resolveGeneralActive = false; + resolveGeneralDocumentType = 'OVD'; + resolveGeneralDocumentNumber = ''; + + showPanResolution = false; + showAadhaarResolution = false; + showGeneralResolution = false; ngOnInit(): void { this.alertRows = Object.values(this.clients); + this.loadExceptions(); } - showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void { + loadExceptions(): void { + this.clientsService.getKycExceptions(false).subscribe({ + next: (data) => { + this.pendingExceptions = data.map((item: any) => this.mapException(item, false)); + this.exceptionRows = this.pendingExceptions; + this.loadContactHistories(this.pendingExceptions); + }, + error: (err) => { + console.error('Failed to load active exceptions', err); + this.pendingExceptions = []; + this.exceptionRows = []; + } + }); + + this.clientsService.getKycExceptions(true).subscribe({ + next: (data) => { + this.resolvedExceptions = data.map((item: any) => this.mapException(item, true)); + this.loadContactHistories(this.resolvedExceptions); + }, + error: (err) => { + console.error('Failed to load resolved exceptions', err); + this.resolvedExceptions = []; + } + }); + } + + private mapException(item: any, resolved: boolean): KmClient { + return { + id: item.clientId?.toString(), + av: (item.clientDisplayName || 'Client') + .split(' ') + .map((n: string) => n[0]) + .join('') + .substring(0, 2) + .toUpperCase(), + name: item.clientDisplayName, + meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`, + account: item.clientAccountNo || `CLI-${item.clientId}`, + state: 'alert', + risk: item.riskCategoryCode?.toLowerCase() || 'low', + riskLabel: item.riskCategory || 'Low Risk', + expiry: 'Pending', + daysLeft: 30, + kyc: { + type: item.kycType || 'Individual', + initiated: item.initiatedOn || '—', + lastVerified: item.verifiedOn || '—', + validity: '—', + pan: item.panNumber || '—', + aadhaar: item.aadhaarNumberMasked || '—', + panStatus: item.panVerified ? 'Verified' : 'Verification Failed', + aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed', + panLink: 'Not Linked' + }, + contact: { + // Masked server-side; the full values never reach the browser. 'Not on file' is meaningful + // here — it's why an SMS or Email channel will be unavailable for this customer. + mobile: item.clientMobileMasked || 'Not on file', + email: item.clientEmailMasked || 'Not on file', + address: 'Client Address, Bhopal MP 462002', + branch: item.clientOfficeName || 'Bhopal Main' + }, + notices: [], + noticesSent: 0, + noticePending: resolved ? 'Resolved Exception' : 'Pending verification', + nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review', + pendingActions: resolved ? [] : ['Resolve Exception reasons'], + actionTaken: resolved ? ['KYC Exception resolved'] : [], + evidence: null, + isException: true, + // No invented default — a record whose reason was never stored (or was cleared by the old + // resolve path) must not be relabelled as a PAN failure it may never have had. + exceptionReason: item.exceptionReason || 'Reason not recorded', + exceptionRemarks: item.remarks || '', + exceptionDate: item.initiatedOn || '—', + exceptionResolved: resolved, + exceptionResolvedOn: item.exceptionResolvedOn || '—', + contactHistory: [], + kycId: item.id?.toString(), + hasMobile: item.hasMobile === true, + hasEmail: item.hasEmail === true + }; + } + + /** + * Pull each row's persisted outreach history. Fired after the exception list loads; a failure on + * one row leaves that row's history empty rather than blanking the whole grid. + */ + private loadContactHistories(rows: KmClient[]): void { + for (const row of rows) { + if (!row.id) continue; + this.clientsService.getKycContactLog(row.id).subscribe({ + next: (logs) => { + row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log)); + }, + error: (err) => { + console.error(`Failed to load contact log for client ${row.id}`, err); + } + }); + } + } + + get notYetContactedCount(): number { + return this.exceptionRows.filter((c) => !c.contactHistory || c.contactHistory.length === 0).length; + } + + get panMismatchesCount(): number { + return this.exceptionRows.filter((c) => c.exceptionReason?.includes('PAN')).length; + } + + get aadhaarFailuresCount(): number { + return this.exceptionRows.filter((c) => c.exceptionReason?.includes('Aadhaar')).length; + } + + showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void { this.activeSection = section; } + openContactDialog(client: KmClient): void { + this.current = client; + this.contactChannel = 'PHONE_CALL'; + this.contactNotes = ''; + this.contactError = ''; + this.contactSaving = false; + this.contactOfficer = this.bankOfficers[1]; // Neha Verma + this.contactDialogOpen = true; + } + + closeContactDialog(): void { + this.contactDialogOpen = false; + } + + /** The channel currently picked in the dialog. */ + get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined { + return this.contactChannels.find((c) => c.value === this.contactChannel); + } + + /** True when saving will transmit a notice to the customer, not merely record one. */ + get selectedChannelSends(): boolean { + return this.selectedChannel?.sends === true; + } + + /** + * True when the picked channel needs a destination the customer doesn't have. Blocks the save so + * the officer isn't left reading a failure they could have seen up front. + */ + get selectedChannelUnavailable(): boolean { + const requires = this.selectedChannel?.requires; + if (!requires || !this.current) return false; + return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false; + } + + get contactSaveDisabled(): boolean { + return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable; + } + + /** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */ + get contactSaveLabel(): string { + if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…'; + return this.selectedChannelSends ? 'Send & Save' : 'Save Log'; + } + + saveContactLog(): void { + if (!this.current || this.contactSaveDisabled) return; + + const client = this.current; + this.contactSaving = true; + this.contactError = ''; + + this.clientsService + .saveKycContactLog(client.id, { + channel: this.contactChannel, + notes: this.contactNotes.trim(), + officerName: this.contactOfficer + }) + .subscribe({ + next: (log: any) => { + this.contactSaving = false; + client.contactHistory = [ + ...(client.contactHistory || []), + this.mapContactLog(log) + ]; + + // The request succeeding does not mean the customer was reached — a gateway failure comes + // back as HTTP 200 with delivered=false, so report what actually happened. + if (log?.platformSent && !log?.delivered) { + this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`); + } else { + this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`); + } + this.contactDialogOpen = false; + }, + error: (err) => { + this.contactSaving = false; + // Keep the dialog open — the officer's notes are still in the textarea and would + // otherwise be lost. + this.contactError = + err?.error?.errors?.[0]?.defaultUserMessage || + err?.error?.defaultUserMessage || + 'Could not save the contact log. Please try again.'; + } + }); + } + + private mapContactLog(log: any): KmContactLog { + return { + date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—', + action: log?.channelLabel || log?.channel || 'Contact', + notes: log?.notes || '', + officer: (log?.officerName || '').split(' — ')[0], + platformSent: log?.platformSent, + delivered: log?.delivered, + deliveryStatus: log?.deliveryStatus, + deliveryMessage: log?.deliveryMessage, + destinationMasked: log?.destinationMasked + }; + } + + /** + * `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a + * [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both. + */ + private formatLogDate(value: any): string { + const date = Array.isArray(value) + ? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0) + : new Date(value); + if (isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); + } + + get isResolveValid(): boolean { + if (!this.resolveRemarks?.trim()) return false; + let anyActive = false; + if (this.showPanResolution && this.resolvePanActive) { + anyActive = true; + if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false; + } + if (this.showAadhaarResolution && this.resolveAadhaarActive) { + anyActive = true; + if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false; + } + if (this.showGeneralResolution && this.resolveGeneralActive) { + anyActive = true; + if (!this.resolveGeneralDocumentNumber?.trim()) return false; + } + return anyActive; + } + + openResolveDialog(client: KmClient): void { + this.current = client; + const reason = client.exceptionReason || ''; + this.showPanResolution = reason.includes('PAN'); + this.showAadhaarResolution = reason.includes('Aadhaar'); + this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution; + + this.resolvePanActive = this.showPanResolution; + this.resolveAadhaarActive = this.showAadhaarResolution; + this.resolveGeneralActive = this.showGeneralResolution; + + this.resolvePanNumber = ''; + this.resolveAadhaarNumber = ''; + this.resolveGeneralDocumentType = 'OVD'; + this.resolveGeneralDocumentNumber = ''; + this.resolveRemarks = ''; + this.resolveDialogOpen = true; + } + + closeResolveDialog(): void { + this.resolveDialogOpen = false; + } + + saveResolveKyc(): void { + if (!this.current || !this.current.kycId) return; + const clientId = this.current.id; + const kycId = this.current.kycId; + + const today = new Date(); + const day = today.getDate().toString().padStart(2, '0'); + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + const monthStr = months[today.getMonth()]; + const year = today.getFullYear(); + const dateStr = `${day} ${monthStr} ${year}`; + + const resolvingPan = this.showPanResolution && this.resolvePanActive; + const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive; + const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive; + + const remainsPan = this.showPanResolution && !this.resolvePanActive; + const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive; + + const isFullyResolved = !remainsPan && !remainsAadhaar; + + const resolvedKycData: any = { + locale: 'en', + dateFormat: 'dd MMMM yyyy', + remarks: this.resolveRemarks || 'Resolved via exceptions dashboard' + }; + + if (isFullyResolved) { + resolvedKycData.isException = false; + resolvedKycData.exceptionResolved = true; + resolvedKycData.exceptionResolvedOn = dateStr; + // Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys + // the audit record of why the exception was ever raised. + } else { + resolvedKycData.isException = true; + resolvedKycData.exceptionResolved = false; + const remainingReasons: string[] = []; + if (remainsPan) remainingReasons.push('PAN Card Verification Failed'); + if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed'); + resolvedKycData.exceptionReason = remainingReasons.join(', '); + } + + if (resolvingPan) { + resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim(); + resolvedKycData.panVerified = true; + } + if (resolvingAadhaar) { + const aadhaarRaw = this.resolveAadhaarNumber.trim(); + resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4); + resolvedKycData.aadhaarVerified = true; + } + if (resolvingGeneral) { + resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType; + resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber; + resolvedKycData.isException = false; + resolvedKycData.exceptionResolved = true; + resolvedKycData.exceptionResolvedOn = dateStr; + // exceptionReason preserved — see the fully-resolved branch above. + } + + this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({ + next: () => { + const msg = isFullyResolved + ? `KYC Exception fully resolved and completed for ${this.current?.name}` + : `KYC Exception partially resolved for ${this.current?.name}`; + this.toast(msg); + this.loadExceptions(); + this.resolveDialogOpen = false; + }, + error: (err) => { + console.error('Failed to resolve exception', err); + this.toast('Failed to resolve exception. Please try again.'); + } + }); + } + openModal(id: string): void { const c = this.clients[id]; if (!c) return; diff --git a/src/app/letter-mgmt/certificates/certificates.shared.scss b/src/app/letter-mgmt/certificates/certificates.shared.scss index ed8bea4d2..fc05c778c 100644 --- a/src/app/letter-mgmt/certificates/certificates.shared.scss +++ b/src/app/letter-mgmt/certificates/certificates.shared.scss @@ -505,3 +505,72 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--cert-g900); } + +/* Form 121 register — eligibility warnings and the quarter-wise drill-down */ + +/* A row the branch needs to look at: an ineligible declaration, or the quarter + in which the 194A threshold was crossed. */ +.cert-row--warn > td { + background: color-mix(in srgb, var(--cert-amber) 7%, transparent); +} + +.cert-row--detail > td { + background: var(--cert-g50); + padding: 16px; +} + +.cert-note { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--cert-blue) 6%, transparent); + border-left: 3px solid var(--cert-blue); + font-size: 12px; + color: var(--cert-g900); + line-height: 1.5; + + fa-icon { + color: var(--cert-blue); + flex-shrink: 0; + margin-top: 1px; + } + + ul { + margin: 6px 0 0; + padding-left: 18px; + } + + li + li { + margin-top: 4px; + } + + &--warn { + background: color-mix(in srgb, var(--cert-amber) 8%, transparent); + border-left-color: var(--cert-amber); + + fa-icon { + color: var(--cert-amber); + } + } +} + +/* The quarter table nested inside an expanded row. */ +.cert-table--inner { + background: #fff; + border: 1px solid var(--cert-g200); + border-radius: 8px; + overflow: hidden; + margin-bottom: 10px; + + th { + background: #fff; + border-bottom: 1px solid var(--cert-g200); + } + + .cert-pill { + margin-left: 6px; + } +} diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html index 417b1525d..dd5f208ef 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.html @@ -11,158 +11,347 @@
-

Form 121

-
TDS-waiver declarations for FD / RD interest income — Form 121.
+

{{ statute.form }}

+
+ @if (viewMode === 'demo') { + Declarations to stop tax being deducted on fixed and recurring deposit interest under + {{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is + uploaded {{ statute.frequency }}. + } @else { + Live customer declarations, sourced from client accounts and KYC profiles. + } +
+
+ @if (viewMode === 'demo') { + + } @else { + + } +
- -
-
- -
-
{{ count15G() | number }}
-
Form 121 (under 60)
-
-
-
- -
-
{{ count15H() | number }}
-
Form 121 (60+)
-
-
-
- -
-
{{ countFiled() | number }}
-
Filed this FY
-
-
-
- -
-
₹{{ totalInterest() | number: '1.0-0' }}
-
Projected Interest
-
-
-
+ @if (viewMode === 'real') { + + } - -
-
- -
- - + @if (viewMode === 'demo') { + +
+
+ +
+
{{ countGeneralBand() | number }}
+
General band (under 60)
+
+
+ +
+
{{ countSeniorBand() | number }}
+
Senior citizen band (60+)
+
+
+
+ +
+
{{ countFiled() | number }}
+
Filed this FY
+
+
+
+ +
+
₹{{ totalInterest() | number: '1.0-0' }}
+
Projected Interest
+
+
+
+ +
+
₹{{ totalTdsSaved() | number: '1.0-0' }}
+
TDS Saved
+
+
+ @if (countIneligible() > 0) { +
+ +
+
{{ countIneligible() | number }}
+
Not Eligible
+
+
+ }
-
- - + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if (search || bandFilter || statusFilter || ineligibleOnly) { + + }
-
- - -
- - @if (search || kindFilter || statusFilter) { - - } -
- - -
-
- - - - - - - - - - - - - - - @for (r of filteredRecords; track r.customerId) { + +
+
+
Customer · PANForm TypeAgeBranchDeposit PrincipalEst. Interest ({{ records[0]?.fy }})StatusActions
+ - - - - - - - + + + + + + + + + + + + + + @for (row of filteredRows; track row.record.customerId) { + + + + + + + + - - - } - @if (filteredRecords.length === 0) { - - - - } - -
-
{{ r.customerName }}
-
{{ r.customerId }} · PAN {{ r.customerPan }}
-
DOB {{ r.dob | date: 'd MMM y' }}
-
- - Form 121 ({{ formKind(r) }}) - - {{ age(r.dob) }} yrs{{ r.branch }}₹{{ r.depositPrincipal | number: '1.0-0' }} - ₹{{ r.estimatedInterest | number: '1.0-0' }} - - @if (r.status === 'FILED') { - Filed - @if (r.filedOn) { -
{{ r.filedOn | date: 'd MMM y' }}
+
Customer · PANForm · BandAgeBranchEst. InterestThresholdTDS PayableTDS SavedSettlementStatusActions
+
{{ row.record.customerName }}
+
{{ row.record.customerId }} · PAN {{ row.record.customerPan }}
+
DOB {{ row.record.dob | date: 'd MMM y' }}
+
+ + {{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }} + +
{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}
+
{{ row.age }} yrs{{ row.record.branch }} + ₹{{ row.record.estimatedInterest | number: '1.0-0' }} + ₹{{ row.schedule.threshold | number: '1.0-0' }} + @if (row.schedule.totalTds > 0) { + ₹{{ row.schedule.totalTds | number: '1.0-0' }} +
@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%
+ } @else { + — } - } @else { - Pending - } -
-
- - -
-
- - No declarations match the current filters. -
+ + + @if (row.tdsSaved > 0) { + ₹{{ row.tdsSaved | number: '1.0-0' }} + } @else { + — + } + + + + {{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }} + +
product-driven
+ + + @if (row.record.status === 'FILED') { + Filed + @if (row.record.filedOn) { +
{{ row.record.filedOn | date: 'd MMM y' }}
+ } + } @else { + Pending + } + @if (!row.eligibility.eligible) { +
+ Not eligible +
+ } + + +
+ + + +
+ + + + @if (isExpanded(row)) { + + + @if (!row.eligibility.eligible) { +
+ +
+ Declaration cannot be accepted +
    + @for (reason of row.eligibility.reasons; track reason) { +
  • {{ reason }}
  • + } +
+
+
+ } + @for (note of row.eligibility.unassessed; track note) { +
+ +
{{ note }}
+
+ } + @for (note of row.schedule.notes; track note) { +
+ +
{{ note }}
+
+ } + + + + + + + + + + + + + + @for (line of row.schedule.lines; track line.quarter) { + + + + + + + + + } + + + + + + + + + +
QuarterInterestCumulativeLiability to dateTDS this quarterReturn due
+ {{ line.label }} + @if (line.thresholdCrossedHere) { + threshold crossed + } + @if (line.declarationActive) { + Form 121 active + } + ₹{{ line.interest | number: '1.0-0' }}₹{{ line.cumInterest | number: '1.0-0' }}₹{{ line.cumLiability | number: '1.0-0' }} + @if (line.tds !== 0) { + ₹{{ line.tds | number: '1.0-0' }} + } @else { + — + } + {{ line.dueDate }}
Total + ₹{{ row.schedule.totalInterest | number: '1.0-0' }} + + ₹{{ row.schedule.totalTds | number: '1.0-0' }} +
+ + @if (row.schedule.shieldedInterest > 0) { +
+ ₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of + the declaration — reportable in the quarterly {{ statute.statement }} return. +
+ } + + + } + } + @if (filteredRows.length === 0) { + + + + No declarations match the current filters. + + + } + + +
-
+ }
diff --git a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts index 839a9fb6f..9ab9b9b5d 100644 --- a/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts +++ b/src/app/letter-mgmt/certificates/form-15gh/form-15gh.component.ts @@ -21,9 +21,31 @@ import { } from '../certificate-preview-dialog/certificate-preview-dialog.component'; import { downloadForm15ghPdf } from '../certificate-pdf'; import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service'; +import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component'; -export type Form15Kind = '15G' | '15H'; +import { + Declaration, + DeclarationForm, + EligibilityResult, + FY_TAX_CONFIG, + Form15Kind, + TdsMode, + TdsSchedule, + ageInFy, + buildSchedule, + checkEligibility, + declarationFormFor, + formKindFor, + isSeniorInFy, + normaliseFy, + projectQuarterlyCredits, + tdsModeFor +} from './tds-15gh.compute'; + +export type { Form15Kind }; export type Form15Status = 'FILED' | 'PENDING'; +/** Which deduction trigger the payee falls under. Age-based, still. */ +export type ThresholdBand = 'GENERAL' | 'SENIOR'; export interface Form15ghRecord { customerId: string; @@ -32,18 +54,46 @@ export interface Form15ghRecord { customerAddress: string; dob: string; branch: string; - /** Aggregate interest the bank will pay this FY. */ + /** Aggregate interest the bank will pay this FY across the customer's time deposits. */ estimatedInterest: number; - /** Customer's total estimated income for the FY. */ + /** Customer's total estimated income for the FY, as declared by them. */ estimatedIncome: number; /** Sum of FD / RD principals tied to the declaration. */ depositPrincipal: number; fy: string; status: Form15Status; filedOn?: string; - /** "Yes / No" for: has any income tax been assessed for the customer - under the IT Act in the past six years? */ + /** + * "Yes / No" for: has any income tax been assessed for the customer under the + * IT Act in the past six years? + */ assessed: boolean; + /** + * The deposit product's interest-posting frequency. Tax falls due when interest + * is credited, so this — and not any customer preference — decides whether it + * settles quarterly or at year end. + */ + interestPostingPeriodType: string; +} + +/** A register row with everything the engine derived for it, computed once. */ +export interface Form15ghRow { + record: Form15ghRecord; + /** The form actually in force for the record's year — '121' from FY 2026-27. */ + form: DeclarationForm; + /** + * The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27 + * the forms merged and this reflects the threshold band alone. + */ + kind: Form15Kind; + band: ThresholdBand; + age: number; + mode: TdsMode; + /** Schedule as things stand, declaration included where one is on record. */ + schedule: TdsSchedule; + /** Tax the declaration kept out of the customer's pocket this year. */ + tdsSaved: number; + eligibility: EligibilityResult; } @Component({ @@ -55,7 +105,8 @@ export interface Form15ghRecord { CommonModule, FormsModule, FaIconComponent, - MatTooltip + MatTooltip, + RealForm15ghViewComponent ] }) export class Form15ghComponent implements OnInit { @@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit { private snack = inject(MatSnackBar); private dscSign = inject(DscSignService); + /** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */ + viewMode: 'demo' | 'real' = 'demo'; + // ── Filters ── search = ''; - /** Which form. Empty = both. */ - kindFilter: '' | Form15Kind = ''; + /** Which deduction trigger. Empty = both bands. */ + bandFilter: '' | ThresholdBand = ''; statusFilter: '' | Form15Status = ''; + /** Show only declarations the branch cannot lawfully accept. */ + ineligibleOnly = false; - records: Form15ghRecord[] = []; + /** Which row's quarter-wise breakdown is open. */ + expandedId: string | null = null; + + rows: Form15ghRow[] = []; ngOnInit(): void { - this.records = seedForm15gh(); + // Derived once. The template reads these many times per change-detection + // pass, and re-running the schedules on every read is what made the other + // register screens stall. + this.rows = seedForm15gh().map((record) => this.buildRow(record)); } - /** Compute age in years from DoB. */ - age(dob: string): number { - if (!dob) return 0; - const today = new Date(); - const birth = new Date(dob); - let age = today.getFullYear() - birth.getFullYear(); - const m = today.getMonth() - birth.getMonth(); - if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--; - return age; + private buildRow(record: Form15ghRecord): Form15ghRow { + const ctx = { + fy: record.fy, + dateOfBirth: record.dob, + pan: record.customerPan, + resident: true + }; + + const declaration: Declaration | null = + record.status === 'FILED' + ? { + kind: formKindFor(record.dob, record.fy), + // The register holds the filing date; a declaration operates from + // the day the branch accepted it. + acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`, + fy: record.fy + } + : null; + + const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType }); + // The register carries a projected annual figure rather than real postings, + // so spread it across the year to drive the schedule. + const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy); + + const schedule = buildSchedule(mode, credits, { ...ctx, declaration }); + const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null }); + + return { + record, + form: declarationFormFor(record.fy, record.dob), + kind: formKindFor(record.dob, record.fy), + band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL', + age: ageInFy(record.dob, record.fy), + mode, + schedule, + tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0, + eligibility: checkEligibility(ctx, { + estimatedInterest: record.estimatedInterest, + estimatedIncome: record.estimatedIncome, + incomeOnRecord: true + }) + }; } - /** 15H once 60+, 15G otherwise. */ - formKind(r: Form15ghRecord): Form15Kind { - return this.age(r.dob) >= 60 ? '15H' : '15G'; - } - - get filteredRecords(): Form15ghRecord[] { + get filteredRows(): Form15ghRow[] { const q = this.search.trim().toLowerCase(); - return this.records.filter((r) => { - if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false; - if (this.statusFilter && r.status !== this.statusFilter) return false; - if (!q) return true; + return this.rows.filter((row) => { + if (this.bandFilter && row.band !== this.bandFilter) { + return false; + } + if (this.statusFilter && row.record.status !== this.statusFilter) { + return false; + } + if (this.ineligibleOnly && row.eligibility.eligible) { + return false; + } + if (!q) { + return true; + } + const r = row.record; return ( r.customerName.toLowerCase().includes(q) || r.customerId.toLowerCase().includes(q) || @@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit { }); } - count15G(): number { - return this.records.filter((r) => this.formKind(r) === '15G').length; + countGeneralBand(): number { + return this.rows.filter((r) => r.band === 'GENERAL').length; } - count15H(): number { - return this.records.filter((r) => this.formKind(r) === '15H').length; + countSeniorBand(): number { + return this.rows.filter((r) => r.band === 'SENIOR').length; + } + + /** + * The declaration in force across the register, and the provision charging the + * tax. Both changed on 01 April 2026, so they are read from the config rather + * than written into the template. + */ + get statute(): { form: string; section: string; statement: string; frequency: string } { + const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27'); + const cfg = FY_TAX_CONFIG[fy]; + return { + form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H', + section: cfg.interestTdsSection, + statement: cfg.tdsStatementForm, + frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly' + }; } countFiled(): number { - return this.records.filter((r) => r.status === 'FILED').length; + return this.rows.filter((r) => r.record.status === 'FILED').length; + } + + /** Declarations on record that the branch could not lawfully have accepted. */ + countIneligible(): number { + return this.rows.filter((r) => !r.eligibility.eligible).length; } totalInterest(): number { - return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0); + return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0); + } + + /** Tax the declarations kept out of customers' pockets, across the filter. */ + totalTdsSaved(): number { + return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0); } clearFilters(): void { this.search = ''; - this.kindFilter = ''; + this.bandFilter = ''; this.statusFilter = ''; + this.ineligibleOnly = false; } - viewCertificate(r: Form15ghRecord): void { + toggleExpanded(row: Form15ghRow): void { + this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId; + } + + isExpanded(row: Form15ghRow): boolean { + return this.expandedId === row.record.customerId; + } + + viewCertificate(row: Form15ghRow): void { const data: CertificatePreviewDialogData = { kind: 'form15gh', - form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) } + form15gh: { record: row.record, kind: row.kind, age: row.age } }; this.dialog.open(CertificatePreviewDialogComponent, { width: '880px', @@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit { }); } - downloadCertificate(r: Form15ghRecord): void { - const kind = this.formKind(r); + downloadCertificate(row: Form15ghRow): void { + const r = row.record; + // A declaration the branch cannot accept must not go out as a signed PDF. + if (!row.eligibility.eligible) { + this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 }); + return; + } this.dscSign.promptThenDownload( { - title: `Form 121 (${kind}) Declaration`, + title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`, reference: `${r.customerId} · ${r.customerName} · ${r.fy}`, filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf` }, - (dsc) => downloadForm15ghPdf(r, kind, dsc) + (dsc) => downloadForm15ghPdf(r, row.kind, dsc) ); } + + toggleViewMode(): void { + this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo'; + } } -// ───────────────────── Mock seed (top-level export) ───────────────────── +// ───────────────────────── Mock seed (top-level export) ───────────────────── +/** + * Two of these customers are deliberately ineligible — their estimated income + * leaves a tax liability, so a Form 121 declaration cannot be accepted from them. + * That gives the register's eligibility warnings something to show. + */ export function seedForm15gh(): Form15ghRecord[] { return [ { @@ -165,86 +314,109 @@ export function seedForm15gh(): Form15ghRecord[] { customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069', dob: '1985-02-14', branch: 'Mumbai — Andheri (E)', - estimatedInterest: 38500, - estimatedIncome: 850000, - depositPrincipal: 500000, - fy: 'FY 2025-26', - status: 'FILED', - filedOn: '2025-04-10', - assessed: true + estimatedInterest: 38_500, + // Over the basic exemption limit — not eligible for 15G. + estimatedIncome: 850_000, + depositPrincipal: 500_000, + fy: 'FY 2026-27', + status: 'PENDING', + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' }, { customerId: 'CUST556677', customerName: 'Karthik Rao', - customerPan: 'PQRSR2233Z', + customerPan: 'PQRPR2233Z', customerAddress: '24, MG Road, Bengaluru 560001', dob: '1972-09-04', branch: 'Bengaluru — MG Road', - estimatedInterest: 52800, - estimatedIncome: 320000, - depositPrincipal: 700000, - fy: 'FY 2025-26', + estimatedInterest: 52_800, + estimatedIncome: 320_000, + depositPrincipal: 700_000, + fy: 'FY 2026-27', status: 'PENDING', - assessed: false + assessed: false, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' }, { customerId: 'CUST667788', customerName: 'Lakshmi Naidu', - customerPan: 'XYZAN7788Q', + customerPan: 'XYZPN7788Q', customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034', dob: '1958-06-22', branch: 'Hyderabad — Banjara Hills', - estimatedInterest: 88200, - estimatedIncome: 410000, - depositPrincipal: 1200000, - fy: 'FY 2025-26', + estimatedInterest: 88_200, + estimatedIncome: 410_000, + depositPrincipal: 1_200_000, + fy: 'FY 2026-27', status: 'FILED', - filedOn: '2025-04-15', - assessed: true + filedOn: '2026-04-15', + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' }, { customerId: 'CUST334455', customerName: 'Anita Verma', - customerPan: 'EFGHV5678A', + customerPan: 'EFGPV5678A', customerAddress: 'B-12, Connaught Pl, New Delhi 110001', dob: '1962-11-30', branch: 'New Delhi — Connaught Place', - estimatedInterest: 64500, - estimatedIncome: 280000, - depositPrincipal: 900000, - fy: 'FY 2025-26', + estimatedInterest: 64_500, + estimatedIncome: 280_000, + depositPrincipal: 900_000, + fy: 'FY 2026-27', status: 'FILED', - filedOn: '2025-04-05', - assessed: false + filedOn: '2026-04-05', + assessed: false, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual' }, { customerId: 'CUST990011', customerName: 'Arjun Banerjee', - customerPan: 'TUVWB4455F', + customerPan: 'TUVPB4455F', customerAddress: '42, Park Street, Kolkata 700016', dob: '1991-07-19', branch: 'Kolkata — Park Street', - estimatedInterest: 14200, - estimatedIncome: 620000, - depositPrincipal: 200000, - fy: 'FY 2025-26', + estimatedInterest: 14_200, + // Over the basic exemption limit — not eligible for 15G. + estimatedIncome: 620_000, + depositPrincipal: 200_000, + fy: 'FY 2026-27', status: 'PENDING', - assessed: true + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly' }, { customerId: 'CUST330044', customerName: 'Madhavi Singh', - customerPan: 'TUVWS3344K', + customerPan: 'TUVPS3344K', customerAddress: 'D-5, Greater Kailash, New Delhi 110048', dob: '1955-03-08', branch: 'New Delhi — Connaught Place', - estimatedInterest: 145000, - estimatedIncome: 320000, - depositPrincipal: 2000000, - fy: 'FY 2025-26', + // A 15H declarant faces no ceiling on interest — only a nil liability. + estimatedInterest: 145_000, + estimatedIncome: 320_000, + depositPrincipal: 2_000_000, + fy: 'FY 2026-27', status: 'FILED', - filedOn: '2025-04-02', - assessed: false + filedOn: '2026-04-02', + assessed: false, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' + }, + { + customerId: 'CUST778899', + customerName: 'Vikram Malhotra', + customerPan: '', + customerAddress: 'Flat 405, Green Glen Layout, Outer Ring Road, Bengaluru 560103', + dob: '1980-05-15', + branch: 'Bengaluru — MG Road', + estimatedInterest: 60_000, + estimatedIncome: 350_000, + depositPrincipal: 800_000, + fy: 'FY 2026-27', + status: 'PENDING', + assessed: true, + interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly' } ]; } diff --git a/src/app/reports/reports-routing.module.ts b/src/app/reports/reports-routing.module.ts index 602c49d5f..7565d21ec 100644 --- a/src/app/reports/reports-routing.module.ts +++ b/src/app/reports/reports-routing.module.ts @@ -59,6 +59,18 @@ const routes: Routes = [ loadComponent: () => import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent) }, + { + path: 'tds-interest', + data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' }, + loadComponent: () => + import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent) + }, + { + path: 'tds-remittance', + data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' }, + loadComponent: () => + import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent) + }, { path: ':filter', data: { routeParamBreadcrumb: 'filter' },