feat: KYC risk profiling, AML flagged transactions bulk review, Risk Review report & Board Resolution register
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
import { Component, Inject, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { AmlService } from 'app/aml/aml.service';
|
||||
|
||||
export interface RiskChangeDialogData {
|
||||
clientId: number;
|
||||
customerName: string;
|
||||
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
|
||||
currentCategory: number | null;
|
||||
/** What the report's own computation says the rating should be, used as the default. */
|
||||
recommendedCategory: number | null;
|
||||
/** The one-line explanation the report already shows against the row. */
|
||||
recommendationReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a change to a customer's KYC risk category for approval.
|
||||
*
|
||||
* <p>
|
||||
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
|
||||
* against, so it is not something one officer moves on their own. The dialog collects what it
|
||||
* should become and why; a checker applies it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-risk-change-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>Change Risk Category</h2>
|
||||
<mat-dialog-content>
|
||||
<div class="rc-summary">
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Customer</span>
|
||||
<strong class="rc-value">{{ data.customerName }}</strong>
|
||||
</div>
|
||||
<div class="rc-row">
|
||||
<span class="rc-label">Rating on file</span>
|
||||
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
|
||||
says plainly that the figure is a recommendation rather than a decision. -->
|
||||
<p class="rc-reason" *ngIf="data.recommendationReason">
|
||||
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="rc-form">
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>New risk category</mat-label>
|
||||
<mat-select formControlName="requestedCategory">
|
||||
<mat-option [value]="1">Low</mat-option>
|
||||
<mat-option [value]="2">Medium</mat-option>
|
||||
<mat-option [value]="3">High</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="rc-full">
|
||||
<mat-label>Reason</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="reason"
|
||||
rows="3"
|
||||
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
|
||||
></textarea>
|
||||
<mat-hint>Recorded against the customer's risk history</mat-hint>
|
||||
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<p class="rc-effect">
|
||||
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
|
||||
date are updated together.
|
||||
</p>
|
||||
|
||||
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button mat-flat-button color="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
|
||||
{{ submitting ? 'Sending…' : 'Send for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.rc-summary {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.rc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.rc-label {
|
||||
color: #64748b;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rc-value {
|
||||
color: #1e293b;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.rc-reason {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.rc-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-full {
|
||||
width: 100%;
|
||||
}
|
||||
.rc-effect {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
color: #1e3a8a;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.rc-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 6px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class RiskChangeDialogComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
private amlService = inject(AmlService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
|
||||
|
||||
form: FormGroup;
|
||||
submitting = false;
|
||||
errorMessage = '';
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
|
||||
this.form = this.fb.group({
|
||||
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
|
||||
// customer and the computation has not.
|
||||
requestedCategory: [
|
||||
data.recommendedCategory ?? null,
|
||||
Validators.required
|
||||
],
|
||||
reason: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
labelOf(category: number | null): string {
|
||||
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMessage = '';
|
||||
const v = this.form.value;
|
||||
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
this.snackBar.open(
|
||||
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
|
||||
'Close',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
this.dialogRef.close(true);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user