feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
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

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -0,0 +1,84 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="aad-rcpt">
<div class="aad-rcpt__header">
<mat-icon class="aad-rcpt__header-icon">receipt_long</mat-icon>
<div>
<div class="aad-rcpt__title">Aadhaar Enrolment Receipt</div>
<div class="aad-rcpt__subtitle">{{ data.clientName || 'Client' }}</div>
</div>
</div>
<mat-dialog-content class="aad-rcpt__body">
<div class="aad-rcpt__reason">
<mat-icon>info</mat-icon>
<span>{{ data.reason }}</span>
</div>
<p class="aad-rcpt__intro">
For a customer who has applied for Aadhaar but has not been issued one yet, there is no Aadhaar number to run the
OTP check against. Record the UIDAI enrolment slip instead — the client can then be created without an Aadhaar KYC
exception being raised.
</p>
<mat-form-field appearance="outline" class="aad-rcpt__field">
<mat-label>Enrolment number (EID)</mat-label>
<span matPrefix class="aad-rcpt__prefix"><mat-icon>confirmation_number</mat-icon></span>
<input matInput [(ngModel)]="enrolmentId" placeholder="1234/56789/01234" maxlength="20" autocomplete="off" />
<mat-hint [class.aad-rcpt__hint--bad]="enrolmentIdEntered && !enrolmentIdValid">
{{ enrolmentIdHint }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="aad-rcpt__field">
<mat-label>Enrolment date</mat-label>
<input matInput [matDatepicker]="enrolPicker" [max]="today" [(ngModel)]="enrolmentDate" />
<mat-datepicker-toggle matSuffix [for]="enrolPicker"></mat-datepicker-toggle>
<mat-datepicker #enrolPicker></mat-datepicker>
<mat-hint>Date printed on the slip</mat-hint>
</mat-form-field>
@if (monthsSinceEnrolment !== null) {
<div class="aad-rcpt__age" [class.aad-rcpt__age--stale]="receiptIsStale">
<mat-icon>{{ receiptIsStale ? 'warning' : 'schedule' }}</mat-icon>
<span>
@if (receiptIsStale) {
Enrolled {{ monthsSinceEnrolment }} months ago. An enrolment normally completes well inside six months —
chase the customer for the Aadhaar itself. The receipt can still be recorded.
} @else {
Enrolled {{ monthsSinceEnrolment }} month{{ monthsSinceEnrolment === 1 ? '' : 's' }} ago.
}
</span>
</div>
}
<mat-form-field appearance="outline" class="aad-rcpt__remarks">
<mat-label>Remarks</mat-label>
<textarea
matInput
rows="3"
[(ngModel)]="remarks"
maxlength="500"
placeholder="What was sighted, and when the Aadhaar is expected"
></textarea>
<mat-hint>{{ remarks.length }}/500</mat-hint>
</mat-form-field>
<mat-checkbox class="aad-rcpt__sighted" [(ngModel)]="sighted">
The original UIDAI enrolment slip has been sighted and a copy filed with the account opening form.
</mat-checkbox>
</mat-dialog-content>
<mat-dialog-actions align="end" class="aad-rcpt__actions">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
Save Receipt
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,136 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.aad-rcpt {
max-width: 560px;
}
.aad-rcpt__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 12px;
border-bottom: 1px solid #bfdbfe;
background: #eff6ff;
}
.aad-rcpt__header-icon {
color: #2563eb;
flex: 0 0 auto;
}
.aad-rcpt__title {
font-size: 16px;
font-weight: 600;
color: #1e3a8a;
}
.aad-rcpt__subtitle {
font-size: 12px;
color: #2563eb;
}
.aad-rcpt__body {
padding: 18px 24px !important;
font-size: 14px;
color: #475569;
}
.aad-rcpt__reason {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 13px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #64748b;
}
}
.aad-rcpt__intro {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
}
.aad-rcpt__field,
.aad-rcpt__remarks {
width: 100%;
}
.aad-rcpt__prefix {
display: inline-flex;
align-items: center;
margin-right: 8px;
color: #94a3b8;
}
.aad-rcpt__hint--bad {
color: #dc2626;
}
// Age of the slip — informational, so it reads as context rather than a blocking error.
.aad-rcpt__age {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 2px 0 14px;
padding: 9px 12px;
border-radius: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
font-size: 12.5px;
line-height: 1.5;
color: #475569;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #64748b;
}
&--stale {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
mat-icon {
color: #d97706;
}
}
}
.aad-rcpt__sighted {
display: block;
margin-top: 4px;
font-size: 13px;
line-height: 1.5;
::ng-deep .mdc-form-field > label {
white-space: normal;
}
}
.aad-rcpt__actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
gap: 8px;
}
@@ -0,0 +1,151 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export interface AadhaarReceiptDialogData {
/** Client name, shown so the officer can check the slip belongs to the right person. */
clientName: string;
/** Message the Aadhaar step came back with, or why the officer is recording a receipt. */
reason: string;
/** Existing receipt, when the officer re-opens one already taken. */
existing?: AadhaarReceiptResult | null;
}
export interface AadhaarReceiptResult {
/** 14-digit enrolment number as printed on the slip, separators kept. */
enrolmentId: string;
enrolmentDate: Date | null;
remarks: string;
}
/**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
*
* A customer who has applied for Aadhaar but has not been issued one holds only an enrolment slip.
* There is no Aadhaar number to send to the OTP flow, so no amount of retrying will clear the
* check — the same dead end the PAN declaration resolves. Recording the slip lets the client be
* created and waives the Aadhaar exception, while Aadhaar itself stays unverified.
*
* Only the 14-digit enrolment number is taken. The date and time printed beside it on the slip are
* captured by the enrolment-date field, so asking for them again in the same box would just be a
* second chance to mistype them.
*
* The number is validated on digit count rather than an exact layout: slips print it with varying
* separators, and rejecting one because of a slash or a space would block a customer over
* formatting rather than substance.
*/
@Component({
selector: 'mifosx-aadhaar-receipt-dialog',
templateUrl: './aadhaar-receipt-dialog.component.html',
styleUrls: ['./aadhaar-receipt-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class AadhaarReceiptDialogComponent {
data = inject<AadhaarReceiptDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<AadhaarReceiptDialogComponent>);
enrolmentId = '';
enrolmentDate: Date | null = null;
remarks = '';
/** The officer confirms the original slip was sighted — nothing is saved without it. */
sighted = false;
today = new Date();
constructor() {
const existing = this.data.existing;
if (existing) {
this.enrolmentId = existing.enrolmentId || '';
this.enrolmentDate = existing.enrolmentDate || null;
this.remarks = existing.remarks || '';
}
}
/** Digits only — separators on the printed slip are cosmetic and vary between forms. */
private get enrolmentDigits(): string {
return (this.enrolmentId || '').replace(/\D/g, '');
}
get enrolmentIdEntered(): boolean {
return this.enrolmentDigits.length > 0;
}
/** UIDAI's enrolment number is 14 digits — the timestamp beside it is not part of it. */
get enrolmentIdValid(): boolean {
return this.enrolmentDigits.length === 14;
}
/** How many digits are still missing, so the officer is told what to fix, not just that it failed. */
get enrolmentIdHint(): string {
const count = this.enrolmentDigits.length;
if (!count) {
return 'The 14-digit enrolment number only, e.g. 1234/56789/01234';
}
return `${count} of 14 digits`;
}
/** A slip dated in the future is a keying error, not a valid enrolment. */
get enrolmentDateValid(): boolean {
return !!this.enrolmentDate && this.enrolmentDate.getTime() <= this.today.getTime();
}
/** Whole months since enrolment — shown because a receipt is temporary by nature. */
get monthsSinceEnrolment(): number | null {
if (!this.enrolmentDate) {
return null;
}
const months =
(this.today.getFullYear() - this.enrolmentDate.getFullYear()) * 12 +
(this.today.getMonth() - this.enrolmentDate.getMonth()) -
(this.today.getDate() < this.enrolmentDate.getDate() ? 1 : 0);
return Math.max(0, months);
}
/**
* An enrolment normally completes well inside six months, so an older slip is a prompt to chase
* the customer for the Aadhaar itself. It is a warning, not a block: the branch may have good
* reason to accept it, and the record shows the age either way.
*/
get receiptIsStale(): boolean {
const months = this.monthsSinceEnrolment;
return months !== null && months >= 6;
}
get canSubmit(): boolean {
return this.enrolmentIdValid && this.enrolmentDateValid && !!this.remarks.trim() && this.sighted;
}
submit(): void {
if (!this.canSubmit) {
return;
}
this.dialogRef.close({
enrolmentId: this.enrolmentId.trim(),
enrolmentDate: this.enrolmentDate,
remarks: this.remarks.trim()
} as AadhaarReceiptResult);
}
}
@@ -84,6 +84,121 @@ export class KycUploadHelperService {
);
}
/**
* Split a stacked front-above-back scan back into its two sides.
*
* Clients saved before the sides were stored separately carry one merged PNG, so a form that
* wants a front image and a back image has nothing to show in the second slot. `mergeFrontBack`
* lays the sides on a white canvas with a 20px gap between them, which leaves a band of
* completely white rows across the full width — that band is the seam, and it is the only thing
* in the file that says where one side ends and the other begins.
*
* Resolves null when no such seam is found, which is the honest answer for an image that was not
* produced by that merge: better to show one scan than to cut a document in half at a guess.
*/
splitFrontBack(blob: Blob): Promise<{ front: Blob; back: Blob } | null> {
if (!blob.type.startsWith('image/')) {
return Promise.resolve(null);
}
type Sides = { front: Blob; back: Blob } | null;
return this.blobToImage(blob)
.then((img): Sides | Promise<Sides> => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx || canvas.width === 0 || canvas.height === 0) {
return null;
}
ctx.drawImage(img, 0, 0);
const seam = this.findWhiteSeam(ctx, canvas.width, canvas.height);
if (!seam) {
return null;
}
return Promise.all([
this.cropToBlob(img, canvas.width, 0, seam.start),
this.cropToBlob(img, canvas.width, seam.end, canvas.height - seam.end)
]).then(
([
front,
back
]): Sides => (front && back ? { front, back } : null)
);
})
.catch((): Sides => null);
}
/**
* The tallest run of all-white rows in the middle of the image.
*
* Confined to the middle half because a scan often has white margins at the top and bottom, and
* those are part of the document rather than the join between two of them.
*/
private findWhiteSeam(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): { start: number; end: number } | null {
const from = Math.floor(height * 0.25);
const to = Math.ceil(height * 0.75);
const step = Math.max(1, Math.floor(width / 64)); // sampling is enough to spot a non-white row
let bestStart = -1;
let bestLength = 0;
let runStart = -1;
for (let y = from; y <= to; y++) {
let white = y < to;
if (white) {
const row = ctx.getImageData(0, y, width, 1).data;
for (let x = 0; x < width; x += step) {
const i = x * 4;
if (row[i] < 245 || row[i + 1] < 245 || row[i + 2] < 245) {
white = false;
break;
}
}
}
if (white) {
if (runStart < 0) {
runStart = y;
}
} else if (runStart >= 0) {
if (y - runStart > bestLength) {
bestStart = runStart;
bestLength = y - runStart;
}
runStart = -1;
}
}
// The gap the merge inserts is 20px; anything much thinner is white space inside a document.
return bestLength >= 12 ? { start: bestStart, end: bestStart + bestLength } : null;
}
private cropToBlob(img: HTMLImageElement, width: number, top: number, height: number): Promise<Blob | null> {
if (height <= 0) {
return Promise.resolve(null);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve(null);
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, top, width, height, 0, 0, width, height);
return new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/png'));
}
private blobToImage(blob: Blob): Promise<HTMLImageElement> {
return this.fileToImage(blob as File);
}
private fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
@@ -0,0 +1,176 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="pan-decl">
<div class="pan-decl__header">
<mat-icon class="pan-decl__header-icon">assignment_late</mat-icon>
<div>
<div class="pan-decl__title">PAN Verification Failed — Take Declaration</div>
<div class="pan-decl__subtitle">PAN {{ data.panNumber }}</div>
</div>
</div>
<mat-dialog-content class="pan-decl__body">
<div class="pan-decl__reason">
<mat-icon>info</mat-icon>
<span>{{ data.failureMessage }}</span>
</div>
@if (!data.unverifiable) {
<p class="pan-decl__intro">
The Income Tax check matches on <strong>name and date of birth together</strong> and does not report which of
the two differs. Enter the details exactly as printed on the customer's PAN card so the declaration records what
actually does not match.
</p>
<!-- Comparison: what we submitted vs what the PAN card says -->
<div class="pan-decl__compare">
<div class="pan-decl__compare-head">
<span class="lbl"></span>
<span class="col">Submitted</span>
<span class="col">As on PAN card</span>
<span class="res"></span>
</div>
<div class="pan-decl__compare-row" [class.mismatch]="nameMismatch">
<span class="lbl">Name</span>
<span class="col submitted">{{ data.submittedName || '—' }}</span>
<span class="col">
<mat-form-field appearance="outline" class="pan-decl__field">
<mat-label>Name on PAN</mat-label>
<input
matInput
[(ngModel)]="nameOnPan"
(ngModelChange)="onCompare()"
placeholder="e.g. RAJESH KUMAR SHARMA"
maxlength="200"
autocomplete="off"
/>
</mat-form-field>
</span>
<span class="res">
@if (compared && nameOnPan.trim()) {
@if (nameMismatch) {
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
} @else {
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
}
}
</span>
</div>
<div class="pan-decl__compare-row" [class.mismatch]="dobMismatch">
<span class="lbl">Date of Birth</span>
<span class="col submitted">{{ data.submittedDob || '—' }}</span>
<span class="col">
<mat-form-field appearance="outline" class="pan-decl__field">
<mat-label>DOB on PAN</mat-label>
<input
matInput
[matDatepicker]="dobPicker"
[max]="today"
[(ngModel)]="dobOnPan"
(ngModelChange)="onCompare()"
/>
<mat-datepicker-toggle matSuffix [for]="dobPicker"></mat-datepicker-toggle>
<mat-datepicker #dobPicker></mat-datepicker>
</mat-form-field>
</span>
<span class="res">
@if (compared && dobOnPan) {
@if (dobMismatch) {
<mat-icon class="res-icon res-icon--bad" aria-label="Does not match">cancel</mat-icon>
} @else {
<mat-icon class="res-icon res-icon--ok" aria-label="Matches">check_circle</mat-icon>
}
}
</span>
</div>
</div>
<!-- What the declaration will state -->
<div
class="pan-decl__verdict"
[class.pan-decl__verdict--pending]="!comparisonComplete"
[class.pan-decl__verdict--clean]="nothingToDeclare"
>
<span class="pan-decl__verdict-label">Does not match:</span>
<span class="pan-decl__verdict-value">{{ mismatchLabel }}</span>
</div>
@if (!comparisonComplete) {
<p class="pan-decl__note">Enter both PAN-card values to see exactly which detail differs.</p>
}
@if (nothingToDeclare) {
<p class="pan-decl__warn">
<mat-icon>warning</mat-icon>
The PAN-card details you entered are the same as the ones submitted, so there is nothing to declare. Correct
the client's name or date of birth on the General step and verify PAN again.
</p>
}
} @else {
<p class="pan-decl__intro">
PAN could not be checked against the Income Tax records. Record the declaration taken from the customer so the
client can be created without a KYC exception being raised for PAN.
</p>
}
<!-- The signed paper is the declaration; it is uploaded, not described. -->
<div class="pan-decl__upload">
<div class="pan-decl__upload-label">Signed declaration</div>
<p class="pan-decl__upload-help">
Get the declaration signed by the customer and upload the scanned copy. It is filed against the client as the
proof that holds the PAN exception off.
</p>
@if (!hasDeclarationFile) {
<label class="pan-decl__dropzone">
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
<mat-icon class="pan-decl__dropzone-icon">cloud_upload</mat-icon>
<span class="pan-decl__dropzone-text">Click to upload signed declaration</span>
<span class="pan-decl__dropzone-hint">PDF, JPG or PNG — up to 5 MB</span>
</label>
} @else {
<div class="pan-decl__file">
<mat-icon class="pan-decl__file-icon">description</mat-icon>
<span class="pan-decl__file-meta">
<span class="pan-decl__file-name">{{ fileName }}</span>
<span class="pan-decl__file-sub">{{ fileSizeLabel }}</span>
</span>
<label class="pan-decl__file-replace">
<input type="file" [accept]="acceptedTypes" (change)="onDeclarationFile($event)" hidden />
Replace
</label>
<button
type="button"
class="pan-decl__file-remove"
aria-label="Remove file"
(click)="removeDeclarationFile()"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
@if (fileError) {
<p class="pan-decl__file-error">
<mat-icon>error_outline</mat-icon>
<span>{{ fileError }}</span>
</p>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="pan-decl__actions">
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
Save Declaration
</button>
</mat-dialog-actions>
</div>
@@ -0,0 +1,361 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.pan-decl {
max-width: 720px;
}
.pan-decl__header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px 12px;
border-bottom: 1px solid #fde68a;
background: #fffbeb;
}
.pan-decl__header-icon {
color: #d97706;
flex: 0 0 auto;
}
.pan-decl__title {
font-size: 16px;
font-weight: 600;
color: #92400e;
}
.pan-decl__subtitle {
font-size: 12px;
color: #b45309;
letter-spacing: 0.03em;
}
.pan-decl__body {
padding: 18px 24px !important;
font-size: 14px;
color: #475569;
}
.pan-decl__reason {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
font-size: 13px;
line-height: 1.5;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
.pan-decl__intro {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.6;
}
// ── Submitted vs PAN-card comparison ───────────────────────────────────────
.pan-decl__compare {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.pan-decl__compare-head,
.pan-decl__compare-row {
display: grid;
grid-template-columns: 110px 1fr 1fr 36px;
gap: 12px;
align-items: center;
padding: 8px 14px;
}
.pan-decl__compare-head {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__compare-row {
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
// A row the declaration will name — tinted so the officer can see the finding at a glance.
&.mismatch {
background: #fff7ed;
}
.lbl {
font-size: 13px;
font-weight: 500;
color: #334155;
}
.submitted {
font-size: 13px;
color: #0f172a;
word-break: break-word;
}
}
.pan-decl__field {
width: 100%;
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.res {
display: flex;
justify-content: center;
}
.res-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.res-icon--ok {
color: #16a34a;
}
.res-icon--bad {
color: #dc2626;
}
// ── Verdict ────────────────────────────────────────────────────────────────
.pan-decl__verdict {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 14px;
padding: 10px 14px;
border-radius: 6px;
background: #fef2f2;
border: 1px solid #fecaca;
&--pending {
background: #f8fafc;
border-color: #e2e8f0;
}
&--clean {
background: #f0fdf4;
border-color: #bbf7d0;
}
}
.pan-decl__verdict-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__verdict-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.pan-decl__note {
margin: 8px 0 0;
font-size: 12px;
color: #64748b;
}
.pan-decl__warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 10px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #b45309;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
// ── Signed declaration upload ──────────────────────────────────────────────
.pan-decl__upload {
margin-top: 18px;
}
.pan-decl__upload-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.pan-decl__upload-help {
margin: 4px 0 10px;
font-size: 12.5px;
line-height: 1.5;
color: #64748b;
}
.pan-decl__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 18px 24px;
background: #fff;
border: 2px dashed #cbd5e1;
border-radius: 10px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
&:hover {
background: #f8fafc;
border-color: #93c5fd;
}
}
.pan-decl__dropzone-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: #94a3b8;
}
.pan-decl__dropzone-text {
font-size: 13px;
font-weight: 600;
color: #475569;
}
.pan-decl__dropzone-hint {
font-size: 11px;
color: #94a3b8;
}
.pan-decl__file {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.pan-decl__file-icon {
color: #16a34a;
flex: 0 0 auto;
}
.pan-decl__file-meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.pan-decl__file-name {
font-size: 13px;
font-weight: 600;
color: #166534;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pan-decl__file-sub {
font-size: 11px;
color: #4d7c69;
}
.pan-decl__file-replace {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid #86efac;
border-radius: 6px;
background: #fff;
font-size: 12px;
font-weight: 600;
color: #166534;
cursor: pointer;
&:hover {
background: #ecfdf5;
}
}
.pan-decl__file-remove {
flex: 0 0 auto;
display: flex;
align-items: center;
padding: 4px;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #dc2626;
}
}
.pan-decl__file-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #b91c1c;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
}
.pan-decl__actions {
padding: 10px 24px 16px !important;
margin: 0 !important;
gap: 8px;
}
@@ -0,0 +1,271 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Which client details differ from the PAN card. */
export type PanMismatchField = 'NAME' | 'DOB';
export interface PanDeclarationDialogData {
/** Name submitted on the General step — the value sent to the Income Tax check. */
submittedName: string;
/** Date of birth submitted on the General step, in the tenant's display format. */
submittedDob: string;
/** PAN being declared against. */
panNumber: string;
/** Message the verification came back with, shown so the officer knows why they are here. */
failureMessage: string;
/**
* True when the check failed on something other than name/DOB (invalid PAN, vendor unreachable).
* The PAN-card fields then have nothing to compare against, so the mismatch reads NONE and the
* officer only has to record why the declaration is being taken.
*/
unverifiable?: boolean;
/** Existing declaration, when the officer re-opens one already taken. */
existing?: PanDeclarationResult | null;
}
export interface PanDeclarationResult {
/** 'NAME', 'DOB', 'NAME,DOB', or 'NONE' when PAN could not be checked at all. */
mismatchFields: string;
nameOnPan: string | null;
dobOnPan: Date | null;
/**
* The scanned signed declaration, to be filed against the client. Null when an existing
* declaration was re-opened and the officer did not replace the document already on file.
*/
file: File | null;
/** Name of the declaration document on record — what the KYC record keeps as the reference. */
fileName: string;
}
/**
* Records the customer declaration that stands in for a passing PAN check.
*
* The Income Tax API matches on name *and* date of birth and tells us only that the pair failed —
* never which half. So rather than show the officer a dead-end error, this dialog asks them to key
* in the two values printed on the physical PAN card in front of them. Comparing those against what
* was submitted is what produces a specific, defensible "NAME does not match" on the declaration,
* and the PAN-card values are kept alongside it as the evidence for that statement.
*
* Name comparison is deliberately loose about case and internal spacing — "RAJESH KUMAR" and
* "Rajesh Kumar" are the same name, and flagging them as a mismatch would put a declaration on file
* that says something untrue.
*
* The declaration itself is taken as an upload rather than typed: what waives the PAN exception is
* the paper the customer signed, so the scan of it is the record — an officer's typed note is not.
*/
@Component({
selector: 'mifosx-pan-declaration-dialog',
templateUrl: './pan-declaration-dialog.component.html',
styleUrls: ['./pan-declaration-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatIcon,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class PanDeclarationDialogComponent {
data = inject<PanDeclarationDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<PanDeclarationDialogComponent>);
/** Name exactly as printed on the PAN card. */
nameOnPan = '';
/** Date of birth exactly as printed on the PAN card. */
dobOnPan: Date | null = null;
/** The scan the officer just picked. Null while an earlier upload is still the one on file. */
declarationFile: File | null = null;
/** Name of the document already filed, when re-opening a declaration that was taken before. */
existingFileName = '';
/** Why the picked file was rejected — shown in place of failing silently. */
fileError = '';
/** Officer has not yet typed anything to compare against. */
compared = false;
today = new Date();
readonly acceptedTypes = '.pdf,.jpg,.jpeg,.png';
private readonly maxFileBytes = 5 * 1024 * 1024;
private readonly acceptedExtensions = [
'pdf',
'jpg',
'jpeg',
'png'
];
constructor() {
const existing = this.data.existing;
if (existing) {
this.nameOnPan = existing.nameOnPan || '';
this.dobOnPan = existing.dobOnPan || null;
this.existingFileName = existing.fileName || '';
this.compared = true;
}
}
/** The document the declaration will be saved against — newly picked, or the one already filed. */
get fileName(): string {
return this.declarationFile?.name || this.existingFileName;
}
get hasDeclarationFile(): boolean {
return !!this.fileName;
}
/** Size of the picked scan, or a note that the document on file is being kept as it is. */
get fileSizeLabel(): string {
if (!this.declarationFile) {
return 'Already on file';
}
const kb = this.declarationFile.size / 1024;
return kb < 1024 ? `${Math.max(1, Math.round(kb))} KB` : `${(kb / 1024).toFixed(1)} MB`;
}
onDeclarationFile(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] || null;
input.value = ''; // allow re-picking the same filename
if (!file) {
return;
}
const extension = (file.name.split('.').pop() || '').toLowerCase();
if (!this.acceptedExtensions.includes(extension)) {
this.fileError = 'Upload the signed declaration as a PDF, JPG or PNG.';
return;
}
if (file.size > this.maxFileBytes) {
this.fileError = 'The file is larger than 5 MB. Scan at a lower resolution and try again.';
return;
}
this.fileError = '';
this.declarationFile = file;
}
/** Drops the picked scan — and the one already filed, since neither may be left implied. */
removeDeclarationFile(): void {
this.declarationFile = null;
this.existingFileName = '';
this.fileError = '';
}
/** Case- and spacing-insensitive: only a genuinely different name counts as a mismatch. */
private normalizeName(value: string): string {
return (value || '').trim().replace(/\s+/g, ' ').toUpperCase();
}
private formatDob(value: Date | null): string {
if (!value) {
return '';
}
const dd = value.getDate().toString().padStart(2, '0');
const mm = (value.getMonth() + 1).toString().padStart(2, '0');
return `${dd}/${mm}/${value.getFullYear()}`;
}
get nameMismatch(): boolean {
if (this.data.unverifiable || !this.nameOnPan.trim()) {
return false;
}
return this.normalizeName(this.nameOnPan) !== this.normalizeName(this.data.submittedName);
}
get dobMismatch(): boolean {
if (this.data.unverifiable || !this.dobOnPan) {
return false;
}
// Compare on the rendered day/month/year so a time component on either value cannot matter.
return this.formatDob(this.dobOnPan) !== this.normalizeDisplayDob(this.data.submittedDob);
}
/** The submitted DOB arrives pre-formatted for display; reduce it to dd/MM/yyyy for comparison. */
private normalizeDisplayDob(display: string): string {
const parsed = new Date(display);
return isNaN(parsed.getTime()) ? (display || '').trim() : this.formatDob(parsed);
}
/** The fields the declaration will state as differing. */
get mismatchFields(): PanMismatchField[] {
const fields: PanMismatchField[] = [];
if (this.nameMismatch) {
fields.push('NAME');
}
if (this.dobMismatch) {
fields.push('DOB');
}
return fields;
}
get mismatchLabel(): string {
if (this.data.unverifiable) {
return 'PAN could not be verified';
}
const fields = this.mismatchFields;
if (!fields.length) {
return this.compared ? 'No difference found' : 'Not yet compared';
}
return fields.map((f) => (f === 'NAME' ? 'Name' : 'Date of Birth')).join(' and ');
}
/** Both PAN-card values must be keyed in before we can honestly say what differs. */
get comparisonComplete(): boolean {
return !!this.nameOnPan.trim() && !!this.dobOnPan;
}
onCompare(): void {
this.compared = true;
}
/**
* A declaration must actually declare something. When the officer keys in PAN-card values that
* match what was submitted, the mismatch is somewhere else — nothing to sign off here, and the
* right move is to retry the verification rather than paper over it.
*/
get nothingToDeclare(): boolean {
return !this.data.unverifiable && this.comparisonComplete && this.mismatchFields.length === 0;
}
get canSubmit(): boolean {
// The signed scan is the declaration — without it there is nothing to waive the exception on.
if (!this.hasDeclarationFile) {
return false;
}
if (this.data.unverifiable) {
return true;
}
return this.comparisonComplete && this.mismatchFields.length > 0;
}
submit(): void {
if (!this.canSubmit) {
return;
}
const result: PanDeclarationResult = {
mismatchFields: this.data.unverifiable ? 'NONE' : this.mismatchFields.join(','),
nameOnPan: this.data.unverifiable ? null : this.nameOnPan.trim(),
dobOnPan: this.data.unverifiable ? null : this.dobOnPan,
file: this.declarationFile,
fileName: this.fileName
};
this.dialogRef.close(result);
}
}