feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="rule-dialog-title">
|
||||
<mat-icon>add_moderator</mat-icon>
|
||||
Add AML Rule
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="rule-dialog-content">
|
||||
<form [formGroup]="ruleForm" class="rule-form" autocomplete="off">
|
||||
<div *ngIf="errorMessage" class="rule-form-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="rule-field-full">
|
||||
<mat-label>Rule Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="2"
|
||||
formControlName="ruleName"
|
||||
placeholder="e.g. Cash deposits exceeding the monthly threshold"
|
||||
></textarea>
|
||||
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('required')">Rule description is required</mat-error>
|
||||
<mat-error *ngIf="ruleForm.get('ruleName')?.hasError('maxlength')">Keep this under 100 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rule-field-full">
|
||||
<mat-label>Rule Reason / Criteria</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
rows="3"
|
||||
formControlName="reason"
|
||||
placeholder="Why the rule exists and what it looks for"
|
||||
></textarea>
|
||||
<mat-error *ngIf="ruleForm.get('reason')?.hasError('required')">Reason is required</mat-error>
|
||||
<mat-error *ngIf="ruleForm.get('reason')?.hasError('maxlength')">Keep this under 500 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="rule-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Risk Category</mat-label>
|
||||
<mat-select formControlName="riskCategory">
|
||||
<mat-option *ngFor="let r of riskCategories" [value]="r.value">{{ r.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies Over</mat-label>
|
||||
<mat-select formControlName="txnWindowDays">
|
||||
<mat-option *ngFor="let w of windowOptions" [value]="w.value">{{ w.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Minimum Limit</mat-label>
|
||||
<span matPrefix class="rule-field-prefix">₹</span>
|
||||
<input matInput type="number" min="0" formControlName="txnMinLimitAmount" />
|
||||
<mat-hint>Leave blank for a non-amount rule</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Maximum Limit</mat-label>
|
||||
<span matPrefix class="rule-field-prefix">₹</span>
|
||||
<input matInput type="number" min="0" formControlName="txnMaxLimitAmount" />
|
||||
<mat-hint>Blank means no upper limit</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rule Type</mat-label>
|
||||
<mat-select formControlName="txnRuleType">
|
||||
<mat-option *ngFor="let t of ruleTypes" [value]="t">{{ t }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Applies To</mat-label>
|
||||
<mat-select formControlName="txnAppliesTo">
|
||||
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>AML Check Status</mat-label>
|
||||
<mat-select formControlName="amlCheckStatus">
|
||||
<mat-option *ngFor="let s of amlCheckStatuses" [value]="s">{{ s }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Banned Entity Match</mat-label>
|
||||
<mat-select formControlName="bannedEntityMatch">
|
||||
<mat-option *ngFor="let y of yesNo" [value]="y">{{ y }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div *ngIf="bandInverted" class="rule-form-error">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
||||
</div>
|
||||
|
||||
<p class="rule-form-note">
|
||||
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
|
||||
</p>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="rule-dialog-actions">
|
||||
<button mat-stroked-button type="button" [disabled]="submitting" (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="ruleForm.invalid || bandInverted || submitting"
|
||||
(click)="submit()"
|
||||
>
|
||||
<mat-spinner *ngIf="submitting" diameter="18" class="rule-submit-spinner"></mat-spinner>
|
||||
{{ submitting ? 'Saving…' : 'Add Rule' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
.rule-dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #1a237e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-dialog-content {
|
||||
padding-top: 8px;
|
||||
min-width: 560px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.rule-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rule-field-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
.rule-field-prefix {
|
||||
color: #64748b;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.rule-form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-form-note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rule-dialog-actions {
|
||||
padding: 8px 24px 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-submit-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.rule-dialog-content {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.rule-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user