feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
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
Tests / testRigor (push) Canceled after 0s

- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports.
- Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons.
- Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars.
- Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval.
- Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names.
- Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks.
- Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
This commit is contained in:
2026-08-14 10:44:00 +05:30
parent 8d67a22f18
commit 352a65cb5d
22 changed files with 2219 additions and 61 deletions
@@ -334,6 +334,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
alternateDipLookupLogId: number | null = null;
/** Alternate DIP lookup timestamp from API */
alternateDipTimestamp: string | null = null;
/**
* DIP/MNRL status the number actually returned (CLEAN / SUSPECTED / SERVICE_UNAVAILABLE).
* Kept separately from `mobileVerified` so an unverifiable number is not indistinguishable
* from one that genuinely passed.
*/
dipStatus: string | null = null;
alternateDipStatus: string | null = null;
/** Officer rejected the number outright — it must be replaced before saving. */
dipRejected = false;
alternateDipRejected = false;
/**
* CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the
@@ -530,8 +540,37 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
sanctionsVerified = false;
/** Sanctions match was found — blocks account opening */
sanctionsBlocked = false;
/**
* Outcome recorded against a match: 'FALSE_POSITIVE' (awaiting Principal Officer approval)
* or 'CONFIRMED_MATCH'. Null when the hit has not been reviewed yet. Both keep the client
* blocked — only the Principal Officer's approval, applied server-side, releases a name.
*/
sanctionsDisposition: string | null = null;
/**
* The name was edited after a block, so the applicant may no longer be the flagged one.
* Re-exposes the verify button; the block itself is only lifted by a CLEAR from the API.
*/
sanctionsNeedsRescreen = false;
/** Screening log ID from sanctions API */
screeningLogId: number | null = null;
/** Cites the direction actually in force — kept in step with the verification dialog. */
readonly sanctionsCitation = 'UAPA 1967 s.51-A & RBI KYC Directions, 2025 — Chapter IX';
readonly sanctionsTooltip =
'All customer names must be verified against the UN Security Council Consolidated List before account ' +
'opening and periodically thereafter. A match is not a rejection: compare the identifiers, record a ' +
'false positive with reasons, or confirm the match and escalate to the Principal Officer immediately.';
/** Badge text reflecting how far the flagged alert has progressed. */
get sanctionsStatusLabel(): string {
if (this.sanctionsDisposition === 'FALSE_POSITIVE') {
return 'Awaiting PO Approval';
}
if (this.sanctionsDisposition === 'CONFIRMED_MATCH') {
return 'Confirmed Match';
}
return 'Sanctions Match';
}
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** True while prefillForEdit() is patching historical monthly/annual values — see linkMonthlyAnnual(). */
@@ -665,13 +704,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.mobileVerified = false;
this.dipLookupLogId = null;
this.dipTimestamp = null;
this.dipStatus = null;
this.dipRejected = false;
this.alternateMobileVerified = false;
this.alternateDipLookupLogId = null;
this.alternateDipTimestamp = null;
this.alternateDipStatus = null;
this.alternateDipRejected = false;
this.mobileNewOtpId = null;
this.emailNewOtpId = null;
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.sanctionsDisposition = null;
this.sanctionsNeedsRescreen = false;
this.screeningLogId = null;
this.verifiedName = '';
}
@@ -1268,6 +1313,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.mobileVerified = false;
// The rejection applied to the previous number, not this one. Leaving it set would
// keep Save & Next dead with a message about a number no longer in the field.
this.dipRejected = false;
this.dipStatus = null;
this.dipLookupLogId = null;
// Also reset create-time contact-OTP verification — an OTP verified against a
// previous mobile number no longer applies once the value is edited. Belt-and-braces
// alongside ContactOtpBlockComponent's own destination-change reset: this fires even
@@ -1282,6 +1332,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.alternateMobileVerified = false;
this.alternateDipRejected = false;
this.alternateDipStatus = null;
this.alternateDipLookupLogId = null;
});
// Reset create-time contact-OTP verification when email address changes — see the
@@ -1322,9 +1375,11 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
if (result.verified) {
this.mobileVerified = true;
}
// A flagged number the officer chose to proceed with counts as verified — the decision and
// its reason are already stored against the lookup, which is what the save gate checks.
this.mobileVerified = result.verified === true || result.decision === 'PROCEEDED';
this.dipRejected = result.decision === 'REJECTED';
this.dipStatus = result.status || null;
this.dipLookupLogId = result.lookupLogId || null;
this.dipTimestamp = result.timestamp || null;
});
@@ -1342,9 +1397,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
if (result.verified) {
this.alternateMobileVerified = true;
}
this.alternateMobileVerified = result.verified === true || result.decision === 'PROCEEDED';
this.alternateDipRejected = result.decision === 'REJECTED';
this.alternateDipStatus = result.status || null;
this.alternateDipLookupLogId = result.lookupLogId || null;
this.alternateDipTimestamp = result.timestamp || null;
});
@@ -1387,10 +1442,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
*/
verifySanctions() {
const name = this.currentNameSnapshot();
const dob = this.createClientForm.get('dateOfBirth')?.value;
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
width: '550px',
data: { name }
width: '620px',
data: {
name,
// The dialog compares these against the listed particulars — a name-only hit cannot be
// judged without the applicant's own identifiers next to it.
dateOfBirth: dob ? new Date(dob).toLocaleDateString('en-IN') : '',
nationality: this.nationalityLabel()
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result) return;
@@ -1399,16 +1461,26 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// CLEAR — no match found
this.sanctionsVerified = true;
this.sanctionsBlocked = false;
this.sanctionsDisposition = null;
this.sanctionsNeedsRescreen = false;
this.verifiedName = name;
} else if (result.verified === false) {
// FLAGGED — match found, block account opening
// FLAGGED — blocked either way. A FALSE_POSITIVE is a claim awaiting the Principal
// Officer, not a clearance; only their approval lets this name through.
this.sanctionsVerified = false;
this.sanctionsBlocked = true;
this.sanctionsDisposition = result.disposition || null;
this.sanctionsNeedsRescreen = false;
this.verifiedName = '';
}
});
}
/** Human-readable nationality for the sanctions comparison panel. */
private nationalityLabel(): string {
return this.getSelectedNationality()?.label || '';
}
/** Concatenated current name from the form (handles individual + entity) */
private currentNameSnapshot(): string {
const firstname = this.createClientForm.get('firstname')?.value || '';
@@ -1448,9 +1520,14 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Only invalidate if a verification was previously recorded
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
if (this.currentNameSnapshot() !== this.verifiedName) {
// Editing the name forces a fresh screening, but must NOT silently clear an existing
// block: otherwise a flagged applicant is waved through by nudging one character. The
// block is only lifted by re-running the check and getting a CLEAR back from the API.
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.screeningLogId = null;
if (this.sanctionsBlocked) {
this.sanctionsNeedsRescreen = true;
}
}
});
}