feat: update kyc tab, branch day register, and dormancy reports
This commit is contained in:
+39
-8
@@ -2432,6 +2432,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
{ upTo: null, category: 3, label: 'above ₹10 crore' }
|
||||
];
|
||||
|
||||
/**
|
||||
* A numeric rule bound counts as configured only when it is present and above zero.
|
||||
*
|
||||
* Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as
|
||||
* either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason;
|
||||
* this keeps the criteria-less test reading the columns the same way.
|
||||
*/
|
||||
private static isConfigured(bound: number | string | null | undefined): boolean {
|
||||
return bound != null && parseFloat(String(bound)) > 0;
|
||||
}
|
||||
|
||||
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
|
||||
riskCategoryLabel(category: number): string {
|
||||
if (category === 3) {
|
||||
@@ -2518,17 +2529,32 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
// Evaluate rules from DB (ordered by orderPosition ASC)
|
||||
if (this.riskRules && this.riskRules.length > 0) {
|
||||
for (const rule of this.riskRules) {
|
||||
// Skip transaction/AML rules that have no KYC criteria
|
||||
// Only KYC profile rules categorise a customer at onboarding. The same table also holds
|
||||
// ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs
|
||||
// Rule - 91", ...) which describe behaviour and may only fire against posted transactions,
|
||||
// as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12)
|
||||
// from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them
|
||||
// apart; it replaced an inference from "this rule has no criteria set", which migration
|
||||
// 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried
|
||||
// client on an income band it was never meant to carry and pre-flag them for AML.
|
||||
if (rule.ruleKind === 'AML') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form
|
||||
// and no income or turnover band constrains nothing, so it would match every customer.
|
||||
// Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind.
|
||||
if (
|
||||
rule.ruleKind == null &&
|
||||
rule.legalFormId == null &&
|
||||
rule.occupationCategoryCvId == null &&
|
||||
rule.occupationCvId == null &&
|
||||
rule.minMonthlyIncome == null &&
|
||||
rule.maxMonthlyIncome == null &&
|
||||
rule.minAnnualIncome == null &&
|
||||
rule.maxAnnualIncome == null &&
|
||||
rule.minAnnualTurnover == null &&
|
||||
rule.maxAnnualTurnover == null
|
||||
!ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) &&
|
||||
!ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) &&
|
||||
!ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) &&
|
||||
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) &&
|
||||
!ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) &&
|
||||
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -2576,7 +2602,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
||||
return {
|
||||
category: rule.riskCategory,
|
||||
reason: rule.reason,
|
||||
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'),
|
||||
// A profile rule sets the risk category; it does not raise an AML flag. High Risk means
|
||||
// enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not
|
||||
// suspicion — an alert belongs to observed transaction behaviour (Section 13), so a
|
||||
// customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a
|
||||
// rule to flag on match can still say so explicitly via the rule's own amlCheckStatus.
|
||||
amlCheckStatus: rule.amlCheckStatus || 'CLEAR',
|
||||
bannedEntityMatch: rule.bannedEntityMatch || 'NO',
|
||||
willfulDefaulter: rule.willfulDefaulter || 'NO',
|
||||
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
|
||||
|
||||
@@ -280,6 +280,82 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The OTP exchange, directly under the row that started it.
|
||||
|
||||
It used to sit at the very bottom of the tab, past the PAN-Aadhaar seeding panel: clicking
|
||||
Re-Verify appeared to do nothing, because the input it opened was off-screen. Keeping it
|
||||
against the Aadhaar row means the field is where the officer is already looking.
|
||||
-->
|
||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<div class="kv-otp-bar kv-otp-bar--inline">
|
||||
<mat-form-field appearance="outline" class="kv-otp-input">
|
||||
<mat-label>Enter 12-digit Aadhaar</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarFullNumber"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="12"
|
||||
type="password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kv-otp-btn"
|
||||
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
@if (verifying.aadhaar) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Send OTP
|
||||
}
|
||||
</button>
|
||||
<button mat-button type="button" class="kv-otp-btn" (click)="cancelAadhaarReVerify()">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="kv-otp-bar kv-otp-bar--inline">
|
||||
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
|
||||
<mat-form-field appearance="outline" class="kv-otp-input">
|
||||
<mat-label>Enter OTP</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarOtp"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kv-otp-btn"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()"
|
||||
>
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Verify
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-otp-btn"
|
||||
[disabled]="verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
<button mat-button type="button" class="kv-otp-btn" (click)="cancelAadhaarReVerify()">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--
|
||||
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
|
||||
only reason Aadhaar carries no KYC exception.
|
||||
@@ -565,73 +641,6 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
|
||||
@if (showAadhaarReVerify && !aadhaarOtpSent) {
|
||||
<div class="kv-otp-bar">
|
||||
<mat-form-field appearance="outline" class="kv-otp-input">
|
||||
<mat-label>Enter 12-digit Aadhaar</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarFullNumber"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="12"
|
||||
type="password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kv-otp-btn"
|
||||
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
@if (verifying.aadhaar) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Send OTP
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (aadhaarOtpSent) {
|
||||
<div class="kv-otp-bar">
|
||||
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
|
||||
<mat-form-field appearance="outline" class="kv-otp-input">
|
||||
<mat-label>Enter OTP</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="aadhaarOtp"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="button"
|
||||
class="kv-otp-btn"
|
||||
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
|
||||
(click)="verifyAadhaarOtp()"
|
||||
>
|
||||
@if (aadhaarOtpVerifying) {
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
|
||||
} @else {
|
||||
Verify
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="kv-otp-btn"
|
||||
[disabled]="verifying.aadhaar"
|
||||
(click)="sendAadhaarOtp()"
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (verificationError.aadhaar) {
|
||||
<div class="kyc-error-msg">
|
||||
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}
|
||||
@@ -1128,6 +1137,69 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
|
||||
it, so re-collecting an expired document is done on this form rather than
|
||||
sending the officer somewhere else. -->
|
||||
@if (kycData?.kycDocumentName || kycDocumentNeedsUpdate) {
|
||||
<div class="full-span kyc-doc-fields">
|
||||
<div class="kyc-doc-fields__head">
|
||||
<h4 class="kyc-doc-fields__title">Officially Valid Document</h4>
|
||||
@if (kycDocumentNeedsUpdate) {
|
||||
<span class="kyc-doc-fields__flag">Re-collect from the customer</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="kyc-doc-fields__grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Document</mat-label>
|
||||
<mat-select formControlName="kycDocumentName">
|
||||
<mat-option value="Aadhaar Card">Aadhaar Card</mat-option>
|
||||
<mat-option value="Passport">Passport</mat-option>
|
||||
<mat-option value="Driving Licence">Driving Licence</mat-option>
|
||||
<mat-option value="Voter ID">Voter ID</mat-option>
|
||||
<mat-option value="NREGA Job Card">NREGA Job Card</mat-option>
|
||||
<mat-option value="Letter from National Population Register">Letter from NPR</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Document Number</mat-label>
|
||||
<input matInput formControlName="kycDocumentNumber" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Issued On</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[matDatepicker]="docIssuePicker"
|
||||
[max]="maxDate"
|
||||
formControlName="kycDocIssueDate"
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="docIssuePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #docIssuePicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Valid Till</mat-label>
|
||||
<input matInput [matDatepicker]="docExpiryPicker" formControlName="kycDocExpiryDate" />
|
||||
<mat-datepicker-toggle matSuffix [for]="docExpiryPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #docExpiryPicker></mat-datepicker>
|
||||
<mat-hint>Blank if it does not expire</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- The scans live with the document itself, on the identifier the customer's
|
||||
original was attached to. Re-scanning is done there rather than duplicated
|
||||
here, so both sides, the cropper and the replace-in-place all behave the
|
||||
same way they did when the document was first collected. -->
|
||||
<a class="kyc-doc-scans-link" [routerLink]="['/clients', clientId, 'edit']">
|
||||
<mat-icon>photo_library</mat-icon>
|
||||
<span>Replace the scanned front / back of this document</span>
|
||||
<mat-icon class="kyc-doc-scans-link__go">chevron_right</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KYC Exception Flag -->
|
||||
<div
|
||||
class="full-span exception-controls-row"
|
||||
|
||||
@@ -856,6 +856,18 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// Sitting inside the table, directly under the Aadhaar row it belongs to. Indented and tinted so
|
||||
// it reads as that row opening up rather than as a new section, and the flat top edge joins it to
|
||||
// the row above.
|
||||
.kv-otp-bar--inline {
|
||||
margin: 0 0 8px 28px;
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kv-otp-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1576,3 +1588,96 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Officially valid document, on the edit form ────────────────────────────
|
||||
// Set apart from the identity fields above it because it is one document with its own
|
||||
// provenance — a type, a number, and the two dates that decide whether it is still valid —
|
||||
// rather than four unrelated inputs.
|
||||
|
||||
.kyc-doc-fields {
|
||||
margin: 4px 0 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.kyc-doc-fields__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kyc-doc-fields__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.kyc-doc-fields__flag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
// Four across on a wide screen, folding to two and then one. The dates sit next to each other
|
||||
// at every width, since it is the pair that has to be read together.
|
||||
.kyc-doc-fields__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
@media (width <= 1280px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-scans-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.kyc-doc-scans-link__go {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
1,
|
||||
[Validators.required]
|
||||
],
|
||||
// The officially valid document, so a lapsed one can be re-collected on this form. The
|
||||
// backend has always accepted these four; only the fields were missing.
|
||||
kycDocumentName: [''],
|
||||
kycDocumentNumber: [''],
|
||||
kycDocIssueDate: [null],
|
||||
kycDocExpiryDate: [null],
|
||||
panNumber: [
|
||||
'',
|
||||
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
|
||||
@@ -176,6 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
// the full 12-digit number to re-verify, so don't pre-fill.
|
||||
aadhaarNumber: '',
|
||||
gstinNumber: data.gstinNumber || '',
|
||||
kycDocumentName: data.kycDocumentName || '',
|
||||
kycDocumentNumber: data.kycDocumentNumber || '',
|
||||
kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate),
|
||||
kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate),
|
||||
remarks: data.remarks || '',
|
||||
isException: data.isException || false,
|
||||
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
|
||||
@@ -355,6 +365,41 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* The officially valid document, for the update payload.
|
||||
*
|
||||
* Only sent when a document is named, so an officer editing the risk category on a client who
|
||||
* never had an OVD recorded does not silently blank the other three columns.
|
||||
*/
|
||||
private buildDocumentFields() {
|
||||
const name = (this.kycForm.value.kycDocumentName || '').trim();
|
||||
if (!name) {
|
||||
return {};
|
||||
}
|
||||
const dateFormat = 'dd MMMM yyyy';
|
||||
return {
|
||||
kycDocumentName: name,
|
||||
kycDocumentNumber: (this.kycForm.value.kycDocumentNumber || '').trim() || null,
|
||||
kycDocIssueDate: this.kycForm.value.kycDocIssueDate
|
||||
? this.dateUtils.formatDate(this.kycForm.value.kycDocIssueDate, dateFormat)
|
||||
: null,
|
||||
// Blank is meaningful: a passport expires, a PAN card does not.
|
||||
kycDocExpiryDate: this.kycForm.value.kycDocExpiryDate
|
||||
? this.dateUtils.formatDate(this.kycForm.value.kycDocExpiryDate, dateFormat)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/** True while the banner is asking for a document, so the form shows the fields to supply it. */
|
||||
get kycDocumentNeedsUpdate(): boolean {
|
||||
return this.showKycDocumentAlert;
|
||||
}
|
||||
|
||||
/** Today, as the latest an existing document can have been issued. */
|
||||
get maxDate(): Date {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
private buildExceptionFields() {
|
||||
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
|
||||
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
|
||||
@@ -392,6 +437,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
remarks: this.kycForm.value.remarks || null,
|
||||
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
...this.buildDocumentFields(),
|
||||
...this.buildExceptionFields()
|
||||
};
|
||||
|
||||
@@ -478,11 +524,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
if (response?.changes?.panVerified) {
|
||||
this.verificationStatus.pan = 'VERIFIED';
|
||||
this.verificationDetails.pan = response.changes;
|
||||
// A PAN that now verifies makes any earlier declaration moot.
|
||||
// A PAN that now verifies makes any earlier declaration moot. That write already
|
||||
// re-derives the exception columns, so it settles the exception too.
|
||||
if (this.panDeclarationTaken) {
|
||||
this.writePanDeclaration(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
this.syncExceptionAfterVerify();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.pan = 'FAILED';
|
||||
@@ -660,6 +707,60 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives the exception columns once a verification has actually passed.
|
||||
*
|
||||
* `saveKycBeforeVerify()` writes the exception state *before* the vendor is called, when the
|
||||
* check is by definition still outstanding — so a check that then succeeds leaves the record
|
||||
* carrying an exception nobody closes. The KYC tab reads `panVerified` and shows "Verified"
|
||||
* while the exceptions register keeps reading `isException`, which is why a client could sit in
|
||||
* the Pending tab with every document verified.
|
||||
*
|
||||
* Called on each verification success. `buildExceptionFields()` already prefers the in-session
|
||||
* `verificationStatus` over the stored record, so the freshly passed check counts even though
|
||||
* `kycData` has not been reloaded yet; `hadException` still reads the stored value, which is
|
||||
* what tells it to close the entry rather than leave every flag off.
|
||||
*
|
||||
* Verifying one of two outstanding items narrows the exception instead of closing it — that
|
||||
* falls out of the derivation, so this needs no special case for it.
|
||||
*/
|
||||
private syncExceptionAfterVerify(): void {
|
||||
if (!this.kycId) {
|
||||
this.loadKycData();
|
||||
return;
|
||||
}
|
||||
|
||||
const exceptionFields = this.buildExceptionFields();
|
||||
|
||||
// Nothing recorded and nothing outstanding — no write would change anything.
|
||||
if (this.kycData?.isException !== true && !exceptionFields.isException) {
|
||||
this.loadKycData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientsService
|
||||
.updateClientKyc(this.clientId, this.kycId, {
|
||||
locale: 'en',
|
||||
dateFormat: 'dd MMMM yyyy',
|
||||
riskCategory: this.kycForm.value.riskCategory || 1,
|
||||
...exceptionFields
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
// The verification itself succeeded either way, so a failure here must not read as a
|
||||
// failed check — surface it as the save problem it is and still refresh.
|
||||
next: () => this.loadKycData(),
|
||||
error: (err: any) => {
|
||||
this.errorMessage =
|
||||
err?.error?.developerMessage ||
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.message ||
|
||||
'Verification succeeded but the KYC exception could not be updated.';
|
||||
this.loadKycData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
|
||||
*
|
||||
@@ -786,6 +887,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
this.verificationError.aadhaar = null;
|
||||
}
|
||||
|
||||
/** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */
|
||||
cancelAadhaarReVerify(): void {
|
||||
this.resetOtpState();
|
||||
this.verificationError.aadhaar = null;
|
||||
}
|
||||
|
||||
/** Step 1: Send OTP to Aadhaar-linked mobile */
|
||||
sendAadhaarOtp(): void {
|
||||
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
|
||||
@@ -855,7 +962,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
|
||||
if (this.aadhaarReceiptTaken) {
|
||||
this.writeAadhaarReceipt(null);
|
||||
} else {
|
||||
this.loadKycData();
|
||||
this.syncExceptionAfterVerify();
|
||||
}
|
||||
} else {
|
||||
this.verificationStatus.aadhaar = 'FAILED';
|
||||
|
||||
@@ -536,6 +536,17 @@ export class ClientsService {
|
||||
return this.http.post(`/clients/${clientId}/identifiers`, identifierData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an identifier in place — the document number, its type, or the description.
|
||||
*
|
||||
* Without this the only way to record a re-collected document was to post a new identifier, so
|
||||
* a customer whose Aadhaar was re-verified ended up with two rows carrying the same number and
|
||||
* the same scans, and nothing saying which was current.
|
||||
*/
|
||||
updateClientIdentifier(clientId: string, identifierId: string, identifierData: any) {
|
||||
return this.http.put(`/clients/${clientId}/identifiers/${identifierId}`, identifierData);
|
||||
}
|
||||
|
||||
deleteClientIdentifier(clientId: string, identifierId: string) {
|
||||
return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`);
|
||||
}
|
||||
|
||||
@@ -1079,12 +1079,15 @@ export class EditClientComponent {
|
||||
*/
|
||||
private saveKycDocProof(clientId: string) {
|
||||
const payload = this.clientKycStep?.kycDocPayload;
|
||||
if (!payload?.proofId || !payload?.files?.length) return;
|
||||
// A document number corrected without re-scanning the document is still a change worth
|
||||
// saving, so this no longer requires files — it used to return here and the new number was
|
||||
// silently dropped.
|
||||
if (!payload?.proofId) return;
|
||||
|
||||
const proofName = payload.proof?.name || 'KYC Document';
|
||||
|
||||
const uploadFiles = (identifierId: string) => {
|
||||
for (const entry of payload.files) {
|
||||
for (const entry of payload.files || []) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${proofName} — ${entry.slot}`);
|
||||
formData.append('file', entry.file);
|
||||
@@ -1092,8 +1095,26 @@ export class EditClientComponent {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.clientKycStep?.existingKycIdentifierId) {
|
||||
uploadFiles(this.clientKycStep.existingKycIdentifierId);
|
||||
// A document already on file is updated in place, not posted again.
|
||||
//
|
||||
// Re-collecting a document used to create a second identifier carrying the same number and
|
||||
// the same scans, with nothing to say which of the two was current — the client in front of
|
||||
// this was left with two identical Aadhaar rows. The document the customer renewed is the
|
||||
// one already recorded, so its number and type are written back to that row and any new
|
||||
// scans are attached to it.
|
||||
const existingId = this.clientKycStep?.existingKycIdentifierId;
|
||||
if (existingId) {
|
||||
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
|
||||
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' };
|
||||
if (documentTypeId != null) {
|
||||
update.documentTypeId = documentTypeId;
|
||||
}
|
||||
this.clientsService.updateClientIdentifier(clientId, existingId, update).subscribe({
|
||||
// The scans go up either way: failing to rewrite the number must not cost the images.
|
||||
next: () => uploadFiles(existingId),
|
||||
error: () => uploadFiles(existingId)
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,23 @@ describe('deriveKycExceptionFields', () => {
|
||||
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
|
||||
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
|
||||
});
|
||||
|
||||
it('closes a record left open by a verification that passed after the exception was written', () => {
|
||||
// A verification writes the exception before calling the vendor, so a check that then passes
|
||||
// leaves `isException` set with the stale reason still loaded into the form's reason list.
|
||||
// Saving such a record has to close it, not re-raise it off its own stale reason.
|
||||
expect(
|
||||
deriveKycExceptionFields({
|
||||
...existingException(true, true, KYC_EXCEPTION_PAN),
|
||||
manualReasons: [KYC_EXCEPTION_PAN]
|
||||
})
|
||||
).toEqual({
|
||||
isException: false,
|
||||
exceptionReason: KYC_EXCEPTION_PAN,
|
||||
exceptionResolved: true,
|
||||
exceptionResolvedOn: TODAY
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opening', () => {
|
||||
|
||||
Reference in New Issue
Block a user