/** * 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); readonly data = inject(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.' ); } }