190 lines
7.1 KiB
TypeScript
190 lines
7.1 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/.
|
|
*/
|
|
|
|
import { Injectable, inject } from '@angular/core';
|
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
import { Observable } from 'rxjs';
|
|
|
|
/**
|
|
* AML transaction-monitoring API: live alerts raised by the rule engine, the
|
|
* two-tier AML Officer / Principal Officer review workflow, STR cases (with
|
|
* their 7-working-day FIU filing deadline), monthly CTR reports, and a
|
|
* client's risk-category change history.
|
|
*/
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class AmlService {
|
|
private http = inject(HttpClient);
|
|
|
|
// ═══ Alerts (AML Officer) ═══
|
|
|
|
getAlerts(status?: string): Observable<any[]> {
|
|
const params = status ? new HttpParams().set('status', status) : undefined;
|
|
return this.http.get<any[]>('/aml/alerts', { params });
|
|
}
|
|
|
|
/** disposition: 'WHITELIST' | 'ESCALATE' */
|
|
reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes });
|
|
}
|
|
|
|
/**
|
|
* Decides named rules within one alert, leaving its other rules open.
|
|
*
|
|
* <p>
|
|
* The alert's own status follows from what is left undecided, so it is not sent: whitelisting five of six rules
|
|
* leaves the alert open on the sixth.
|
|
*/
|
|
reviewAlertRules(alertId: number, ruleCodes: string[], disposition: string, notes: string): Observable<any> {
|
|
return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes, ruleCodes });
|
|
}
|
|
|
|
// ═══ Recurring-alert review queue ═══
|
|
|
|
/**
|
|
* Customers whose transactions repeatedly trip the engine. Each row carries the triggering
|
|
* alerts, so the reviewer can see why the customer keeps surfacing.
|
|
*/
|
|
getRecurringAlerts(minAlerts: number, windowDays: number, onlyNeedingReview: boolean): Observable<any[]> {
|
|
const params = new HttpParams()
|
|
.set('minAlerts', String(minAlerts))
|
|
.set('windowDays', String(windowDays))
|
|
.set('onlyNeedingReview', String(onlyNeedingReview));
|
|
return this.http.get<any[]>('/aml/recurring-alerts', { params });
|
|
}
|
|
|
|
/** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */
|
|
reviewRecurringAlert(
|
|
clientId: number,
|
|
outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED',
|
|
notes: string,
|
|
windowDays: number
|
|
): Observable<any> {
|
|
return this.http.post(`/aml/recurring-alerts/${clientId}/review`, { outcome, notes, windowDays });
|
|
}
|
|
|
|
// ═══ Manual referrals from the general ledger ═══
|
|
|
|
/**
|
|
* Ledger entries a user has already marked, keyed on `journalEntryId`.
|
|
*
|
|
* Separate from `getAlerts()` because these are not engine alerts — they carry the
|
|
* journal entry id rather than a savings or loan transaction id, so the ledger
|
|
* screen can match them to the rows it is showing.
|
|
*/
|
|
getManualReferrals(): Observable<any[]> {
|
|
return this.http.get<any[]>('/aml/alerts/manual-referrals');
|
|
}
|
|
|
|
/**
|
|
* Marks a ledger entry. WHITELIST clears it with no report; ESCALATE opens an STR
|
|
* case for the Principal Officer, the same road an escalated engine alert takes.
|
|
* The entry must have a customer behind it or the server rejects it.
|
|
*/
|
|
referJournalEntry(journalEntryId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable<any> {
|
|
return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes });
|
|
}
|
|
|
|
// ═══ STR cases (Principal Officer) ═══
|
|
|
|
getStrCases(): Observable<any[]> {
|
|
return this.http.get<any[]>('/aml/str-cases');
|
|
}
|
|
|
|
getStrCase(strCaseId: number): Observable<any> {
|
|
return this.http.get(`/aml/str-cases/${strCaseId}`);
|
|
}
|
|
|
|
getWeeklyDigest(): Observable<any[]> {
|
|
return this.http.get<any[]>('/aml/str-cases/weekly-digest');
|
|
}
|
|
|
|
assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable<any> {
|
|
return this.http.post(`/aml/str-cases/${strCaseId}/assign`, { principalOfficerUserId });
|
|
}
|
|
|
|
/** conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS' */
|
|
concludeStrCase(
|
|
strCaseId: number,
|
|
conclusion: 'NOT_SUSPICIOUS' | 'SUSPICIOUS',
|
|
investigationNotes: string,
|
|
sourceOfFundsNotes: string
|
|
): Observable<any> {
|
|
return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, {
|
|
conclusion,
|
|
investigationNotes,
|
|
sourceOfFundsNotes
|
|
});
|
|
}
|
|
|
|
fileStr(strCaseId: number, referenceNo: string): Observable<any> {
|
|
return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo });
|
|
}
|
|
|
|
// ═══ CTR reports (mechanical, threshold-based) ═══
|
|
|
|
getCtrReports(): Observable<any[]> {
|
|
return this.http.get<any[]>('/aml/ctr-reports');
|
|
}
|
|
|
|
/**
|
|
* The reportable lines behind the generated CTRs, each resolved to the account, customer, branch
|
|
* and date a FIU-IND filing names. Omit `reportId` for every generated month.
|
|
*/
|
|
getCtrReportLines(reportId?: number): Observable<any[]> {
|
|
const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined;
|
|
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
|
}
|
|
|
|
/** month format: yyyy-MM */
|
|
generateCtrReport(month: string): Observable<any> {
|
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
|
}
|
|
|
|
// ═══ Client risk-category history ═══
|
|
|
|
getRiskCategoryHistory(clientId: number): Observable<any[]> {
|
|
return this.http.get<any[]>(`/clients/${clientId}/risk-category-history`);
|
|
}
|
|
|
|
// ═══ Risk-category changes (maker-checker) ═══
|
|
//
|
|
// The rating decides how often a customer is re-KYC'd and what AML thresholds apply to them, so
|
|
// it moves only once a checker agrees rather than on one officer's say-so.
|
|
|
|
/** @param requestedCategory 1 Low, 2 Medium, 3 High */
|
|
requestRiskChange(clientId: number, requestedCategory: number, reason: string): Observable<any> {
|
|
return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason });
|
|
}
|
|
|
|
getRiskChangeRequests(status?: string): Observable<any[]> {
|
|
const params = status ? new HttpParams().set('status', status) : undefined;
|
|
return this.http.get<any[]>('/aml/risk-change-requests', { params });
|
|
}
|
|
|
|
approveRiskChange(requestId: number): Observable<any> {
|
|
return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {});
|
|
}
|
|
|
|
rejectRiskChange(requestId: number, rejectionReason: string): Observable<any> {
|
|
return this.http.post(`/aml/risk-change-requests/${requestId}/reject`, { rejectionReason });
|
|
}
|
|
|
|
/**
|
|
* The latest behaviour window for each customer, as the nightly job computed it.
|
|
*
|
|
* One row per customer carrying the review period's figures and the rating they suggest. The Risk Review report
|
|
* prefers these over its own per-alert reckoning, because a rating describes a period rather than the handful of
|
|
* transactions that happened to trip a rule.
|
|
*/
|
|
getBehaviourWindows(): Observable<any[]> {
|
|
return this.http.get<any[]>('/risk-review/behaviour-windows');
|
|
}
|
|
}
|