196 lines
5.6 KiB
TypeScript
196 lines
5.6 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 { 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<AddAmlRuleDialogComponent>);
|
|
|
|
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.'
|
|
);
|
|
}
|
|
}
|