feat: add database export, welcome kit dialog, and DIP/sanctions updates
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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:52:28 +05:30
co-authored by Claude Opus 5
parent c18219fce5
commit 7ef75c902d
33 changed files with 3042 additions and 208 deletions
@@ -283,26 +283,61 @@
</div>
<div class="pd-section__body">
<div class="pd-grid">
<!-- Primary Mobile DIP -->
<!--
Primary Mobile DIP. Driven by dipLiveStatus — the latest lookup — not by dipVerified,
which is frozen at onboarding and would keep reporting "Verified" long after the number
was reported for fraud or reassigned to someone else.
-->
<div class="pd-field">
<div class="pd-field__label">Mobile DIP Status</div>
<div class="pd-field__value">
@if (
clientViewData.mobileVerified === true ||
clientViewData.mobileVerified === 'true' ||
clientViewData.mobileVerified === 1 ||
clientViewData.dipVerified === true ||
clientViewData.dipVerified === 'true' ||
clientViewData.dipVerified === 1
) {
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ dipStatusLabel(clientViewData) }}
</span>
} @else if (isDipClear(clientViewData)) {
<span class="pd-verify-badge pd-verify-badge--verified">
<i class="fa fa-check-circle"></i> Verified
</span>
} @else {
<span class="pd-verify-badge pd-verify-badge--pending"> <i class="fa fa-clock-o"></i> Pending </span>
<span class="pd-verify-badge pd-verify-badge--pending">
<i class="fa fa-clock-o"></i> {{ dipStatusLabel(clientViewData) }}
</span>
}
</div>
</div>
@if (isDipFlagged(clientViewData.dipLiveStatus)) {
<div class="pd-field pd-field--full">
<div class="pd-dip-alert">
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>This mobile number is flagged on the DoT Digital Intelligence Platform.</strong>
<div class="pd-dip-alert__detail">
OTPs and SMS alerts to this number are blocked. Contact the customer through an alternate channel and
obtain a new number. Account operations are not restricted.
</div>
</div>
</div>
</div>
}
<!--
A sanctions alert raised after onboarding is far more serious than a DIP hit and must be
impossible to miss on the customer's own page, not only in the Principal Officer's queue.
-->
@if (clientViewData.sanctionsAlertStatus) {
<div class="pd-field pd-field--full">
<div
class="pd-dip-alert"
[class.pd-dip-alert--review]="clientViewData.sanctionsAlertStatus === 'PENDING_REVIEW'"
>
<i class="fa fa-exclamation-triangle"></i>
<div>
<strong>{{ sanctionsAlertHeadline(clientViewData) }}</strong>
<div class="pd-dip-alert__detail">{{ sanctionsAlertDetail(clientViewData) }}</div>
</div>
</div>
</div>
}
@if (clientViewData.dipTimestamp) {
<div class="pd-field">
<div class="pd-field__label">DIP Verified On</div>
@@ -314,7 +349,12 @@
<div class="pd-field">
<div class="pd-field__label">Alternate Mobile DIP Status</div>
<div class="pd-field__value">
@if (
@if (isDipFlagged(clientViewData.alternateDipLiveStatus)) {
<span class="pd-verify-badge pd-verify-badge--flagged">
<i class="fa fa-exclamation-triangle"></i> {{ alternateDipStatusLabel(clientViewData) }}
</span>
} @else if (
clientViewData.alternateDipLiveStatus === 'CLEAN' ||
clientViewData.alternateDipVerified === true ||
clientViewData.alternateDipVerified === 'true' ||
clientViewData.alternateDipVerified === 1
@@ -248,9 +248,59 @@ $teal: #00897b;
border: 1px solid #fde68a;
}
&--danger {
&--danger,
&--flagged {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
}
// A DIP hit on an existing customer restricts their NUMBER, not their account, so it reads as a
// standing advisory across the full width rather than as a blocked-account state.
.pd-field--full {
grid-column: 1 / -1;
}
.pd-dip-alert {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fef2f2;
border: 1px solid #fecaca;
border-left: 4px solid #dc2626;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
color: #7f1d1d;
line-height: 1.5;
i {
color: #dc2626;
font-size: 15px;
margin-top: 2px;
flex-shrink: 0;
}
&__detail {
margin-top: 3px;
font-weight: 400;
color: #991b1b;
}
// An unreviewed hit is not yet a finding, so it reads as amber rather than red.
&--review {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
i {
color: #d97706;
}
.pd-dip-alert__detail {
color: #b45309;
}
}
}
@@ -32,6 +32,14 @@ interface ClientViewData {
displayName?: string;
mobileNo?: string;
emailAddress?: string;
/**
* Current DIP/MNRL status resolved from the latest lookup, as opposed to the `dipVerified`
* flag captured when the client was onboarded.
*/
dipLiveStatus?: string;
alternateDipLiveStatus?: string;
/** Disposition of the latest flagged sanctions alert, or absent when the client carries none. */
sanctionsAlertStatus?: string;
dateOfBirth?: number[];
gender?: { id: number; name: string; active?: boolean };
clientType?: { id: number; name: string; active?: boolean };
@@ -67,6 +75,91 @@ export class PersonalDataTabComponent {
/** Client View Data */
clientViewData!: ClientViewData;
/**
* Whether the number's CURRENT DIP/MNRL status is a hit.
*
* The onboarding-time `dipVerified` flag never changes, so a customer cleared when they joined
* kept reading "Verified" even after their number was reported for fraud or reassigned. These
* helpers read `dipLiveStatus`, which the API resolves from the most recent lookup.
*/
isDipFlagged(liveStatus: string | null | undefined): boolean {
return !!liveStatus && liveStatus !== 'CLEAN' && liveStatus !== 'ERROR';
}
/** True only when the number is affirmatively clean — not merely "not flagged". */
isDipClear(client: any): boolean {
if (client?.dipLiveStatus) {
return client.dipLiveStatus === 'CLEAN';
}
return (
client?.mobileVerified === true ||
client?.mobileVerified === 'true' ||
client?.mobileVerified === 1 ||
client?.dipVerified === true ||
client?.dipVerified === 'true' ||
client?.dipVerified === 1
);
}
dipStatusLabel(client: any): string {
return this.labelFor(client?.dipLiveStatus, this.isDipClear(client));
}
/** Headline for the sanctions banner, driven by how far the alert has progressed. */
sanctionsAlertHeadline(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return 'Confirmed UN Security Council sanctions match.';
case 'FALSE_POSITIVE':
return 'Sanctions hit reviewed as a false positive.';
default:
return 'Sanctions match awaiting Principal Officer review.';
}
}
sanctionsAlertDetail(client: any): string {
switch (client?.sanctionsAlertStatus) {
case 'CONFIRMED_MATCH':
return (
'Debits are frozen on this client’s deposit accounts and the case has been escalated. ' +
'Report to FIU-IND and the nodal officer within 24 hours if not already done.'
);
case 'FALSE_POSITIVE':
return 'Recorded as a different person from the listed individual. See the Sanctions Logs for the reasoning.';
default:
return (
'This client matched the UN Security Council Consolidated List on a later screening. ' +
'Review the alert under Reports → Sanctions Screening & Alerts before transacting.'
);
}
}
alternateDipStatusLabel(client: any): string {
const clear =
client?.alternateDipLiveStatus === 'CLEAN' ||
client?.alternateDipVerified === true ||
client?.alternateDipVerified === 'true' ||
client?.alternateDipVerified === 1;
return this.labelFor(client?.alternateDipLiveStatus, clear);
}
private labelFor(liveStatus: string | null | undefined, clear: boolean): string {
switch (liveStatus) {
case 'SUSPECTED':
return 'Flagged — Suspected';
case 'DISCONNECTED':
return 'Flagged — Disconnected';
case 'SUSPECTED_AND_DISCONNECTED':
return 'Flagged — Suspected & Disconnected';
case 'SERVICE_UNAVAILABLE':
return 'Not Verified';
case 'CLEAN':
return 'Verified';
default:
return clear ? 'Verified' : 'Pending';
}
}
constructor() {
this.route.parent.data.pipe(takeUntilDestroyed()).subscribe((data: { clientViewData: ClientViewData }) => {
this.clientViewData = data.clientViewData;