235 lines
7.8 KiB
TypeScript
235 lines
7.8 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/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, inject } from '@angular/core';
|
|
import {
|
|
AbstractControl,
|
|
FormArray,
|
|
FormBuilder,
|
|
ValidationErrors,
|
|
Validators,
|
|
ReactiveFormsModule
|
|
} from '@angular/forms';
|
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { CommonModule } from '@angular/common';
|
|
|
|
/** Custom Services */
|
|
import { AmlService } from '../../../aml/aml.service';
|
|
|
|
export interface AmlReviewDialogData {
|
|
alert: any;
|
|
disposition: 'WHITELIST' | 'ESCALATE';
|
|
/**
|
|
* The rules being decided, when the officer picked some inside the expanded pane rather than acting on the whole
|
|
* transaction. Absent means the decision covers the alert and every rule under it.
|
|
*/
|
|
ruleCodes?: string[];
|
|
}
|
|
|
|
/**
|
|
* Disposes of one engine alert: clear it (WHITELIST) or raise it as suspicious (ESCALATE).
|
|
*
|
|
* The alert being actioned is restated in full here rather than relying on the officer having
|
|
* read the right table row — the two outcomes are not symmetric. WHITELIST closes the alert with
|
|
* no report; ESCALATE opens an STR case and re-rates the client's risk, which is not something to
|
|
* trigger from a stray click on a table. Notes are mandatory because they are the audit trail for
|
|
* why a flagged transaction was cleared.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-aml-review-alert-dialog',
|
|
templateUrl: './aml-review-alert-dialog.component.html',
|
|
styleUrls: ['./aml-review-alert-dialog.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
ReactiveFormsModule,
|
|
MatDialogModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatCheckboxModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule
|
|
]
|
|
})
|
|
export class AmlReviewAlertDialogComponent {
|
|
private formBuilder = inject(FormBuilder);
|
|
private amlService = inject(AmlService);
|
|
private dialogRef = inject(MatDialogRef<AmlReviewAlertDialogComponent>);
|
|
readonly data = inject<AmlReviewDialogData>(MAT_DIALOG_DATA);
|
|
|
|
submitting = false;
|
|
errorMessage = '';
|
|
|
|
/**
|
|
* What supports clearing the alert. The platform has no evidence table — m_aml_alert.review_notes
|
|
* (VARCHAR(1000)) is the only field the review writes — so the classification is folded into that
|
|
* one string rather than invented as a column the API cannot persist.
|
|
*/
|
|
readonly evidenceTypes = [
|
|
'Customer explanation obtained',
|
|
'Source of funds documented',
|
|
'KYC / identity re-verified',
|
|
'Consistent with declared income or turnover',
|
|
'Established business or salary pattern',
|
|
'Duplicate or system-generated alert',
|
|
'Other'
|
|
];
|
|
|
|
/** Leaves headroom for the composed prefix inside the 1000-char column. */
|
|
private static readonly DETAIL_MAX = 800;
|
|
private static readonly NOTES_COLUMN_MAX = 1000;
|
|
|
|
/** One checkbox per evidence type; a clearing decision can rest on several at once. */
|
|
reviewForm = this.formBuilder.group({
|
|
evidence: this.formBuilder.array(this.evidenceTypes.map(() => false)),
|
|
evidenceRef: [
|
|
'',
|
|
Validators.maxLength(100)
|
|
],
|
|
notes: [
|
|
'',
|
|
[
|
|
Validators.required,
|
|
Validators.maxLength(AmlReviewAlertDialogComponent.DETAIL_MAX)
|
|
]
|
|
]
|
|
});
|
|
|
|
constructor() {
|
|
// Evidence classification is meaningless on an escalation — that path records grounds for
|
|
// suspicion, not proof of innocence — so it is only required when clearing an alert.
|
|
if (this.isWhitelist) {
|
|
this.evidenceArray.addValidators(AmlReviewAlertDialogComponent.atLeastOneChecked);
|
|
this.evidenceArray.updateValueAndValidity();
|
|
}
|
|
}
|
|
|
|
/** FormArray of booleans, so "none ticked" has to be checked across the whole array. */
|
|
private static atLeastOneChecked(control: AbstractControl): ValidationErrors | null {
|
|
const boxes = control as FormArray;
|
|
return boxes.controls.some((c) => c.value === true) ? null : { required: true };
|
|
}
|
|
|
|
get evidenceArray(): FormArray {
|
|
return this.reviewForm.get('evidence') as FormArray;
|
|
}
|
|
|
|
get selectedEvidence(): string[] {
|
|
return this.evidenceTypes.filter((_, i) => this.evidenceArray.at(i)?.value === true);
|
|
}
|
|
|
|
get evidenceMissing(): boolean {
|
|
return this.isWhitelist && this.evidenceArray.invalid && this.evidenceArray.touched;
|
|
}
|
|
|
|
get isWhitelist(): boolean {
|
|
return this.data.disposition === 'WHITELIST';
|
|
}
|
|
|
|
get detailMax(): number {
|
|
return AmlReviewAlertDialogComponent.DETAIL_MAX;
|
|
}
|
|
|
|
/** Exactly what will be written to the alert record, shown before the officer commits. */
|
|
get composedNotes(): string {
|
|
const v = this.reviewForm.value;
|
|
const detail = (v.notes || '').trim();
|
|
if (!this.isWhitelist) {
|
|
return detail ? `Grounds: ${detail}` : '';
|
|
}
|
|
const parts: string[] = [];
|
|
const selected = this.selectedEvidence;
|
|
if (selected.length) {
|
|
parts.push(`Evidence: ${selected.join('; ')}`);
|
|
}
|
|
if (v.evidenceRef) {
|
|
parts.push(`Ref: ${(v.evidenceRef || '').trim()}`);
|
|
}
|
|
const head = parts.join(' | ');
|
|
return head && detail ? `${head} — ${detail}` : head || detail;
|
|
}
|
|
|
|
get composedTooLong(): boolean {
|
|
return this.composedNotes.length > AmlReviewAlertDialogComponent.NOTES_COLUMN_MAX;
|
|
}
|
|
|
|
get title(): string {
|
|
// Named differently when only some of the transaction's rules are being decided, so the officer is not told they
|
|
// are clearing a transaction when five of its six rules stay open.
|
|
const n = this.data.ruleCodes?.length ?? 0;
|
|
if (n > 0) {
|
|
const subject = n === 1 ? 'Rule' : `${n} Rules`;
|
|
return this.isWhitelist ? `Whitelist ${subject}` : `Mark ${subject} as Suspicious`;
|
|
}
|
|
return this.isWhitelist ? 'Whitelist Transaction' : 'Mark as Suspicious';
|
|
}
|
|
|
|
get confirmLabel(): string {
|
|
return this.isWhitelist ? 'Whitelist' : 'Escalate to STR';
|
|
}
|
|
|
|
get notesLabel(): string {
|
|
return this.isWhitelist ? 'Evidence detail' : 'What makes this transaction suspicious?';
|
|
}
|
|
|
|
submit(): void {
|
|
if (this.reviewForm.invalid || this.composedTooLong) {
|
|
this.reviewForm.markAllAsTouched();
|
|
return;
|
|
}
|
|
|
|
this.submitting = true;
|
|
this.errorMessage = '';
|
|
// Deciding named rules within the alert rather than the whole alert, when the caller passed some. The evidence
|
|
// requirements are the same either way, which is why this dialog serves both.
|
|
const request = this.data.ruleCodes?.length
|
|
? this.amlService.reviewAlertRules(
|
|
this.data.alert.id,
|
|
this.data.ruleCodes,
|
|
this.data.disposition,
|
|
this.composedNotes
|
|
)
|
|
: this.amlService.reviewAlert(this.data.alert.id, this.data.disposition, this.composedNotes);
|
|
request.subscribe({
|
|
next: () => {
|
|
this.submitting = false;
|
|
this.dialogRef.close(this.data.disposition);
|
|
},
|
|
error: (err) => {
|
|
this.submitting = false;
|
|
this.errorMessage = this.readError(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
cancel(): void {
|
|
this.dialogRef.close();
|
|
}
|
|
|
|
amount(): string {
|
|
const value = Number(this.data.alert?.amount) || 0;
|
|
return '₹' + value.toLocaleString('en-IN');
|
|
}
|
|
|
|
private readError(err: any): string {
|
|
const body = err?.error;
|
|
const first = body?.errors?.[0];
|
|
return (
|
|
first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not record the review.'
|
|
);
|
|
}
|
|
}
|