250 lines
6.9 KiB
TypeScript
250 lines
6.9 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, OnInit, inject, ViewChild } from '@angular/core';
|
|
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
|
|
import { CommonModule } from '@angular/common';
|
|
|
|
/** Custom Services */
|
|
import { ClientsService } from '../../clients/clients.service';
|
|
|
|
@Component({
|
|
selector: 'mifosx-aml-rules',
|
|
templateUrl: './aml-rules.component.html',
|
|
styleUrls: ['./aml-rules.component.scss'],
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
MatTableModule,
|
|
MatCardModule,
|
|
MatIconModule,
|
|
MatButtonModule,
|
|
MatCheckboxModule,
|
|
MatPaginatorModule
|
|
]
|
|
})
|
|
export class AmlRulesComponent implements OnInit {
|
|
private clientsService = inject(ClientsService);
|
|
|
|
rules = new MatTableDataSource<any>([]);
|
|
displayedColumns: string[] = [
|
|
'orderPosition',
|
|
'ruleName',
|
|
'riskCategory',
|
|
'limitBasis',
|
|
'minLimit',
|
|
'maxLimit',
|
|
'appliesOver',
|
|
'reason',
|
|
'amlCheckStatus',
|
|
'bannedEntityMatch'
|
|
];
|
|
|
|
/** Every rule as returned by the API, before the period filter is applied. */
|
|
private allRules: any[] = [];
|
|
|
|
/** Period buckets the rules can be separated into. */
|
|
readonly periodFilters: { key: string; label: string }[] = [
|
|
{ key: '', label: 'All rules' },
|
|
{ key: 'TXN', label: 'Per transaction' },
|
|
{ key: 'DAY', label: 'Daily' },
|
|
{ key: 'WEEK', label: 'Weekly' },
|
|
{ key: 'MONTH', label: 'Monthly' },
|
|
{ key: 'OTHER', label: 'Other window' }
|
|
];
|
|
activePeriod = '';
|
|
|
|
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
|
|
|
ngOnInit(): void {
|
|
this.clientsService.getClientRiskRules().subscribe({
|
|
next: (data) => {
|
|
this.allRules = data || [];
|
|
this.applyPeriodFilter();
|
|
this.rules.paginator = this.paginator;
|
|
},
|
|
error: () => {}
|
|
});
|
|
}
|
|
|
|
setPeriod(key: string): void {
|
|
this.activePeriod = key;
|
|
this.applyPeriodFilter();
|
|
}
|
|
|
|
private applyPeriodFilter(): void {
|
|
this.rules.data = this.activePeriod
|
|
? this.allRules.filter((r) => this.periodKey(r) === this.activePeriod)
|
|
: this.allRules;
|
|
if (this.rules.paginator) {
|
|
this.rules.paginator.firstPage();
|
|
}
|
|
}
|
|
|
|
/** How many rules fall in each period bucket — shown on the filter chips. */
|
|
periodCount(key: string): number {
|
|
return key ? this.allRules.filter((r) => this.periodKey(r) === key).length : this.allRules.length;
|
|
}
|
|
|
|
/**
|
|
* Buckets a rule by the observation window it is measured over.
|
|
* A rule with no window is evaluated on the single transaction itself, so it is
|
|
* in force every day rather than accumulating over a period.
|
|
*/
|
|
periodKey(rule: any): string {
|
|
const days = rule.txnWindowDays;
|
|
if (days == null || days === 0) {
|
|
return 'TXN';
|
|
}
|
|
if (days === 1) {
|
|
return 'DAY';
|
|
}
|
|
if (days === 7) {
|
|
return 'WEEK';
|
|
}
|
|
if (days >= 28 && days <= 31) {
|
|
return 'MONTH';
|
|
}
|
|
return 'OTHER';
|
|
}
|
|
|
|
periodLabel(rule: any): string {
|
|
const days = rule.txnWindowDays;
|
|
switch (this.periodKey(rule)) {
|
|
case 'TXN':
|
|
return 'Per transaction';
|
|
case 'DAY':
|
|
return 'Daily';
|
|
case 'WEEK':
|
|
return 'Weekly';
|
|
case 'MONTH':
|
|
return 'Monthly';
|
|
default:
|
|
return `${days} days`;
|
|
}
|
|
}
|
|
|
|
periodClass(rule: any): string {
|
|
return 'period-badge period-' + this.periodKey(rule).toLowerCase();
|
|
}
|
|
|
|
/** Sub-caption telling the reader what the min/max figures actually measure. */
|
|
getLimitBasis(rule: any): string {
|
|
if (rule.minMonthlyIncome > 0 || rule.maxMonthlyIncome > 0) {
|
|
return 'Monthly Income';
|
|
}
|
|
if (rule.minAnnualIncome > 0 || rule.maxAnnualIncome > 0) {
|
|
return 'Annual Income';
|
|
}
|
|
if (rule.minAnnualTurnover > 0 || rule.maxAnnualTurnover > 0) {
|
|
return 'Annual Turnover';
|
|
}
|
|
if (rule.txnThresholdAmountMediumRisk != null || rule.txnThresholdAmountHighRisk != null) {
|
|
return 'Txn Amount (tiered)';
|
|
}
|
|
if (rule.txnThresholdAmount != null) {
|
|
return 'Txn Amount';
|
|
}
|
|
if (rule.occupationName) {
|
|
return 'Occupation';
|
|
}
|
|
if (rule.occupationCategoryName) {
|
|
return 'Occupation Category';
|
|
}
|
|
return 'Behavioural pattern';
|
|
}
|
|
|
|
private inr(n: number): string {
|
|
return '₹' + Number(n).toLocaleString('en-IN');
|
|
}
|
|
|
|
/** Lower bound of the rule, or an em dash where the criterion is not numeric. */
|
|
getMinLimit(rule: any): string {
|
|
if (rule.minMonthlyIncome > 0) {
|
|
return this.inr(rule.minMonthlyIncome);
|
|
}
|
|
if (rule.minAnnualIncome > 0) {
|
|
return this.inr(rule.minAnnualIncome);
|
|
}
|
|
if (rule.minAnnualTurnover > 0) {
|
|
return this.inr(rule.minAnnualTurnover);
|
|
}
|
|
// Declarative band from m_aml_transaction_rule takes precedence over the raw threshold.
|
|
if (rule.txnMinLimitAmount != null) {
|
|
return this.inr(rule.txnMinLimitAmount);
|
|
}
|
|
if (rule.txnThresholdAmount != null) {
|
|
return this.inr(rule.txnThresholdAmount);
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
/** Upper bound, or "No upper limit" where the rule is genuinely open-ended. */
|
|
getMaxLimit(rule: any): string {
|
|
if (rule.maxMonthlyIncome > 0) {
|
|
return this.inr(rule.maxMonthlyIncome);
|
|
}
|
|
if (rule.maxAnnualIncome > 0) {
|
|
return this.inr(rule.maxAnnualIncome);
|
|
}
|
|
if (rule.maxAnnualTurnover > 0) {
|
|
return this.inr(rule.maxAnnualTurnover);
|
|
}
|
|
if (rule.txnMaxLimitAmount != null) {
|
|
return this.inr(rule.txnMaxLimitAmount);
|
|
}
|
|
// A floor with no configured ceiling means the rule has no upper bound.
|
|
if (
|
|
rule.minMonthlyIncome > 0 ||
|
|
rule.minAnnualIncome > 0 ||
|
|
rule.minAnnualTurnover > 0 ||
|
|
rule.txnThresholdAmount != null
|
|
) {
|
|
return 'No upper limit';
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
/** Occupation-driven rules carry no amount; show the criterion instead. */
|
|
getNonNumericCriterion(rule: any): string {
|
|
return rule.occupationName || rule.occupationCategoryName || '';
|
|
}
|
|
|
|
getRiskCategoryLabel(category: number): string {
|
|
switch (category) {
|
|
case 1:
|
|
return 'Low Risk';
|
|
case 2:
|
|
return 'Medium Risk';
|
|
case 3:
|
|
return 'High Risk';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
|
|
getRiskColorClass(category: number): string {
|
|
switch (category) {
|
|
case 1:
|
|
return 'risk-low';
|
|
case 2:
|
|
return 'risk-medium';
|
|
case 3:
|
|
return 'risk-high';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
}
|