/** * 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 { const params = status ? new HttpParams().set('status', status) : undefined; return this.http.get('/aml/alerts', { params }); } /** disposition: 'WHITELIST' | 'ESCALATE' */ reviewAlert(alertId: number, disposition: 'WHITELIST' | 'ESCALATE', notes: string): Observable { return this.http.post(`/aml/alerts/${alertId}/review`, { disposition, notes }); } /** * Decides named rules within one alert, leaving its other rules open. * *

* 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 { 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 { const params = new HttpParams() .set('minAlerts', String(minAlerts)) .set('windowDays', String(windowDays)) .set('onlyNeedingReview', String(onlyNeedingReview)); return this.http.get('/aml/recurring-alerts', { params }); } /** outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED' */ reviewRecurringAlert( clientId: number, outcome: 'CLEARED' | 'MONITOR' | 'ESCALATED', notes: string, windowDays: number ): Observable { 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 { return this.http.get('/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 { return this.http.post('/aml/alerts/manual-referral', { journalEntryId, disposition, notes }); } // ═══ STR cases (Principal Officer) ═══ getStrCases(): Observable { return this.http.get('/aml/str-cases'); } getStrCase(strCaseId: number): Observable { return this.http.get(`/aml/str-cases/${strCaseId}`); } getWeeklyDigest(): Observable { return this.http.get('/aml/str-cases/weekly-digest'); } assignPrincipalOfficer(strCaseId: number, principalOfficerUserId: number): Observable { 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 { return this.http.post(`/aml/str-cases/${strCaseId}/conclude`, { conclusion, investigationNotes, sourceOfFundsNotes }); } fileStr(strCaseId: number, referenceNo: string): Observable { return this.http.post(`/aml/str-cases/${strCaseId}/file`, { referenceNo }); } // ═══ CTR reports (mechanical, threshold-based) ═══ getCtrReports(): Observable { return this.http.get('/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 { const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined; return this.http.get('/aml/ctr-reports/lines', { params }); } /** month format: yyyy-MM */ generateCtrReport(month: string): Observable { return this.http.post('/aml/ctr-reports/generate', { month }); } // ═══ Client risk-category history ═══ getRiskCategoryHistory(clientId: number): Observable { return this.http.get(`/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 { return this.http.post('/aml/risk-change-requests', { clientId, requestedCategory, reason }); } getRiskChangeRequests(status?: string): Observable { const params = status ? new HttpParams().set('status', status) : undefined; return this.http.get('/aml/risk-change-requests', { params }); } approveRiskChange(requestId: number): Observable { return this.http.post(`/aml/risk-change-requests/${requestId}/approve`, {}); } rejectRiskChange(requestId: number, rejectionReason: string): Observable { 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 { return this.http.get('/risk-review/behaviour-windows'); } }