KYC management: stage-wise notices, pagination, RD base chart
This commit is contained in:
+308
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormGroupDirective,
|
||||
NgForm,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { ErrorStateMatcher } from '@angular/material/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { Observable, Subject, merge, of } from 'rxjs';
|
||||
import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { RestrictInputDirective } from '../../../directives/restrict-input.directive';
|
||||
import { ClientsService } from '../../clients.service';
|
||||
import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model';
|
||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
||||
|
||||
export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error';
|
||||
|
||||
/**
|
||||
* What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered
|
||||
* so the KYC step can be filled in without typing them again.
|
||||
*/
|
||||
export interface ClientIdentityState {
|
||||
status: ClientIdentityStatus;
|
||||
/** Individual: the person's PAN. Entity: the company's PAN. */
|
||||
pan: string | null;
|
||||
/** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */
|
||||
aadhaar: string | null;
|
||||
gstin: string | null;
|
||||
/** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */
|
||||
blocking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is
|
||||
* wrong should say so while it is being typed, and the form should not look fine in the meantime.
|
||||
*/
|
||||
class ShowErrorWhenDirty implements ErrorStateMatcher {
|
||||
isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean {
|
||||
return !!control && control.invalid && (control.dirty || control.touched);
|
||||
}
|
||||
}
|
||||
|
||||
const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/;
|
||||
const AADHAAR_PATTERN = /^\d{12}$/;
|
||||
const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/;
|
||||
|
||||
/**
|
||||
* "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved.
|
||||
*
|
||||
* Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means
|
||||
* the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the
|
||||
* KYC step so it does not have to be typed again.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-client-identity-check',
|
||||
templateUrl: './client-identity-check.component.html',
|
||||
styleUrls: ['./client-identity-check.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
RestrictInputDirective
|
||||
]
|
||||
})
|
||||
export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private clientsService = inject(ClientsService);
|
||||
private router = inject(Router);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
/** 1 = individual, 2 = entity (non-individual). */
|
||||
@Input() legalFormType = 1;
|
||||
@Output() identityChange = new EventEmitter<ClientIdentityState>();
|
||||
|
||||
form: UntypedFormGroup;
|
||||
readonly errorMatcher = new ShowErrorWhenDirty();
|
||||
status: ClientIdentityStatus = 'idle';
|
||||
matches: ClientIdentityMatch[] = [];
|
||||
/** True while the customer is being introduced to this branch. */
|
||||
linking = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private recheck$ = new Subject<void>();
|
||||
|
||||
get isEntity(): boolean {
|
||||
return this.legalFormType === 2;
|
||||
}
|
||||
|
||||
/** The first (and normally only) existing customer found. */
|
||||
get match(): ClientIdentityMatch | null {
|
||||
return this.matches[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or
|
||||
* carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed.
|
||||
*/
|
||||
get hasInvalidInput(): boolean {
|
||||
return !!this.form?.invalid;
|
||||
}
|
||||
|
||||
markAllAsTouched(): void {
|
||||
this.form?.markAllAsTouched();
|
||||
}
|
||||
|
||||
get state(): ClientIdentityState {
|
||||
const v = this.normalized();
|
||||
return {
|
||||
status: this.status,
|
||||
pan: v.pan,
|
||||
aadhaar: v.aadhaar,
|
||||
gstin: v.gstin,
|
||||
blocking: this.status === 'matched' || this.status === 'conflict'
|
||||
};
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.formBuilder.group({
|
||||
pan: [
|
||||
'',
|
||||
Validators.pattern(PAN_PATTERN)
|
||||
],
|
||||
aadhaar: [
|
||||
'',
|
||||
Validators.pattern(AADHAAR_PATTERN)
|
||||
],
|
||||
gstin: [
|
||||
'',
|
||||
Validators.pattern(GSTIN_PATTERN)
|
||||
]
|
||||
});
|
||||
|
||||
// A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight.
|
||||
merge(this.form.valueChanges, this.recheck$)
|
||||
.pipe(
|
||||
debounceTime(400),
|
||||
tap(() => this.startChecking()),
|
||||
switchMap(() => this.lookup()),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((result) => this.applyResult(result));
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale.
|
||||
if (changes['legalFormType'] && !changes['legalFormType'].firstChange) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/** Clears the fields and the result, e.g. when a new client creation session starts. */
|
||||
reset(): void {
|
||||
this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false });
|
||||
this.status = 'idle';
|
||||
this.matches = [];
|
||||
this.identityChange.emit(this.state);
|
||||
}
|
||||
|
||||
recheck(): void {
|
||||
this.recheck$.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put
|
||||
* back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input
|
||||
* directive only capitalises when the field is left, which let lower-case show while typing.
|
||||
*/
|
||||
capitalise(controlName: 'pan' | 'gstin', event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const upper = input.value.toUpperCase();
|
||||
if (upper !== input.value) {
|
||||
const { selectionStart, selectionEnd } = input;
|
||||
input.value = upper;
|
||||
input.setSelectionRange(selectionStart, selectionEnd);
|
||||
}
|
||||
this.form.get(controlName)?.setValue(upper, { emitEvent: false });
|
||||
}
|
||||
|
||||
/** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */
|
||||
private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } {
|
||||
const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' };
|
||||
const pan = String(raw.pan ?? '')
|
||||
.replace(/\s+/g, '')
|
||||
.toUpperCase();
|
||||
const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, '');
|
||||
const gstin = String(raw.gstin ?? '')
|
||||
.replace(/\s+/g, '')
|
||||
.toUpperCase();
|
||||
return {
|
||||
pan: PAN_PATTERN.test(pan) ? pan : null,
|
||||
aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null,
|
||||
gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null
|
||||
};
|
||||
}
|
||||
|
||||
private startChecking(): void {
|
||||
const v = this.normalized();
|
||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
||||
this.status = 'idle';
|
||||
this.matches = [];
|
||||
this.identityChange.emit(this.state);
|
||||
return;
|
||||
}
|
||||
this.status = 'checking';
|
||||
this.matches = [];
|
||||
this.identityChange.emit(this.state);
|
||||
}
|
||||
|
||||
private lookup(): Observable<ClientIdentityLookupResult | 'error' | null> {
|
||||
const v = this.normalized();
|
||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
||||
return of(null);
|
||||
}
|
||||
return this.clientsService
|
||||
.lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin })
|
||||
.pipe(
|
||||
map((result): ClientIdentityLookupResult | 'error' => result),
|
||||
catchError(() => of('error' as const))
|
||||
);
|
||||
}
|
||||
|
||||
private applyResult(result: ClientIdentityLookupResult | 'error' | null): void {
|
||||
if (result === null) {
|
||||
// Nothing complete to look up; startChecking() already reset to idle.
|
||||
return;
|
||||
}
|
||||
if (result === 'error') {
|
||||
// Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding.
|
||||
this.status = 'error';
|
||||
this.matches = [];
|
||||
} else if (result.conflict) {
|
||||
this.status = 'conflict';
|
||||
this.matches = result.matches;
|
||||
} else if (result.matched) {
|
||||
this.status = 'matched';
|
||||
this.matches = result.matches;
|
||||
} else {
|
||||
this.status = 'not-found';
|
||||
this.matches = [];
|
||||
}
|
||||
this.identityChange.emit(this.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home
|
||||
* branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under
|
||||
* this branch once an account is actually opened here. An account opened for a customer of another branch is held
|
||||
* at the branch that opens it.
|
||||
*/
|
||||
openAccountHere(match: ClientIdentityMatch): void {
|
||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
||||
if (this.linking || officeId == null) {
|
||||
return;
|
||||
}
|
||||
this.linking = true;
|
||||
this.clientsService.linkClientToMyBranch(match.clientId).subscribe({
|
||||
next: () => {
|
||||
this.linking = false;
|
||||
this.router.navigate([
|
||||
'/clients',
|
||||
match.clientId,
|
||||
'general'
|
||||
]);
|
||||
},
|
||||
// The failure is shown by the global error handler.
|
||||
error: () => (this.linking = false)
|
||||
});
|
||||
}
|
||||
|
||||
/** Legal form of the existing customer, for the card. */
|
||||
legalFormLabel(match: ClientIdentityMatch): string {
|
||||
return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual';
|
||||
}
|
||||
|
||||
matchedByLabel(by: string): string {
|
||||
return by === 'AADHAAR' ? 'Aadhaar' : by;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user