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);
}
}