Files
cbsnexora/src/app/rbi-circulars/aml-rules/aml-flagged-transactions/aml-review-alert-dialog.component.ts
T
2026-09-23 15:51:10 +05:30

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