/** * 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(); 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(); private recheck$ = new Subject(); 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 { 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; } }