feat: update kyc tab, branch day register, and dormancy reports
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s

This commit is contained in:
2026-09-10 18:29:33 +05:30
parent f0c06988c0
commit fd5fe6e3b2
16 changed files with 595 additions and 104 deletions
@@ -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';
+11
View File
@@ -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', () => {