/** * 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 { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; 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 { ClientsService } from '../../../clients/clients.service'; /** * Authoring form for a new AML catalog rule. * * The screen the officer came from renders one flat table, but a rule is stored across two * tables: the profile rule in m_client_risk_rule and, when a transaction band or observation * window is given, an evaluable counterpart in m_aml_transaction_rule. That split is the * backend's concern — this form stays shaped like the table it was opened from. */ @Component({ selector: 'mifosx-add-aml-rule-dialog', templateUrl: './add-aml-rule-dialog.component.html', styleUrls: ['./add-aml-rule-dialog.component.scss'], standalone: true, imports: [ CommonModule, ReactiveFormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule ] }) export class AddAmlRuleDialogComponent { private formBuilder = inject(FormBuilder); private clientsService = inject(ClientsService); private dialogRef = inject(MatDialogRef); submitting = false; /** Server-side rejection, surfaced in the dialog so the officer keeps their input. */ errorMessage = ''; readonly riskCategories = [ { value: 3, label: 'High Risk' }, { value: 2, label: 'Medium Risk' }, { value: 1, label: 'Low Risk' } ]; /** Mirrors the "Applies Over" column: window in days, per-transaction being no window. */ readonly windowOptions = [ { value: 0, label: 'Per transaction' }, { value: 1, label: 'Daily' }, { value: 7, label: 'Weekly' }, { value: 30, label: 'Monthly' } ]; readonly ruleTypes = [ 'THRESHOLD', 'STRUCTURING', 'VELOCITY' ]; readonly appliesToOptions = [ 'ALL', 'SAVINGS', 'LOAN', 'TRANSFER' ]; readonly amlCheckStatuses = [ 'CLEAR', 'FLAGGED', 'PENDING' ]; readonly yesNo = [ 'NO', 'YES' ]; ruleForm = this.formBuilder.group({ ruleName: [ '', [ Validators.required, Validators.maxLength(100) ] ], reason: [ '', [ Validators.required, Validators.maxLength(500) ] ], riskCategory: [ 3, Validators.required ], txnWindowDays: [0], txnRuleType: ['THRESHOLD'], txnAppliesTo: ['ALL'], txnMinLimitAmount: [ null as | number | null, Validators.min(0) ], txnMaxLimitAmount: [ null as | number | null, Validators.min(0) ], amlCheckStatus: ['FLAGGED'], bannedEntityMatch: ['NO'], willfulDefaulter: ['NO'], ecgcSalFlag: ['NO'], dormantFlag: ['ACTIVE'] }); /** Mirrors the backend band check so the officer is told before the round trip. */ get bandInverted(): boolean { const min = this.ruleForm.value.txnMinLimitAmount; const max = this.ruleForm.value.txnMaxLimitAmount; return min != null && max != null && Number(max) < Number(min); } submit(): void { if (this.ruleForm.invalid || this.bandInverted) { this.ruleForm.markAllAsTouched(); return; } this.submitting = true; this.errorMessage = ''; this.clientsService.createClientRiskRule(this.buildPayload()).subscribe({ next: (rule) => { this.submitting = false; this.dialogRef.close(rule); }, error: (err) => { this.submitting = false; this.errorMessage = this.readError(err); } }); } cancel(): void { this.dialogRef.close(); } private buildPayload(): any { const v = this.ruleForm.value; const amount = (n: any) => (n === null || n === undefined || n === '' ? null : Number(n)); return { ruleName: (v.ruleName || '').trim(), reason: (v.reason || '').trim(), riskCategory: v.riskCategory, amlCheckStatus: v.amlCheckStatus, bannedEntityMatch: v.bannedEntityMatch, willfulDefaulter: v.willfulDefaulter, ecgcSalFlag: v.ecgcSalFlag, dormantFlag: v.dormantFlag, // A window of 0 means per-transaction, which the backend stores as a null window rather // than a zero-day one. txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null, txnRuleType: v.txnRuleType, txnAppliesTo: v.txnAppliesTo, txnMinLimitAmount: amount(v.txnMinLimitAmount), txnMaxLimitAmount: amount(v.txnMaxLimitAmount) }; } /** Fineract returns field-level failures under errors[]; fall back to the envelope message. */ private readError(err: any): string { const body = err?.error; const first = body?.errors?.[0]; return ( first?.defaultUserMessage || first?.developerMessage || body?.defaultUserMessage || 'Could not save the rule.' ); } }