feat: add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-11 11:11:18 +05:30
parent 01e86173c1
commit 7b895a0e2b
77 changed files with 10046 additions and 1130 deletions
@@ -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>
@@ -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.'
);
}
}