309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|