Compare commits

...
7 Commits
Author SHA1 Message Date
Anshul Rathore b9aa29fb35 feat: commit latest changes on dev
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-06 10:46:22 +05:30
Anshul Rathore 84f8aa2274 Merge branch 'demo' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev 2026-08-03 12:51:55 +05:30
keshav 7773a9e9c6 feat(aml): add API endpoints and services for manual journal entry referrals
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-03 10:49:37 +05:30
keshav c890faee78 form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
2026-08-02 11:45:54 +05:30
keshav abc7eb22a2 oss file updated, profitl loss as per the day month and also with date select
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-08-01 11:54:30 +05:30
keshav 8622dcaf90 static update
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-31 15:11:23 +05:30
keshav 2022dc5241 kyc risk and AML & Compliance Checks automate on client creation
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-07-31 11:02:31 +05:30
119 changed files with 10957 additions and 19 deletions
@@ -65,6 +65,8 @@ public enum JobName {
NEFT_BATCH_PROCESS("NEFT Batch Process"), // NEFT_BATCH_PROCESS("NEFT Batch Process"), //
VERIFICATION_TOKEN_CLEANUP("Verification Token Cleanup"), // VERIFICATION_TOKEN_CLEANUP("Verification Token Cleanup"), //
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), // OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
; // ; //
private final String name; private final String name;
@@ -0,0 +1,160 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One row per {@link AmlTransactionRule} hit on a live transaction. Raised regardless of the
* client's current KYC risk category - alerting is never suppressed by a client being Low/Medium
* risk. First reviewed by an AML Officer (whitelist or escalate); an ESCALATED alert spawns an
* {@link AmlStrCase} for the Principal Officer.
*/
@Entity
@Table(name = "m_aml_alert")
public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "transaction_type", nullable = false, length = 20)
private String transactionType;
@Column(name = "transaction_id", nullable = false)
private Long transactionId;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "rule_id", nullable = false)
private AmlTransactionRule rule;
@Column(name = "amount", nullable = false, precision = 19, scale = 6)
private BigDecimal amount;
@Column(name = "currency_code", length = 3)
private String currencyCode;
@Column(name = "raised_on", nullable = false)
private LocalDateTime raisedOn;
@Column(name = "status", nullable = false, length = 20)
private String status;
@ManyToOne
@JoinColumn(name = "reviewed_by")
private AppUser reviewedBy;
@Column(name = "reviewed_on")
private LocalDateTime reviewedOn;
@Column(name = "review_notes", length = 1000)
private String reviewNotes;
@Column(name = "disposition", length = 20)
private String disposition;
protected AmlAlert() {}
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
BigDecimal amount, String currencyCode, LocalDateTime raisedOn) {
final AmlAlert alert = new AmlAlert();
alert.transactionType = transactionType.name();
alert.transactionId = transactionId;
alert.client = client;
alert.rule = rule;
alert.amount = amount;
alert.currencyCode = currencyCode;
alert.raisedOn = raisedOn;
alert.status = AmlAlertStatus.RAISED.name();
return alert;
}
public void whitelist(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = AmlAlertDisposition.WHITELIST.name();
this.status = AmlAlertStatus.WHITELISTED.name();
}
public void escalate(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = AmlAlertDisposition.ESCALATE.name();
this.status = AmlAlertStatus.ESCALATED.name();
}
public Long getTransactionId() {
return transactionId;
}
public AmlTransactionType getTransactionType() {
return AmlTransactionType.valueOf(transactionType);
}
public Client getClient() {
return client;
}
public AmlTransactionRule getRule() {
return rule;
}
public BigDecimal getAmount() {
return amount;
}
public String getCurrencyCode() {
return currencyCode;
}
public LocalDateTime getRaisedOn() {
return raisedOn;
}
public AmlAlertStatus getStatus() {
return AmlAlertStatus.valueOf(status);
}
public AppUser getReviewedBy() {
return reviewedBy;
}
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
public String getReviewNotes() {
return reviewNotes;
}
public AmlAlertDisposition getDisposition() {
return disposition == null ? null : AmlAlertDisposition.valueOf(disposition);
}
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** The AML Officer's first-level disposition of an {@link AmlAlert}. */
public enum AmlAlertDisposition {
WHITELIST, ESCALATE
}
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
List<AmlAlert> findByStatusOrderByRaisedOnAsc(String status);
@Query("SELECT COALESCE(SUM(a.amount), 0) FROM AmlAlert a WHERE a.client.id = :clientId "
+ "AND a.transactionType = :transactionType AND a.raisedOn >= :since")
BigDecimal sumAmountForClientSince(@Param("clientId") Long clientId, @Param("transactionType") String transactionType,
@Param("since") LocalDateTime since);
long countByClientId(Long clientId);
/**
* Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
public enum AmlAlertStatus {
RAISED, UNDER_REVIEW, WHITELISTED, ESCALATED
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
public enum AmlAppliesTo {
SAVINGS, LOAN, TRANSFER, ALL;
public boolean matches(AmlTransactionType transactionType) {
return this == ALL || this.name().equals(transactionType.name());
}
}
@@ -0,0 +1,97 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Monthly, mechanical CTR batch header - purely threshold-based (₹10L+ cash, individually or
* connected), no Principal Officer judgment involved. One row per calendar month.
*/
@Entity
@Table(name = "m_aml_ctr_report")
public class AmlCtrReport extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "report_month", nullable = false, length = 7)
private String reportMonth;
@Column(name = "generated_on", nullable = false)
private LocalDateTime generatedOn;
@Column(name = "record_count", nullable = false)
private int recordCount;
@Column(name = "file_reference", length = 150)
private String fileReference;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "submitted_on")
private LocalDateTime submittedOn;
protected AmlCtrReport() {}
public static AmlCtrReport draft(String reportMonth, LocalDateTime generatedOn, int recordCount) {
final AmlCtrReport report = new AmlCtrReport();
report.reportMonth = reportMonth;
report.generatedOn = generatedOn;
report.recordCount = recordCount;
report.status = AmlCtrReportStatus.DRAFT.name();
return report;
}
public void markGenerated(String fileReference) {
this.fileReference = fileReference;
this.status = AmlCtrReportStatus.GENERATED.name();
}
public void markSubmitted(LocalDateTime submittedOn) {
this.submittedOn = submittedOn;
this.status = AmlCtrReportStatus.SUBMITTED.name();
}
public String getReportMonth() {
return reportMonth;
}
public LocalDateTime getGeneratedOn() {
return generatedOn;
}
public int getRecordCount() {
return recordCount;
}
public String getFileReference() {
return fileReference;
}
public AmlCtrReportStatus getStatus() {
return AmlCtrReportStatus.valueOf(status);
}
public LocalDateTime getSubmittedOn() {
return submittedOn;
}
}
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/**
* One line per transaction (or connected-transaction group, when {@link #connectedGroup} is true)
* feeding a monthly {@link AmlCtrReport}.
*/
@Entity
@Table(name = "m_aml_ctr_report_line")
public class AmlCtrReportLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "ctr_report_id", nullable = false)
private AmlCtrReport ctrReport;
@Column(name = "transaction_type", nullable = false, length = 20)
private String transactionType;
@Column(name = "transaction_id")
private Long transactionId;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "amount", nullable = false, precision = 19, scale = 6)
private BigDecimal amount;
@Column(name = "is_connected_group", nullable = false)
private boolean connectedGroup;
protected AmlCtrReportLine() {}
public static AmlCtrReportLine of(AmlCtrReport ctrReport, AmlTransactionType transactionType, Long transactionId, Long clientId,
BigDecimal amount, boolean connectedGroup) {
final AmlCtrReportLine line = new AmlCtrReportLine();
line.ctrReport = ctrReport;
line.transactionType = transactionType.name();
line.transactionId = transactionId;
line.clientId = clientId;
line.amount = amount;
line.connectedGroup = connectedGroup;
return line;
}
public Long getId() {
return id;
}
public AmlCtrReport getCtrReport() {
return ctrReport;
}
public AmlTransactionType getTransactionType() {
return AmlTransactionType.valueOf(transactionType);
}
public Long getTransactionId() {
return transactionId;
}
public Long getClientId() {
return clientId;
}
public BigDecimal getAmount() {
return amount;
}
public boolean isConnectedGroup() {
return connectedGroup;
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlCtrReportLineRepository extends JpaRepository<AmlCtrReportLine, Long> {
List<AmlCtrReportLine> findByCtrReport_Id(Long ctrReportId);
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlCtrReportRepository extends JpaRepository<AmlCtrReport, Long> {
Optional<AmlCtrReport> findByReportMonth(String reportMonth);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
public enum AmlCtrReportStatus {
DRAFT, GENERATED, SUBMITTED
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/**
* How an {@link AmlTransactionRule} is evaluated.
*
* <ul>
* <li>THRESHOLD - a single transaction at or above {@code thresholdAmount}</li>
* <li>STRUCTURING - connected transactions summing at/above {@code thresholdAmount} within {@code windowDays}</li>
* <li>VELOCITY - unusual frequency of transactions within {@code windowDays}, independent of a fixed amount</li>
* </ul>
*/
public enum AmlRuleType {
THRESHOLD, STRUCTURING, VELOCITY
}
@@ -0,0 +1,164 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Created when an {@link AmlAlert} is escalated past first-level review. {@link #concludedOn} is
* the Principal Officer's conclusion date - the RBI-mandated 7-working-day FIU filing clock
* ({@link #fiuDueDate}) starts here, never at the alert or transaction date.
*/
@Entity
@Table(name = "m_aml_str_case")
public class AmlStrCase extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@ManyToOne
@JoinColumn(name = "assigned_po")
private AppUser assignedPo;
@Column(name = "investigation_notes", length = 2000)
private String investigationNotes;
@Column(name = "source_of_funds_notes", length = 2000)
private String sourceOfFundsNotes;
@Column(name = "conclusion", length = 20)
private String conclusion;
@ManyToOne
@JoinColumn(name = "concluded_by")
private AppUser concludedBy;
@Column(name = "concluded_on")
private LocalDate concludedOn;
@Column(name = "fiu_due_date")
private LocalDate fiuDueDate;
@Column(name = "str_filed_on")
private LocalDate strFiledOn;
@Column(name = "str_reference_no", length = 100)
private String strReferenceNo;
@Column(name = "status", nullable = false, length = 25)
private String status;
protected AmlStrCase() {}
public static AmlStrCase openFor(AmlAlert alert) {
final AmlStrCase strCase = new AmlStrCase();
strCase.alert = alert;
strCase.client = alert.getClient();
strCase.status = AmlStrCaseStatus.OPEN.name();
return strCase;
}
public void assignPrincipalOfficer(AppUser po) {
this.assignedPo = po;
}
/** {@code fiuDueDate} is computed by the caller using the working-days calendar, not here. */
public void conclude(AppUser po, AmlStrConclusion conclusion, String investigationNotes, String sourceOfFundsNotes,
LocalDate concludedOn, LocalDate fiuDueDate) {
this.concludedBy = po;
this.conclusion = conclusion.name();
this.investigationNotes = investigationNotes;
this.sourceOfFundsNotes = sourceOfFundsNotes;
this.concludedOn = concludedOn;
this.status = conclusion == AmlStrConclusion.SUSPICIOUS ? AmlStrCaseStatus.CONCLUDED_SUSPICIOUS.name()
: AmlStrCaseStatus.CONCLUDED_CLEAR.name();
this.fiuDueDate = conclusion == AmlStrConclusion.SUSPICIOUS ? fiuDueDate : null;
}
public void fileStr(String referenceNo, LocalDate filedOn) {
this.strReferenceNo = referenceNo;
this.strFiledOn = filedOn;
this.status = AmlStrCaseStatus.FILED.name();
}
public Long getClientId() {
return client.getId();
}
public AmlAlert getAlert() {
return alert;
}
public Client getClient() {
return client;
}
public AppUser getAssignedPo() {
return assignedPo;
}
public String getInvestigationNotes() {
return investigationNotes;
}
public String getSourceOfFundsNotes() {
return sourceOfFundsNotes;
}
public AmlStrConclusion getConclusion() {
return conclusion == null ? null : AmlStrConclusion.valueOf(conclusion);
}
public AppUser getConcludedBy() {
return concludedBy;
}
public LocalDate getConcludedOn() {
return concludedOn;
}
public LocalDate getFiuDueDate() {
return fiuDueDate;
}
public LocalDate getStrFiledOn() {
return strFiledOn;
}
public String getStrReferenceNo() {
return strReferenceNo;
}
public AmlStrCaseStatus getStatus() {
return AmlStrCaseStatus.valueOf(status);
}
}
@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
long countByClient_Id(Long clientId);
List<AmlStrCase> findByStatus(String status);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
List<AmlStrCase> findConcludedSuspiciousBetween(@Param("from") LocalDate from, @Param("to") LocalDate to);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.fiuDueDate < :asOf")
List<AmlStrCase> findOverdueForFiling(@Param("asOf") LocalDate asOf);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
public enum AmlStrCaseStatus {
OPEN, CONCLUDED_CLEAR, CONCLUDED_SUSPICIOUS, FILED
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** The Principal Officer's final conclusion on an {@link AmlStrCase}. */
public enum AmlStrConclusion {
NOT_SUSPICIOUS, SUSPICIOUS
}
@@ -0,0 +1,140 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
/**
* A machine-evaluable transaction-monitoring rule. Distinct from {@code ClientRiskRule}
* (client-profile, KYC-time scoring) - these are checked against live savings/loan/transfer
* transactions as they post. {@link #sourceClientRiskRuleId} optionally traces a rule back to its
* originating entry in the 221-rule AML catalog seeded in {@code m_client_risk_rule}.
*/
@Entity
@Table(name = "m_aml_transaction_rule")
public class AmlTransactionRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rule_code", nullable = false, length = 50)
private String ruleCode;
@Column(name = "description", nullable = false, length = 500)
private String description;
@Column(name = "applies_to", nullable = false, length = 20)
private String appliesTo;
@Column(name = "threshold_amount", precision = 19, scale = 6)
private BigDecimal thresholdAmount;
/** Risk-category-tiered overrides of {@link #thresholdAmount}. Null on both means the rule is
* not tiered; {@link #thresholdAmount} is used for every client regardless of risk category. */
@Column(name = "threshold_amount_medium_risk", precision = 19, scale = 6)
private BigDecimal thresholdAmountMediumRisk;
@Column(name = "threshold_amount_high_risk", precision = 19, scale = 6)
private BigDecimal thresholdAmountHighRisk;
/**
* Declarative band the rule covers, shown on the AML Rules Master screen. Purely
* descriptive: unlike the tiered columns above these never take part in matching, so
* changing them cannot alter which transactions raise an alert. A null maximum means
* the rule is genuinely open-ended.
*/
@Column(name = "min_limit_amount", precision = 19, scale = 6)
private BigDecimal minLimitAmount;
@Column(name = "max_limit_amount", precision = 19, scale = 6)
private BigDecimal maxLimitAmount;
@Column(name = "rule_type", nullable = false, length = 20)
private String ruleType;
@Column(name = "window_days")
private Integer windowDays;
@Column(name = "source_client_risk_rule_id")
private Long sourceClientRiskRuleId;
@Column(name = "is_active", nullable = false)
private boolean active;
protected AmlTransactionRule() {}
public Long getId() {
return id;
}
public String getRuleCode() {
return ruleCode;
}
public String getDescription() {
return description;
}
public AmlAppliesTo getAppliesTo() {
return AmlAppliesTo.valueOf(appliesTo);
}
public BigDecimal getThresholdAmount() {
return thresholdAmount;
}
public BigDecimal getThresholdAmountMediumRisk() {
return thresholdAmountMediumRisk;
}
public BigDecimal getThresholdAmountHighRisk() {
return thresholdAmountHighRisk;
}
public BigDecimal getMinLimitAmount() {
return minLimitAmount;
}
public BigDecimal getMaxLimitAmount() {
return maxLimitAmount;
}
public AmlRuleType getRuleType() {
return AmlRuleType.valueOf(ruleType);
}
public Integer getWindowDays() {
return windowDays;
}
public Long getSourceClientRiskRuleId() {
return sourceClientRiskRuleId;
}
public boolean isActive() {
return active;
}
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
/**
* Looks a rule up by its code irrespective of {@code is_active}. Needed for MANUAL_REFERRAL, which is deliberately
* seeded inactive so the evaluation engine never fires it, yet must still be attachable to an alert a user raises
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
}
@@ -0,0 +1,25 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/** The kind of ledger transaction an {@link AmlAlert} was raised against. */
public enum AmlTransactionType {
SAVINGS, LOAN, TRANSFER
}
@@ -0,0 +1,138 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* Immutable, append-only audit trail of a client's KYC risk-category changes. Backs the AML menu's
* existing "Risk Update History Report" item, which previously had no data behind it. Written both
* for manual re-categorization and for the automatic escalation triggered on a client's 3rd
* cumulative {@link AmlStrCase}.
*/
@Entity
@Table(name = "m_client_risk_category_history")
public class ClientRiskCategoryHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@Column(name = "previous_category", nullable = false)
private Integer previousCategory;
@Column(name = "new_category", nullable = false)
private Integer newCategory;
@Column(name = "reason", nullable = false, length = 500)
private String reason;
@Column(name = "triggered_by", nullable = false, length = 30)
private String triggeredBy;
@ManyToOne
@JoinColumn(name = "str_case_id")
private AmlStrCase strCase;
@Column(name = "changed_on", nullable = false)
private LocalDateTime changedOn;
@ManyToOne
@JoinColumn(name = "changed_by", nullable = false)
private AppUser changedBy;
protected ClientRiskCategoryHistory() {}
public static ClientRiskCategoryHistory autoEscalation(Client client, Integer previousCategory, Integer newCategory,
AmlStrCase triggeringCase, AppUser systemActor, LocalDateTime changedOn) {
final ClientRiskCategoryHistory history = new ClientRiskCategoryHistory();
history.client = client;
history.previousCategory = previousCategory;
history.newCategory = newCategory;
history.reason = "Auto-escalated after client's 3rd cumulative AML STR case (case #" + triggeringCase.getId() + ")";
history.triggeredBy = RiskCategoryChangeTrigger.AML_AUTO_ESCALATION.name();
history.strCase = triggeringCase;
history.changedOn = changedOn;
history.changedBy = systemActor;
return history;
}
public static ClientRiskCategoryHistory manual(Client client, Integer previousCategory, Integer newCategory, String reason,
AppUser changedBy, LocalDateTime changedOn) {
final ClientRiskCategoryHistory history = new ClientRiskCategoryHistory();
history.client = client;
history.previousCategory = previousCategory;
history.newCategory = newCategory;
history.reason = reason;
history.triggeredBy = RiskCategoryChangeTrigger.MANUAL.name();
history.changedBy = changedBy;
history.changedOn = changedOn;
return history;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
public Integer getPreviousCategory() {
return previousCategory;
}
public Integer getNewCategory() {
return newCategory;
}
public String getReason() {
return reason;
}
public RiskCategoryChangeTrigger getTriggeredBy() {
return RiskCategoryChangeTrigger.valueOf(triggeredBy);
}
public AmlStrCase getStrCase() {
return strCase;
}
public LocalDateTime getChangedOn() {
return changedOn;
}
public AppUser getChangedBy() {
return changedBy;
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClientRiskCategoryHistoryRepository extends JpaRepository<ClientRiskCategoryHistory, Long> {
List<ClientRiskCategoryHistory> findByClient_IdOrderByChangedOnDesc(Long clientId);
}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
public enum RiskCategoryChangeTrigger {
AML_AUTO_ESCALATION, MANUAL
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.data;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class ClientFamilyMemberChildData implements Serializable {
private final Long id;
private final String fullName;
private final Long genderId;
private final String gender;
private final LocalDate dateOfBirth;
}
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
private final LocalDate marriageDate; private final LocalDate marriageDate;
private final String marriageAuthority; private final String marriageAuthority;
@lombok.Setter
private java.util.List<ClientFamilyMemberChildData> children;
// template holder // template holder
private final Collection<CodeValueData> relationshipIdOptions; private final Collection<CodeValueData> relationshipIdOptions;
private final Collection<CodeValueData> genderIdOptions; private final Collection<CodeValueData> genderIdOptions;
@@ -617,6 +617,10 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.office.getId(); return this.office.getId();
} }
public String getDisplayName() {
return this.displayName;
}
public String mobileNo() { public String mobileNo() {
return this.mobileNo; return this.mobileNo;
} }
@@ -586,6 +586,15 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return riskCategory; return riskCategory;
} }
/**
* Directly sets the risk category outside the normal command-diff update path. Used by the AML
* module to auto-escalate a client's risk category after their 3rd cumulative STR case -
* every such change is recorded in {@code m_client_risk_category_history} by the caller.
*/
public void escalateRiskCategory(Integer newCategory) {
this.riskCategory = newCategory;
}
public String getPanNumber() { public String getPanNumber() {
return panNumber; return panNumber;
} }
@@ -72,9 +72,24 @@ public class ClientRiskRule {
@Column(name = "risk_category", nullable = false) @Column(name = "risk_category", nullable = false)
private Integer riskCategory; private Integer riskCategory;
@Column(name = "reason", nullable = false, length = 255) @Column(name = "reason", nullable = false, length = 500)
private String reason; private String reason;
@Column(name = "aml_check_status", length = 20)
private String amlCheckStatus;
@Column(name = "banned_entity_match", length = 10)
private String bannedEntityMatch;
@Column(name = "willful_defaulter", length = 10)
private String willfulDefaulter;
@Column(name = "ecgc_sal_flag", length = 10)
private String ecgcSalFlag;
@Column(name = "dormant_flag", length = 20)
private String dormantFlag;
@Column(name = "order_position", nullable = false) @Column(name = "order_position", nullable = false)
private Integer orderPosition; private Integer orderPosition;
@@ -135,4 +150,24 @@ public class ClientRiskRule {
public Integer getOrderPosition() { public Integer getOrderPosition() {
return orderPosition; return orderPosition;
} }
public String getAmlCheckStatus() {
return amlCheckStatus;
}
public String getBannedEntityMatch() {
return bannedEntityMatch;
}
public String getWillfulDefaulter() {
return willfulDefaulter;
}
public String getEcgcSalFlag() {
return ecgcSalFlag;
}
public String getDormantFlag() {
return dormantFlag;
}
} }
@@ -154,6 +154,11 @@ public class SecurityConfig {
// a Fineract Bearer token; security is enforced at the JWT envelope // a Fineract Bearer token; security is enforced at the JWT envelope
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler. // (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
// Client edit approval email open-tracking pixel — hit by the client's email client
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
// path is the only capability credential; the endpoint always returns a GIF so
// token validity is not leaked via different HTTP responses.
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed // NPCI clearing-house receiver — the real, separate NPCI service pushes routed
// packages here without a Fineract Bearer token; security is enforced by the // packages here without a Fineract Bearer token; security is enforced by the
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler. // envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/alerts/{id}/review}. {@code disposition}: WHITELIST | ESCALATE. */
@Getter
@Setter
@NoArgsConstructor
public class AlertReviewRequest {
private String disposition;
private String notes;
}
@@ -0,0 +1,107 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.service.AmlAlertReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/alerts")
@Component
@Tag(name = "AML Alerts", description = "Live transaction-monitoring alerts raised by the AML rule engine against savings, loan and fund-transfer activity.")
@RequiredArgsConstructor
public class AmlAlertApiResource {
private final PlatformSecurityContext context;
private final AmlAlertReadPlatformService readPlatformService;
private final AmlAlertWritePlatformService writePlatformService;
private final JournalEntryReferralService referralService;
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List AML alerts",
description = "Optionally filter by status via ?status= : RAISED, UNDER_REVIEW, WHITELISTED, ESCALATED.")
public String retrieveAll(@QueryParam("status") final String status) {
this.context.authenticatedUser();
final List<AmlAlertData> alerts = this.readPlatformService.retrieve(status);
return this.toApiJsonSerializer.serialize(alerts);
}
@POST
@Path("{alertId}/review")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "AML Officer first-level review of an alert",
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
this.writePlatformService.escalate(alertId, request.getNotes());
} else {
this.writePlatformService.whitelist(alertId, request.getNotes());
}
final AmlAlertData updated = this.readPlatformService.retrieve(null).stream().filter(a -> a.id().equals(alertId)).findFirst()
.orElse(null);
return this.toApiJsonSerializer.serialize(updated);
}
@GET
@Path("manual-referrals")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List ledger entries marked by a user",
description = "Every entry marked cleared or suspicious from the general ledger screen, keyed on journalEntryId so the ledger can show what has already been dealt with.")
public String retrieveManualReferrals() {
this.context.authenticatedUser();
return this.referralJsonSerializer.serialize(this.referralService.retrieveAll());
}
@POST
@Path("manual-referral")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Mark a ledger entry cleared or suspicious",
description = "WHITELIST clears the entry with no report. ESCALATE raises an alert against the MANUAL_REFERRAL rule and escalates it, so an STR case opens for the Principal Officer exactly as an engine alert would. The entry must have a customer behind it.")
public String referJournalEntry(final ManualReferralRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final ManualReferralData referral = this.referralService.refer(request.getJournalEntryId(), request.getDisposition(),
request.getNotes());
return this.referralJsonSerializer.serialize(referral);
}
}
@@ -0,0 +1,72 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.YearMonth;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/ctr-reports")
@Component
@Tag(name = "AML CTR Reports",
description = "Monthly, mechanical Cash Transaction Reports - threshold-based, no Principal Officer judgment involved.")
@RequiredArgsConstructor
public class AmlCtrReportApiResource {
private final PlatformSecurityContext context;
private final AmlCtrReportReadPlatformService readPlatformService;
private final AmlCtrReportGenerationService generationService;
private final DefaultToApiJsonSerializer<AmlCtrReportData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List generated CTR reports")
public String retrieveAll() {
this.context.authenticatedUser();
final List<AmlCtrReportData> reports = this.readPlatformService.retrieveAll();
return this.toApiJsonSerializer.serialize(reports);
}
@POST
@Path("generate")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Generate the CTR report for a month", description = "month format: yyyy-MM. Idempotent - fails if already generated.")
public String generate(final CtrGenerateRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
final YearMonth month = YearMonth.parse(request.getMonth());
this.generationService.generateForMonth(month);
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveAll());
}
}
@@ -0,0 +1,118 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.AmlStrCaseData;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
import org.apache.fineract.portfolio.aml.service.AmlStrCaseReadPlatformService;
import org.apache.fineract.portfolio.aml.service.AmlStrCaseWritePlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/aml/str-cases")
@Component
@Tag(name = "AML STR Cases",
description = "Principal-Officer-only investigation and conclusion of escalated AML alerts, and STR filing to FIU-IND.")
@RequiredArgsConstructor
public class AmlStrCaseApiResource {
private final PlatformSecurityContext context;
private final AmlStrCaseReadPlatformService readPlatformService;
private final AmlStrCaseWritePlatformService writePlatformService;
private final DefaultToApiJsonSerializer<AmlStrCaseData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List AML STR cases")
public String retrieveAll() {
this.context.authenticatedUser();
final List<AmlStrCaseData> cases = this.readPlatformService.retrieveAll();
return this.toApiJsonSerializer.serialize(cases);
}
@GET
@Path("{strCaseId}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve one AML STR case")
public String retrieveOne(@PathParam("strCaseId") final Long strCaseId) {
this.context.authenticatedUser();
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
@GET
@Path("weekly-digest")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Internal MIS digest of STR cases concluded suspicious in the last 7 days",
description = "For compliance tracking only - does not replace each case's own 7-working-day FIU filing deadline.")
public String weeklyDigest() {
this.context.authenticatedUser();
final List<AmlStrCaseData> digest = this.readPlatformService.weeklyDigest();
return this.toApiJsonSerializer.serialize(digest);
}
@POST
@Path("{strCaseId}/assign")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Assign a Principal Officer to an STR case")
public String assign(@PathParam("strCaseId") final Long strCaseId, final StrCaseAssignRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("CONCLUDE_AML_STR");
this.writePlatformService.assignPrincipalOfficer(strCaseId, request.getPrincipalOfficerUserId());
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
@POST
@Path("{strCaseId}/conclude")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Principal Officer conclusion",
description = "conclusion NOT_SUSPICIOUS closes the case; SUSPICIOUS sets the 7-working-day FIU filing due date starting today.")
public String conclude(@PathParam("strCaseId") final Long strCaseId, final StrCaseConcludeRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("CONCLUDE_AML_STR");
final AmlStrConclusion conclusion = AmlStrConclusion.valueOf(request.getConclusion());
this.writePlatformService.conclude(strCaseId, conclusion, request.getInvestigationNotes(), request.getSourceOfFundsNotes());
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
@POST
@Path("{strCaseId}/file")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record that the STR has been filed with FIU-IND")
public String fileStr(@PathParam("strCaseId") final Long strCaseId, final StrCaseFileRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("CONCLUDE_AML_STR");
this.writePlatformService.fileStr(strCaseId, request.getReferenceNo());
return this.toApiJsonSerializer.serialize(this.readPlatformService.retrieveOne(strCaseId));
}
}
@@ -0,0 +1,57 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.ClientRiskCategoryHistoryData;
import org.apache.fineract.portfolio.aml.service.ClientRiskCategoryHistoryReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/clients/{clientId}/risk-category-history")
@Component
@Tag(name = "Client Risk Category History",
description = "Audit trail of a client's KYC risk-category changes, including automatic escalation after repeated AML STR cases.")
@RequiredArgsConstructor
public class ClientRiskCategoryHistoryApiResource {
private final PlatformSecurityContext context;
private final ClientRiskCategoryHistoryReadPlatformService readPlatformService;
private final DefaultToApiJsonSerializer<ClientRiskCategoryHistoryData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a client's risk-category change history")
public String retrieveForClient(@PathParam("clientId") final Long clientId) {
this.context.authenticatedUser();
final List<ClientRiskCategoryHistoryData> history = this.readPlatformService.retrieveForClient(clientId);
return this.toApiJsonSerializer.serialize(history);
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/ctr-reports/generate}. {@code month} format: yyyy-MM. */
@Getter
@Setter
@NoArgsConstructor
public class CtrGenerateRequest {
private String month;
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Request body for {@code POST /v1/aml/alerts/manual-referral}.
*
* {@code journalEntryId} is acc_gl_journal_entry.id as shown in the ledger. {@code disposition} is WHITELIST (cleared,
* no report) or ESCALATE (raises an STR case for the Principal Officer).
*/
@Getter
@Setter
@NoArgsConstructor
public class ManualReferralRequest {
private Long journalEntryId;
private String disposition;
private String notes;
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/str-cases/{id}/assign}. */
@Getter
@Setter
@NoArgsConstructor
public class StrCaseAssignRequest {
private Long principalOfficerUserId;
}
@@ -0,0 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/str-cases/{id}/conclude}. {@code conclusion}: NOT_SUSPICIOUS | SUSPICIOUS. */
@Getter
@Setter
@NoArgsConstructor
public class StrCaseConcludeRequest {
private String conclusion;
private String investigationNotes;
private String sourceOfFundsNotes;
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/** Request body for {@code POST /v1/aml/str-cases/{id}/file}. */
@Getter
@Setter
@NoArgsConstructor
public class StrCaseFileRequest {
private String referenceNo;
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable {
}
@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.time.LocalDateTime;
public record AmlCtrReportData(Long id, String reportMonth, LocalDateTime generatedOn, int recordCount, String fileReference,
String status, LocalDateTime submittedOn) implements Serializable {
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.time.LocalDate;
public record AmlStrCaseData(Long id, Long alertId, Long clientId, String clientDisplayName, String assignedPoUsername,
String investigationNotes, String sourceOfFundsNotes, String conclusion, String concludedByUsername, LocalDate concludedOn,
LocalDate fiuDueDate, LocalDate strFiledOn, String strReferenceNo, String status, long cumulativeStrCaseCountForClient)
implements Serializable {
}
@@ -0,0 +1,92 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client;
/**
* Normalized view of a savings/loan/transfer transaction handed to {@code AmlRuleEvaluationService}
* by the business-event listener that observed it. Keeps the evaluation service itself decoupled
* from the savings/loan/fund-transfer domain models - each listener is responsible for building
* this context (including, where relevant, {@link #windowAggregateAmount}) from the repositories
* it already has access to.
*/
public final class AmlTransactionContext {
private final AmlTransactionType transactionType;
private final Long transactionId;
private final Client client;
private final BigDecimal amount;
private final String currencyCode;
private final LocalDateTime occurredOn;
private final boolean cash;
/**
* For STRUCTURING/VELOCITY rules: the sum of this client's qualifying transactions of this
* type within the rule's window, INCLUSIVE of this transaction. Null if not computed - such
* rules are simply skipped for this context rather than guessed at.
*/
private final BigDecimal windowAggregateAmount;
public AmlTransactionContext(AmlTransactionType transactionType, Long transactionId, Client client, BigDecimal amount,
String currencyCode, LocalDateTime occurredOn, boolean cash, BigDecimal windowAggregateAmount) {
this.transactionType = transactionType;
this.transactionId = transactionId;
this.client = client;
this.amount = amount;
this.currencyCode = currencyCode;
this.occurredOn = occurredOn;
this.cash = cash;
this.windowAggregateAmount = windowAggregateAmount;
}
public AmlTransactionType getTransactionType() {
return transactionType;
}
public Long getTransactionId() {
return transactionId;
}
public Client getClient() {
return client;
}
public BigDecimal getAmount() {
return amount;
}
public String getCurrencyCode() {
return currencyCode;
}
public LocalDateTime getOccurredOn() {
return occurredOn;
}
public boolean isCash() {
return cash;
}
public BigDecimal getWindowAggregateAmount() {
return windowAggregateAmount;
}
}
@@ -0,0 +1,26 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.time.LocalDateTime;
public record ClientRiskCategoryHistoryData(Long id, Long clientId, Integer previousCategory, Integer newCategory, String reason,
String triggeredBy, Long strCaseId, LocalDateTime changedOn, String changedByUsername) implements Serializable {
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One ledger entry a user has marked, so the ledger screen can show what has already been dealt with.
*
* {@code journalEntryId} is the acc_gl_journal_entry row the mark was made against - it is what the ledger table keys
* on. {@code strCaseId} is populated only where the mark escalated into an STR case.
*/
public record ManualReferralData(Long alertId, Long journalEntryId, Long clientId, String clientDisplayName,
BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status, String disposition,
String reviewedByUsername, String reviewNotes, Long strCaseId) implements Serializable {}
@@ -0,0 +1,24 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.event;
/** Marker interface whose only implementation registers AML business-event listeners on startup. */
public interface AmlDomainService {
}
@@ -0,0 +1,153 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.event;
import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.event.business.BusinessEventListener;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanDisbursalTransactionBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanTransactionMakeRepaymentPostBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsDepositBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.savings.transaction.SavingsWithdrawalBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.service.AmlRuleEvaluationService;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
/**
* Registers post-business-event listeners that evaluate every live savings and loan transaction
* against the AML rule catalog, purely additively - listeners observe transactions that already
* completed successfully and never alter their outcome. Runs synchronously in the same request,
* mirroring {@code org.apache.fineract.notification.service.NotificationDomainServiceImpl}, so an
* alert is atomic with the transaction that raised it.
*/
@RequiredArgsConstructor
@Slf4j
public class AmlDomainServiceImpl implements AmlDomainService {
/**
* Fixed lookback used for every STRUCTURING/VELOCITY rule regardless of its own configured
* window_days - a v1 approximation (errs toward over-inclusion, the safer failure mode for AML
* monitoring) until per-rule windows are wired through.
*/
private static final int STRUCTURING_WINDOW_DAYS = 30;
private final BusinessEventNotifierService businessEventNotifierService;
private final AmlRuleEvaluationService ruleEvaluationService;
private final AmlSavingsTransactionQueryRepository savingsQueryRepository;
private final AmlLoanTransactionQueryRepository loanQueryRepository;
@PostConstruct
public void addListeners() {
businessEventNotifierService.addPostBusinessEventListener(SavingsDepositBusinessEvent.class, new SavingsDepositListener());
businessEventNotifierService.addPostBusinessEventListener(SavingsWithdrawalBusinessEvent.class, new SavingsWithdrawalListener());
businessEventNotifierService.addPostBusinessEventListener(LoanDisbursalTransactionBusinessEvent.class,
new LoanDisbursalListener());
businessEventNotifierService.addPostBusinessEventListener(LoanTransactionMakeRepaymentPostBusinessEvent.class,
new LoanRepaymentListener());
}
private final class SavingsDepositListener implements BusinessEventListener<SavingsDepositBusinessEvent> {
@Override
public void onBusinessEvent(SavingsDepositBusinessEvent event) {
evaluateSavingsTransaction(event.get(), true);
}
}
private final class SavingsWithdrawalListener implements BusinessEventListener<SavingsWithdrawalBusinessEvent> {
@Override
public void onBusinessEvent(SavingsWithdrawalBusinessEvent event) {
evaluateSavingsTransaction(event.get(), false);
}
}
private final class LoanDisbursalListener implements BusinessEventListener<LoanDisbursalTransactionBusinessEvent> {
@Override
public void onBusinessEvent(LoanDisbursalTransactionBusinessEvent event) {
evaluateLoanTransaction(event.get());
}
}
private final class LoanRepaymentListener implements BusinessEventListener<LoanTransactionMakeRepaymentPostBusinessEvent> {
@Override
public void onBusinessEvent(LoanTransactionMakeRepaymentPostBusinessEvent event) {
evaluateLoanTransaction(event.get());
}
}
private void evaluateSavingsTransaction(SavingsAccountTransaction transaction, boolean depositDirection) {
final Client client = transaction.getSavingsAccount().getClient();
if (client == null) {
// group/JLG savings with no individual client behind it are out of scope for this rule set
return;
}
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<SavingsAccountTransaction> recent = savingsQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(t -> depositDirection ? t.isDeposit() : t.isWithdrawal())
.map(SavingsAccountTransaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.SAVINGS, transaction.getId(), client,
transaction.getAmount(), transaction.getSavingsAccount().getCurrency().getCode(),
transaction.getDateOf().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
try {
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
// AML alerting must never block the underlying savings transaction it is observing
log.error("AML rule evaluation failed for savings transaction {}", transaction.getId(), e);
}
}
private void evaluateLoanTransaction(LoanTransaction transaction) {
final Client client = transaction.getLoan().getClient();
if (client == null) {
return;
}
final LocalDate since = LocalDate.now().minusDays(STRUCTURING_WINDOW_DAYS);
final List<LoanTransaction> recent = loanQueryRepository.findForClientSince(client.getId(), since);
final BigDecimal windowAggregate = recent.stream().filter(LoanTransaction::isRepaymentLikeType)
.map(t -> t.getAmount(transaction.getLoan().getCurrency()).getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.LOAN, transaction.getId(), client,
transaction.getAmount(transaction.getLoan().getCurrency()).getAmount(), transaction.getLoan().getCurrency().getCode(),
transaction.getTransactionDate().atStartOfDay(), isCash(transaction.getPaymentDetail()), windowAggregate);
try {
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
log.error("AML rule evaluation failed for loan transaction {}", transaction.getId(), e);
}
}
private boolean isCash(PaymentDetail paymentDetail) {
return paymentDetail == null || Boolean.TRUE.equals(paymentDetail.getPaymentType().getIsCashPayment());
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.job;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class AmlCtrReportBatchJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private AmlCtrReportGenerationService generationService;
@Bean
public AmlCtrReportBatchTasklet amlCtrReportBatchTasklet() {
return new AmlCtrReportBatchTasklet(generationService);
}
@Bean
protected Step amlCtrReportBatchStep() {
return new StepBuilder(JobName.CTR_REPORT_GENERATION.name(), jobRepository)
.tasklet(amlCtrReportBatchTasklet(), transactionManager).build();
}
@Bean
public Job amlCtrReportBatchJob() {
return new JobBuilder(JobName.CTR_REPORT_GENERATION.name(), jobRepository).start(amlCtrReportBatchStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,55 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.job;
import java.time.YearMonth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.service.AmlCtrReportGenerationService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Generates the CTR report for the previous calendar month as a {@code DRAFT} - mechanical
* threshold aggregation only. Admin-triggered today (see {@code AmlCtrReportApiResource}); wiring
* to a real monthly cron is a follow-up, matching the same "driven by an admin endpoint today"
* stance already used for {@code NeftBatchProcessorService}.
*/
@Slf4j
@RequiredArgsConstructor
public class AmlCtrReportBatchTasklet implements Tasklet {
private final AmlCtrReportGenerationService generationService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
final YearMonth previousMonth = YearMonth.now().minusMonths(1);
try {
final AmlCtrReport report = generationService.generateForMonth(previousMonth);
log.info("[Job:CTR_REPORT_GENERATION] generated CTR report for {} with {} line(s)", previousMonth,
report.getRecordCount());
} catch (final IllegalStateException e) {
log.info("[Job:CTR_REPORT_GENERATION] {} — skipping", e.getMessage());
}
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.job;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.aml.service.AmlStrWeeklyDigestService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class AmlStrWeeklyDigestJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private AmlStrWeeklyDigestService digestService;
@Bean
public AmlStrWeeklyDigestTasklet amlStrWeeklyDigestTasklet() {
return new AmlStrWeeklyDigestTasklet(digestService);
}
@Bean
protected Step amlStrWeeklyDigestStep() {
return new StepBuilder(JobName.AML_STR_WEEKLY_DIGEST.name(), jobRepository)
.tasklet(amlStrWeeklyDigestTasklet(), transactionManager).build();
}
@Bean
public Job amlStrWeeklyDigestJob() {
return new JobBuilder(JobName.AML_STR_WEEKLY_DIGEST.name(), jobRepository).start(amlStrWeeklyDigestStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.job;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.service.AmlStrWeeklyDigestService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Periodic heartbeat that surfaces the current week's suspicious-STR count in the application log
* for ops visibility. The actual digest content (for compliance MIS use) is pulled on demand via
* {@code AmlStrCaseApiResource#weeklyDigest}, mirroring how the CTR job only drafts a report that a
* human then reviews/exports.
*/
@Slf4j
@RequiredArgsConstructor
public class AmlStrWeeklyDigestTasklet implements Tasklet {
private final AmlStrWeeklyDigestService digestService;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
final List<AmlStrCase> digest = digestService.currentWeekDigest();
log.info("[Job:AML_STR_WEEKLY_DIGEST] {} STR case(s) concluded suspicious in the last 7 days", digest.size());
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,44 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.repository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Read-only aggregation over {@code FundTransfer} for AML window-based rules (structuring /
* velocity), across all channels (INTERNAL/IMPS/NEFT/RTGS).
*
* <p>
* Lives under {@code .repository} (not {@code .service}) because {@code JPAConfig} only scans
* {@code org.apache.fineract.**.domain} and {@code org.apache.fineract.**.repository} for
* {@code @EnableJpaRepositories}.
*/
public interface AmlFundTransferQueryRepository extends JpaRepository<FundTransfer, Long> {
@Query("SELECT COALESCE(SUM(t.amount), 0) FROM FundTransfer t WHERE t.clientId = :clientId "
+ "AND t.status = :status AND t.initiatedAt >= :since")
BigDecimal sumForClientSince(@Param("clientId") Long clientId, @Param("status") FundTransferStatus status,
@Param("since") LocalDateTime since);
}
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.repository;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Read-only aggregation queries over {@code LoanTransaction} for AML window-based rules
* (structuring / velocity). Direction/kind (repayment-like) is filtered in Java via
* {@link LoanTransaction#isRepaymentLikeType()} rather than in JPQL.
*
* <p>
* Lives under {@code .repository} (not {@code .service}) because {@code JPAConfig} only scans
* {@code org.apache.fineract.**.domain} and {@code org.apache.fineract.**.repository} for
* {@code @EnableJpaRepositories}.
*/
public interface AmlLoanTransactionQueryRepository extends JpaRepository<LoanTransaction, Long> {
@Query("SELECT t FROM LoanTransaction t WHERE t.loan.client.id = :clientId AND t.dateOf >= :since AND t.reversed = false")
List<LoanTransaction> findForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDate since);
@Query("SELECT t FROM LoanTransaction t WHERE t.dateOf BETWEEN :start AND :end AND t.reversed = false")
List<LoanTransaction> findInDateRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.repository;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* Read-only aggregation queries over {@code SavingsAccountTransaction} for AML window-based rules
* (structuring / velocity). Kept separate from {@code SavingsAccountTransactionRepository} so the
* AML module doesn't modify savings module code - direction (deposit/withdrawal) is filtered in
* Java via {@link SavingsAccountTransaction#isDeposit()}/{@code isWithdrawal()} rather than in
* JPQL, since {@code typeOf} there is a raw internal code.
*
* <p>
* Lives under {@code .repository} (not {@code .service}) because {@code JPAConfig} only scans
* {@code org.apache.fineract.**.domain} and {@code org.apache.fineract.**.repository} for
* {@code @EnableJpaRepositories}.
*/
public interface AmlSavingsTransactionQueryRepository extends JpaRepository<SavingsAccountTransaction, Long> {
@Query("SELECT t FROM SavingsAccountTransaction t WHERE t.savingsAccount.client.id = :clientId "
+ "AND t.dateOf >= :since AND t.reversed = false")
List<SavingsAccountTransaction> findForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDate since);
@Query("SELECT t FROM SavingsAccountTransaction t WHERE t.dateOf BETWEEN :start AND :end AND t.reversed = false")
List<SavingsAccountTransaction> findInDateRange(@Param("start") LocalDate start, @Param("end") LocalDate end);
}
@@ -0,0 +1,28 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
public interface AmlAlertReadPlatformService {
/** @param statusFilter one of RAISED/UNDER_REVIEW/WHITELISTED/ESCALATED, or null/blank for all. */
List<AmlAlertData> retrieve(String statusFilter);
}
@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
private final AmlAlertRepository repository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
.collect(Collectors.toList());
}
private AmlAlertData mapToData(AmlAlert alert) {
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
}
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
/** First-level AML Officer disposition on an {@link AmlAlert}. */
public interface AmlAlertWritePlatformService {
AmlAlert whitelist(Long alertId, String notes);
AmlStrCase escalate(Long alertId, String notes);
}
@@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
private final AmlAlertRepository alertRepository;
private final AmlStrCaseRepository strCaseRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
@Override
public AmlAlert whitelist(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
alert.whitelist(context.authenticatedUser(), notes);
return alertRepository.save(alert);
}
@Override
public AmlStrCase escalate(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
alert.escalate(context.authenticatedUser(), notes);
alertRepository.save(alert);
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
return strCase;
}
private AmlAlert loadAlert(Long alertId) {
return alertRepository.findById(alertId)
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
}
}
@@ -0,0 +1,140 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Monthly, mechanical CTR batch: every individually-large cash transaction (savings or loan, ≥
* ₹10L) gets its own line; every client whose smaller cash transactions of a single type sum to ≥
* ₹10L within the month gets one connected-group line for that type. Purely threshold-based - no
* Principal Officer judgment, matching the regulation. NEFT/RTGS transfers are non-cash by nature
* and are out of scope for CTR.
*
* <p>
* Known v1 limitation: a client splitting cash activity across BOTH savings and loan channels to
* stay under ₹10L in each is not caught by this aggregation - each channel is summed separately.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class AmlCtrReportGenerationService {
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
private final AmlCtrReportRepository ctrReportRepository;
private final AmlCtrReportLineRepository ctrReportLineRepository;
private final AmlSavingsTransactionQueryRepository savingsQueryRepository;
private final AmlLoanTransactionQueryRepository loanQueryRepository;
public AmlCtrReport generateForMonth(YearMonth month) {
final String reportMonth = month.toString();
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
}
final LocalDate start = month.atDay(1);
final LocalDate end = month.atEndOfMonth();
final List<PendingLine> pendingLines = new ArrayList<>();
final Map<Long, BigDecimal> smallSavingsCashByClient = new HashMap<>();
final Map<Long, BigDecimal> smallLoanCashByClient = new HashMap<>();
for (final SavingsAccountTransaction t : savingsQueryRepository.findInDateRange(start, end)) {
if (!(t.isDeposit() || t.isWithdrawal()) || !isCash(t.getPaymentDetail())) {
continue;
}
final Client client = t.getSavingsAccount().getClient();
if (client == null) {
continue;
}
if (t.getAmount().compareTo(CTR_THRESHOLD) >= 0) {
pendingLines.add(new PendingLine(AmlTransactionType.SAVINGS, t.getId(), client.getId(), t.getAmount(), false));
} else {
smallSavingsCashByClient.merge(client.getId(), t.getAmount(), BigDecimal::add);
}
}
for (final LoanTransaction t : loanQueryRepository.findInDateRange(start, end)) {
if (!t.isRepaymentLikeType() || !isCash(t.getPaymentDetail())) {
continue;
}
final Client client = t.getLoan().getClient();
if (client == null) {
continue;
}
final BigDecimal amount = t.getAmount(t.getLoan().getCurrency()).getAmount();
if (amount.compareTo(CTR_THRESHOLD) >= 0) {
pendingLines.add(new PendingLine(AmlTransactionType.LOAN, t.getId(), client.getId(), amount, false));
} else {
smallLoanCashByClient.merge(client.getId(), amount, BigDecimal::add);
}
}
addConnectedGroupLines(pendingLines, smallSavingsCashByClient, AmlTransactionType.SAVINGS);
addConnectedGroupLines(pendingLines, smallLoanCashByClient, AmlTransactionType.LOAN);
final AmlCtrReport report = ctrReportRepository
.save(AmlCtrReport.draft(reportMonth, LocalDateTime.now(), pendingLines.size()));
for (final PendingLine pending : pendingLines) {
ctrReportLineRepository.save(AmlCtrReportLine.of(report, pending.transactionType(), pending.transactionId(),
pending.clientId(), pending.amount(), pending.connectedGroup()));
}
return report;
}
private void addConnectedGroupLines(List<PendingLine> pendingLines, Map<Long, BigDecimal> smallAmountsByClient,
AmlTransactionType transactionType) {
smallAmountsByClient.forEach((clientId, total) -> {
if (total.compareTo(CTR_THRESHOLD) >= 0) {
pendingLines.add(new PendingLine(transactionType, null, clientId, total, true));
}
});
}
/** Holds a line's values before the parent {@link AmlCtrReport} row exists to attach them to. */
private record PendingLine(AmlTransactionType transactionType, Long transactionId, Long clientId, BigDecimal amount,
boolean connectedGroup) {
}
private boolean isCash(PaymentDetail paymentDetail) {
return paymentDetail == null || Boolean.TRUE.equals(paymentDetail.getPaymentType().getIsCashPayment());
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
public interface AmlCtrReportReadPlatformService {
List<AmlCtrReportData> retrieveAll();
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlCtrReportData;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlatformService {
private final AmlCtrReportRepository repository;
@Override
public List<AmlCtrReportData> retrieveAll() {
return repository.findAll().stream().sorted(Comparator.comparing(AmlCtrReport::getReportMonth).reversed())
.map(this::mapToData).collect(Collectors.toList());
}
private AmlCtrReportData mapToData(AmlCtrReport report) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
}
}
@@ -0,0 +1,73 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlFundTransferQueryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Entry point for AML monitoring of NEFT/RTGS/IMPS/INTERNAL fund transfers. This module's
* fund-transfer code (unlike savings/loans) doesn't use Fineract's BusinessEvent framework - it
* has its own simpler {@code FundTransferEvent} append-only status log - so rather than bolting on
* an unrelated event bus, this is called directly from the write paths (synchronous strategy
* execution and the NEFT batch processor) at the point a transfer reaches SUCCESS.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class AmlFundTransferEvaluationService {
private static final int STRUCTURING_WINDOW_DAYS = 30;
private final AmlRuleEvaluationService ruleEvaluationService;
private final AmlFundTransferQueryRepository fundTransferQueryRepository;
private final ClientRepositoryWrapper clientRepositoryWrapper;
public void evaluateIfSuccessful(FundTransfer transfer) {
if (transfer.getStatus() != FundTransferStatus.SUCCESS || transfer.getClientId() == null) {
return;
}
try {
final Client client = clientRepositoryWrapper.findOneWithNotFoundDetection(transfer.getClientId());
final LocalDateTime since = LocalDateTime.now().minusDays(STRUCTURING_WINDOW_DAYS);
final BigDecimal windowAggregate = fundTransferQueryRepository.sumForClientSince(client.getId(), FundTransferStatus.SUCCESS,
since);
final AmlTransactionContext context = new AmlTransactionContext(AmlTransactionType.TRANSFER, transfer.getId(), client,
transfer.getAmount(), "INR",
transfer.getSettledAt() != null ? transfer.getSettledAt() : transfer.getInitiatedAt(), false, windowAggregate);
ruleEvaluationService.evaluate(context);
} catch (final RuntimeException e) {
// AML alerting must never block the underlying fund transfer it is observing
log.error("AML rule evaluation failed for fund transfer {}", transfer.getId(), e);
}
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
/**
* Evaluates a single transaction against the active {@code AmlTransactionRule} catalog and
* persists one {@link AmlAlert} per rule hit. Runs identically regardless of the client's current
* KYC risk category - risk category is never used to suppress evaluation.
*/
public interface AmlRuleEvaluationService {
List<AmlAlert> evaluate(AmlTransactionContext context);
}
@@ -0,0 +1,104 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
private static final int HIGH_RISK = 3;
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final ClientKycRepository clientKycRepository;
@Override
public List<AmlAlert> evaluate(AmlTransactionContext context) {
final List<AmlTransactionRule> candidates = new ArrayList<>();
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
final List<AmlAlert> raised = new ArrayList<>();
for (final AmlTransactionRule rule : candidates) {
if (isHit(rule, context)) {
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
raised.add(alertRepository.save(alert));
}
}
return raised;
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
final BigDecimal threshold = resolveThreshold(rule, context);
if (threshold == null) {
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
// per rule and are not yet evaluated here - they exist in the catalog for future work.
return false;
}
switch (rule.getRuleType()) {
case THRESHOLD:
return context.getAmount().compareTo(threshold) >= 0;
case STRUCTURING:
case VELOCITY:
return context.getWindowAggregateAmount() != null
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
default:
return false;
}
}
/**
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
* clients.
*/
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
return rule.getThresholdAmount();
}
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
.orElse(null);
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
return rule.getThresholdAmountHighRisk();
}
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
return rule.getThresholdAmountMediumRisk();
}
return rule.getThresholdAmount();
}
}
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.AmlStrCaseData;
public interface AmlStrCaseReadPlatformService {
List<AmlStrCaseData> retrieveAll();
AmlStrCaseData retrieveOne(Long strCaseId);
List<AmlStrCaseData> weeklyDigest();
}
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlStrCaseData;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlStrCaseReadPlatformServiceImpl implements AmlStrCaseReadPlatformService {
private final AmlStrCaseRepository repository;
private final AmlStrWeeklyDigestService digestService;
@Override
public List<AmlStrCaseData> retrieveAll() {
return repository.findAll().stream().map(this::mapToData).collect(Collectors.toList());
}
@Override
public AmlStrCaseData retrieveOne(Long strCaseId) {
return repository.findById(strCaseId).map(this::mapToData)
.orElseThrow(() -> new EntityNotFoundException("AML STR case " + strCaseId + " does not exist"));
}
@Override
public List<AmlStrCaseData> weeklyDigest() {
return digestService.currentWeekDigest().stream().map(this::mapToData).collect(Collectors.toList());
}
private AmlStrCaseData mapToData(AmlStrCase strCase) {
final long cumulative = repository.countByClient_Id(strCase.getClient().getId());
return new AmlStrCaseData(strCase.getId(), strCase.getAlert().getId(), strCase.getClient().getId(),
strCase.getClient().getDisplayName(), strCase.getAssignedPo() == null ? null : strCase.getAssignedPo().getUsername(),
strCase.getInvestigationNotes(), strCase.getSourceOfFundsNotes(),
strCase.getConclusion() == null ? null : strCase.getConclusion().name(),
strCase.getConcludedBy() == null ? null : strCase.getConcludedBy().getUsername(), strCase.getConcludedOn(),
strCase.getFiuDueDate(), strCase.getStrFiledOn(), strCase.getStrReferenceNo(), strCase.getStatus().name(), cumulative);
}
}
@@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
/** Principal-Officer-only actions on an escalated {@link AmlStrCase}. */
public interface AmlStrCaseWritePlatformService {
AmlStrCase assignPrincipalOfficer(Long strCaseId, Long principalOfficerUserId);
AmlStrCase conclude(Long strCaseId, AmlStrConclusion conclusion, String investigationNotes, String sourceOfFundsNotes);
AmlStrCase fileStr(Long strCaseId, String referenceNo);
}
@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrConclusion;
import org.apache.fineract.useradministration.domain.AppUser;
import org.apache.fineract.useradministration.domain.AppUserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class AmlStrCaseWritePlatformServiceImpl implements AmlStrCaseWritePlatformService {
private final AmlStrCaseRepository strCaseRepository;
private final AppUserRepository appUserRepository;
private final PlatformSecurityContext context;
private final StrSlaCalculator strSlaCalculator;
@Override
public AmlStrCase assignPrincipalOfficer(Long strCaseId, Long principalOfficerUserId) {
final AmlStrCase strCase = loadCase(strCaseId);
final AppUser po = appUserRepository.findById(principalOfficerUserId)
.orElseThrow(() -> new EntityNotFoundException("User " + principalOfficerUserId + " does not exist"));
strCase.assignPrincipalOfficer(po);
return strCaseRepository.save(strCase);
}
@Override
public AmlStrCase conclude(Long strCaseId, AmlStrConclusion conclusion, String investigationNotes, String sourceOfFundsNotes) {
final AmlStrCase strCase = loadCase(strCaseId);
final AppUser principalOfficer = context.authenticatedUser();
final LocalDate concludedOn = LocalDate.now();
final LocalDate fiuDueDate = conclusion == AmlStrConclusion.SUSPICIOUS
? strSlaCalculator.calculateFiuDueDate(concludedOn, strCase.getClient().officeId())
: null;
strCase.conclude(principalOfficer, conclusion, investigationNotes, sourceOfFundsNotes, concludedOn, fiuDueDate);
return strCaseRepository.save(strCase);
}
@Override
public AmlStrCase fileStr(Long strCaseId, String referenceNo) {
final AmlStrCase strCase = loadCase(strCaseId);
strCase.fileStr(referenceNo, LocalDate.now());
return strCaseRepository.save(strCase);
}
private AmlStrCase loadCase(Long strCaseId) {
return strCaseRepository.findById(strCaseId)
.orElseThrow(() -> new EntityNotFoundException("AML STR case " + strCaseId + " does not exist"));
}
}
@@ -0,0 +1,51 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Internal MIS view of STR cases the Principal Officer concluded suspicious in the last 7 calendar
* days. This is separate from, and does not replace, each case's own individual 7-working-day FIU
* filing deadline ({@code AmlStrCase.fiuDueDate}) - it exists purely for compliance-team tracking.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AmlStrWeeklyDigestService {
private static final int DIGEST_WINDOW_DAYS = 7;
private final AmlStrCaseRepository strCaseRepository;
public List<AmlStrCase> currentWeekDigest() {
final LocalDate today = LocalDate.now();
return strCaseRepository.findConcludedSuspiciousBetween(today.minusDays(DIGEST_WINDOW_DAYS), today);
}
public List<AmlStrCase> digestFor(LocalDate from, LocalDate to) {
return strCaseRepository.findConcludedSuspiciousBetween(from, to);
}
}
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.ClientRiskCategoryHistoryData;
public interface ClientRiskCategoryHistoryReadPlatformService {
List<ClientRiskCategoryHistoryData> retrieveForClient(Long clientId);
}
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.ClientRiskCategoryHistoryData;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClientRiskCategoryHistoryReadPlatformServiceImpl implements ClientRiskCategoryHistoryReadPlatformService {
private final ClientRiskCategoryHistoryRepository repository;
@Override
public List<ClientRiskCategoryHistoryData> retrieveForClient(Long clientId) {
return repository.findByClient_IdOrderByChangedOnDesc(clientId).stream().map(this::mapToData).collect(Collectors.toList());
}
private ClientRiskCategoryHistoryData mapToData(ClientRiskCategoryHistory history) {
return new ClientRiskCategoryHistoryData(history.getId(), history.getClient().getId(), history.getPreviousCategory(),
history.getNewCategory(), history.getReason(), history.getTriggeredBy().name(),
history.getStrCase() == null ? null : history.getStrCase().getId(), history.getChangedOn(),
history.getChangedBy().getUsername());
}
}
@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
/**
* A client's existing static KYC risk category never suppresses AML monitoring - a Low/Medium
* risk client who breaches a rule still gets an alert and still goes through the STR pipeline.
* This service closes the feedback loop the other way: on a client's 3rd cumulative
* {@link AmlStrCase}, their risk category is automatically escalated one level, so repeated
* breaches are reflected back into their KYC profile rather than only living in the AML module.
*/
public interface ClientRiskEscalationService {
void onStrCaseEscalated(AmlStrCase strCase);
}
@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.KycRiskCategory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ClientRiskEscalationServiceImpl implements ClientRiskEscalationService {
private static final int ESCALATION_THRESHOLD = 3;
private final AmlStrCaseRepository strCaseRepository;
private final ClientKycRepositoryWrapper clientKycRepositoryWrapper;
private final ClientRiskCategoryHistoryRepository historyRepository;
private final PlatformSecurityContext context;
@Override
public void onStrCaseEscalated(AmlStrCase strCase) {
final Client client = strCase.getClient();
final long cumulativeCases = strCaseRepository.countByClient_Id(client.getId());
if (cumulativeCases != ESCALATION_THRESHOLD) {
// only fires on the exact 3rd case, not every case from the 3rd onward
return;
}
final ClientKyc kyc = clientKycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
final Integer previousCategory = kyc.getRiskCategoryValue();
final Integer nextCategory = nextCategory(previousCategory);
if (nextCategory.equals(previousCategory)) {
log.info("Client {} reached {} cumulative AML STR cases but is already at the highest risk category", client.getId(),
cumulativeCases);
return;
}
kyc.escalateRiskCategory(nextCategory);
historyRepository.save(ClientRiskCategoryHistory.autoEscalation(client, previousCategory, nextCategory, strCase,
context.authenticatedUser(), LocalDateTime.now()));
}
private Integer nextCategory(Integer currentCategory) {
if (currentCategory == null || currentCategory.equals(KycRiskCategory.LOW.getValue())) {
return KycRiskCategory.MEDIUM.getValue();
}
if (currentCategory.equals(KycRiskCategory.MEDIUM.getValue())) {
return KycRiskCategory.HIGH.getValue();
}
return KycRiskCategory.HIGH.getValue();
}
}
@@ -0,0 +1,39 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
/**
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
*
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
*/
public interface JournalEntryReferralService {
/** disposition: WHITELIST | ESCALATE. */
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
List<ManualReferralData> retrieveAll();
}
@@ -0,0 +1,224 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
private final JdbcTemplate jdbcTemplate;
private final AmlAlertRepository alertRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlTransactionRuleRepository ruleRepository;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
/**
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
* posted against.
*
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
* a savings-type entity may be either a savings or a current account, which live in separate tables.
*
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
*/
private static final String RESOLVE_SQL = """
SELECT je.amount AS amount,
je.currency_code AS currencyCode,
je.savings_transaction_id AS savingsTxnId,
je.loan_transaction_id AS loanTxnId,
je.entity_type_enum AS entityType,
COALESCE(
esa.client_id,
eca.client_id,
eloan.client_id,
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
sa.client_id,
l.client_id,
ct.client_id
) AS clientId
FROM acc_gl_journal_entry je
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
LEFT JOIN m_loan l ON l.id = lt.loan_id
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
WHERE je.id = ?
""";
private static final String LIST_SQL = """
SELECT a.id AS alertId,
a.transaction_id AS journalEntryId,
a.client_id AS clientId,
c.display_name AS clientDisplayName,
a.amount AS amount,
a.currency_code AS currencyCode,
a.raised_on AS raisedOn,
a.status AS status,
a.disposition AS disposition,
u.username AS reviewedByUsername,
a.review_notes AS reviewNotes,
s.id AS strCaseId
FROM m_aml_alert a
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
LEFT JOIN m_client c ON c.id = a.client_id
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
ORDER BY a.raised_on DESC
""";
@Override
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
if (journalEntryId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
"A journal entry must be given to refer.");
}
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
"Disposition must be WHITELIST or ESCALATE.", disposition);
}
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
// Marking the same entry twice would put two cases in front of the Principal Officer for
// one transaction, so the first mark stands until it is explicitly reworked.
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
"This ledger entry has already been marked.", journalEntryId);
}
final Map<String, Object> row = loadEntry(journalEntryId);
final Number clientIdValue = (Number) row.get("clientId");
if (clientIdValue == null) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
}
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
final BigDecimal amount = (BigDecimal) row.get("amount");
final String currencyCode = (String) row.get("currencyCode");
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
// savings or loan transaction id.
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
LocalDateTime.now());
alertRepository.save(alert);
Long strCaseId = null;
if (escalate) {
alert.escalate(context.authenticatedUser(), notes);
alertRepository.save(alert);
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
strCaseId = strCase.getId();
} else {
alert.whitelist(context.authenticatedUser(), notes);
alertRepository.save(alert);
}
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
alert.getDisposition() == null ? null : alert.getDisposition().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
strCaseId);
}
@Override
@Transactional(readOnly = true)
public List<ManualReferralData> retrieveAll() {
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
MANUAL_RULE_CODE);
}
private Map<String, Object> loadEntry(final Long journalEntryId) {
try {
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
} catch (final EmptyResultDataAccessException e) {
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
}
}
/**
* TRANSFER covers everything that is neither a savings nor a loan posting.
*
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
* id; falling straight through to the transaction columns would mislabel those as transfers.
*/
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
final Number entityType = (Number) row.get("entityType");
if (entityType != null) {
if (entityType.intValue() == 2) {
return AmlTransactionType.SAVINGS;
}
if (entityType.intValue() == 1) {
return AmlTransactionType.LOAN;
}
}
if (row.get("savingsTxnId") != null) {
return AmlTransactionType.SAVINGS;
}
if (row.get("loanTxnId") != null) {
return AmlTransactionType.LOAN;
}
return AmlTransactionType.TRANSFER;
}
}
@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.organisation.holiday.domain.HolidayRepositoryWrapper;
import org.apache.fineract.organisation.workingdays.domain.WorkingDays;
import org.apache.fineract.organisation.workingdays.domain.WorkingDaysRepositoryWrapper;
import org.apache.fineract.organisation.workingdays.service.WorkingDaysUtil;
import org.springframework.stereotype.Service;
/**
* Computes the RBI/PMLA-mandated 7-working-day FIU-IND filing deadline from a Principal Officer's
* STR conclusion date. "Working day" excludes both the tenant's configured weekly off-days
* ({@link WorkingDays}) and declared office holidays - reuses the same calendar machinery loan
* repayment scheduling already depends on, rather than a bespoke calculator.
*/
@Service
@RequiredArgsConstructor
public class StrSlaCalculator {
private static final int STR_FILING_WORKING_DAYS = 7;
private final WorkingDaysRepositoryWrapper workingDaysRepositoryWrapper;
private final HolidayRepositoryWrapper holidayRepositoryWrapper;
public LocalDate calculateFiuDueDate(LocalDate concludedOn, Long officeId) {
final WorkingDays workingDays = workingDaysRepositoryWrapper.findOne();
LocalDate date = concludedOn;
int workingDaysElapsed = 0;
while (workingDaysElapsed < STR_FILING_WORKING_DAYS) {
date = date.plusDays(1);
if (isWorkingDay(workingDays, date, officeId)) {
workingDaysElapsed++;
}
}
return date;
}
private boolean isWorkingDay(WorkingDays workingDays, LocalDate date, Long officeId) {
return WorkingDaysUtil.isWorkingDay(workingDays, date) && !holidayRepositoryWrapper.isHoliday(officeId, date);
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.starter;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.portfolio.aml.event.AmlDomainService;
import org.apache.fineract.portfolio.aml.event.AmlDomainServiceImpl;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.service.AmlRuleEvaluationService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AmlConfiguration {
@Bean
@ConditionalOnMissingBean(AmlDomainService.class)
public AmlDomainService amlDomainService(BusinessEventNotifierService businessEventNotifierService,
AmlRuleEvaluationService amlRuleEvaluationService, AmlSavingsTransactionQueryRepository savingsQueryRepository,
AmlLoanTransactionQueryRepository loanQueryRepository) {
return new AmlDomainServiceImpl(businessEventNotifierService, amlRuleEvaluationService, savingsQueryRepository,
loanQueryRepository);
}
}
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper; import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData; import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService; import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService; import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/clients") @Path("/v1/clients")
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ClientEditApiResource { public class ClientEditApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
* whether the token matched, to avoid leaking token validity via response differences. */
private static final byte[] TRANSPARENT_GIF = {
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
};
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final ClientEditWritePlatformService clientEditWritePlatformService; private final ClientEditWritePlatformService clientEditWritePlatformService;
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService; private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final ApiRequestParameterHelper apiRequestParameterHelper; private final ApiRequestParameterHelper apiRequestParameterHelper;
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer; private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a pending Client details edit", @Operation(summary = "Approve a pending Client details edit",
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.") description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) { + "The maker of the edit cannot also approve it — no exceptions.")
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@Context final HttpServletRequest httpRequest) {
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) { if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
throw new NoAuthorizationException("User has no authority to approve client edits"); throw new NoAuthorizationException("User has no authority to approve client edits");
} }
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId); // Derive base URL from the incoming request so the tracking pixel URL is absolute and
// reachable by the client's email client without needing a configured server.url property.
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
: ":" + httpRequest.getServerPort());
// Tenant ID from the Fineract-specific header (same header the frontend sends).
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
tenantId != null ? tenantId : "default");
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
return this.toApiJsonSerializer.serialize(result); return this.toApiJsonSerializer.serialize(result);
} }
// ── Email preview (authenticated) ─────────────────────────────────────────
@GET
@Path("{clientId}/edit/{pendingEditId}/email-preview")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
if (preview == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
}
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
@GET
@Path("edit/pixel/{token}")
@Produces("image/gif")
@Operation(summary = "Email open tracking pixel",
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
+ "regardless of whether the token matched, to avoid leaking token validity.")
public Response trackEmailOpen(@PathParam("token") final String token) {
if (token != null && !token.isBlank()) {
try {
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
if (pendingEditOpt.isPresent()) {
final var pendingEdit = pendingEditOpt.get();
pendingEdit.markEmailOpened();
pendingEditRepository.saveAndFlush(pendingEdit);
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
}
} catch (final Exception e) {
// Never fail a pixel request — email clients don't handle errors well.
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
}
}
return Response.ok(TRANSPARENT_GIF, "image/gif")
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
.header("Pragma", "no-cache")
.build();
}
} }
@@ -65,7 +65,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName, alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
nameVerifiedParamName, screeningLogIdParamName, nameVerifiedParamName, screeningLogIdParamName,
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName, submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName)); mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
"oldMobileVerificationExceptionDocumentId"));
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>( protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName, Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull() baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
.longGreaterThanZero(); .longGreaterThanZero();
} }
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
.longGreaterThanZero();
}
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) { if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element); final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull() baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
private final String checkerName; private final String checkerName;
private final OffsetDateTime checkedDate; private final OffsetDateTime checkedDate;
private final String rejectReason; private final String rejectReason;
// ── Email tracking fields ─────────────────────────────────────────────────
/** Whether a confirmation email was successfully sent to the client after approval. */
private final Boolean emailSent;
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
private final OffsetDateTime emailSentAt;
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
private final OffsetDateTime emailOpenedAt;
// emailSubject and emailBody are deliberately NOT included in the list DTO —
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
} }
@@ -41,6 +41,25 @@ public final class KycRiskRuleData implements Serializable {
private final Integer riskCategory; private final Integer riskCategory;
private final String reason; private final String reason;
private final Integer orderPosition; private final Integer orderPosition;
private final String amlCheckStatus;
private final String bannedEntityMatch;
private final String willfulDefaulter;
private final String ecgcSalFlag;
private final String dormantFlag;
/** Populated only for rules that also have a live-transaction-monitoring counterpart in
* {@code m_aml_transaction_rule} (linked via source_client_risk_rule_id) - distinct from the
* client-onboarding income/turnover fields above, which drive KYC-time risk categorization. */
private final BigDecimal txnThresholdAmount;
private final String txnRuleType;
private final Integer txnWindowDays;
/** Risk-category-tiered overrides of {@link #txnThresholdAmount}, populated only when the
* linked {@code m_aml_transaction_rule} row has tiering configured (e.g. RTGS monitoring). */
private final BigDecimal txnThresholdAmountMediumRisk;
private final BigDecimal txnThresholdAmountHighRisk;
/** Declarative band the rule covers, for display on the AML Rules Master screen. Never
* used in matching; a null maximum means the rule is genuinely open-ended. */
private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount;
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId, public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
final Long occupationCategoryCvId, final String occupationCategoryName, final Long occupationCategoryCvId, final String occupationCategoryName,
@@ -48,7 +67,12 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome, final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
final Integer riskCategory, final String reason, final Integer orderPosition) { final Integer riskCategory, final String reason, final Integer orderPosition,
final String amlCheckStatus, final String bannedEntityMatch,
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id; this.id = id;
this.ruleName = ruleName; this.ruleName = ruleName;
this.legalFormId = legalFormId; this.legalFormId = legalFormId;
@@ -65,6 +89,18 @@ public final class KycRiskRuleData implements Serializable {
this.riskCategory = riskCategory; this.riskCategory = riskCategory;
this.reason = reason; this.reason = reason;
this.orderPosition = orderPosition; this.orderPosition = orderPosition;
this.amlCheckStatus = amlCheckStatus;
this.bannedEntityMatch = bannedEntityMatch;
this.willfulDefaulter = willfulDefaulter;
this.ecgcSalFlag = ecgcSalFlag;
this.dormantFlag = dormantFlag;
this.txnThresholdAmount = txnThresholdAmount;
this.txnRuleType = txnRuleType;
this.txnWindowDays = txnWindowDays;
this.txnThresholdAmountMediumRisk = txnThresholdAmountMediumRisk;
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
this.txnMinLimitAmount = txnMinLimitAmount;
this.txnMaxLimitAmount = txnMaxLimitAmount;
} }
public Long getId() { public Long getId() {
@@ -130,4 +166,52 @@ public final class KycRiskRuleData implements Serializable {
public Integer getOrderPosition() { public Integer getOrderPosition() {
return orderPosition; return orderPosition;
} }
public String getAmlCheckStatus() {
return amlCheckStatus;
}
public String getBannedEntityMatch() {
return bannedEntityMatch;
}
public String getWillfulDefaulter() {
return willfulDefaulter;
}
public String getEcgcSalFlag() {
return ecgcSalFlag;
}
public String getDormantFlag() {
return dormantFlag;
}
public BigDecimal getTxnThresholdAmount() {
return txnThresholdAmount;
}
public String getTxnRuleType() {
return txnRuleType;
}
public Integer getTxnWindowDays() {
return txnWindowDays;
}
public BigDecimal getTxnThresholdAmountMediumRisk() {
return txnThresholdAmountMediumRisk;
}
public BigDecimal getTxnThresholdAmountHighRisk() {
return txnThresholdAmountHighRisk;
}
public BigDecimal getTxnMinLimitAmount() {
return txnMinLimitAmount;
}
public BigDecimal getTxnMaxLimitAmount() {
return txnMaxLimitAmount;
}
} }
@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@Entity
@Table(name = "m_family_member_children")
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
@ManyToOne
@JoinColumn(name = "family_member_id", nullable = false)
private ClientFamilyMembers familyMember;
@Column(name = "full_name", nullable = false)
private String fullName;
@ManyToOne
@JoinColumn(name = "gender_cv_id", nullable = false)
private CodeValue gender;
@Column(name = "date_of_birth", nullable = true)
private LocalDate dateOfBirth;
protected ClientFamilyMemberChildren() {
// Default constructor
}
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
final LocalDate dateOfBirth) {
this.familyMember = familyMember;
this.fullName = fullName;
this.gender = gender;
this.dateOfBirth = dateOfBirth;
}
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
final LocalDate dateOfBirth) {
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
}
public ClientFamilyMembers getFamilyMember() {
return this.familyMember;
}
public void setFamilyMember(ClientFamilyMembers familyMember) {
this.familyMember = familyMember;
}
public String getFullName() {
return this.fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public CodeValue getGender() {
return this.gender;
}
public void setGender(CodeValue gender) {
this.gender = gender;
}
public LocalDate getDateOfBirth() {
return this.dateOfBirth;
}
public void setDateOfBirth(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
}
}
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
@Column(name = "modified_on") @Column(name = "modified_on")
private LocalDateTime modifiedOn; private LocalDateTime modifiedOn;
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName, private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship, final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession, final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public Long getModifiedBy() { return modifiedBy; } public Long getModifiedBy() { return modifiedBy; }
public LocalDateTime getModifiedOn() { return modifiedOn; } public LocalDateTime getModifiedOn() { return modifiedOn; }
public java.util.List<ClientFamilyMemberChildren> getChildren() {
return this.children;
}
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
if (this.children == null) {
this.children = new java.util.ArrayList<>();
} else {
this.children.clear();
}
if (children != null) {
this.children.addAll(children);
}
}
} }
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
@Column(name = "pending_lock_key", length = 60, unique = true) @Column(name = "pending_lock_key", length = 60, unique = true)
private String pendingLockKey; private String pendingLockKey;
// ── Email tracking ────────────────────────────────────────────────────────
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
@Column(name = "email_tracking_token", length = 36, unique = true)
private String emailTrackingToken;
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
@Column(name = "email_sent", nullable = false)
private Boolean emailSent = Boolean.FALSE;
/** Timestamp of the successful send. Null when emailSent is false. */
@Column(name = "email_sent_at")
private OffsetDateTime emailSentAt;
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
* the 1×1 img tag. Null until opened. */
@Column(name = "email_opened_at")
private OffsetDateTime emailOpenedAt;
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
* without regenerating the template. */
@Column(name = "email_subject", length = 300)
private String emailSubject;
/** Exact HTML body that was sent — stored alongside email_subject. */
@Column(name = "email_body", columnDefinition = "TEXT")
private String emailBody;
/** Exact sender address configured at the time the email was dispatched. */
@Column(name = "email_from", length = 255)
private String emailFrom;
// ── Factory / lifecycle ───────────────────────────────────────────────────
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson, public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
final AppUser maker) { final AppUser maker) {
final ClientPendingEdit edit = new ClientPendingEdit(); final ClientPendingEdit edit = new ClientPendingEdit();
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
edit.makerId = maker.getId(); edit.makerId = maker.getId();
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant(); edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
edit.pendingLockKey = lockKeyFor(client.getId()); edit.pendingLockKey = lockKeyFor(client.getId());
edit.emailSent = Boolean.FALSE;
return edit; return edit;
} }
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
this.rejectReason = reason; this.rejectReason = reason;
this.pendingLockKey = null; this.pendingLockKey = null;
} }
/** Called after a successful email send. Records the token (for pixel tracking), the exact
* subject and body, and marks the row as sent. */
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
this.emailTrackingToken = token;
this.emailSubject = subject;
this.emailBody = body;
this.emailFrom = fromAddress;
this.emailSent = Boolean.TRUE;
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
}
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
public void markEmailOpened() {
if (this.emailOpenedAt == null) {
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
}
}
} }
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status); Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status); List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
} }
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name()); return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
} }
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
return this.repository.findByEmailTrackingToken(token);
}
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) { public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
return this.repository.saveAndFlush(edit); return this.repository.saveAndFlush(edit);
} }
@@ -76,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER", "SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY"); "MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
public static final String CHILDREN = "children";
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>( private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT, Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS, RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS, NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY)); AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
public static final String FAMILY_MEMBERS1 = "FamilyMembers"; public static final String FAMILY_MEMBERS1 = "FamilyMembers";
public static final String RELATION_SHIP_ID = "relationShipId"; public static final String RELATION_SHIP_ID = "relationShipId";
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
@@ -0,0 +1,49 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
/**
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
* their pending profile-edit. Also records the tracking token, subject and body back onto the
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
* dialog can reproduce it later without regenerating the template.
*
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
* that a mail-server outage never rolls back an already-committed approval.
*/
public interface ClientEditEmailService {
/**
* Attempts to send a confirmation email to the client. On success, calls
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
* {@code pendingEdit} — the approval itself is never rolled back.
*
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
* yet persisted by the caller at this point)
* @param client the client whose details were edited; used for display name and email
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
* {@code https://cbs.example.com}) used to build the tracking-pixel href
* @param tenantId the current tenant identifier, used to build the pixel URL
*/
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
}
@@ -0,0 +1,301 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
* Checker. Records the tracking token, exact subject and exact body back onto the
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
* the entity after this method returns).
*
* <p>Template selection:
* <ul>
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
* {@code original_json}). Lists old→new values in a table.</li>
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
* </ul>
*
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
* approval transaction to roll back. The caller catches any exception from this service and logs it.
*/
@Service
@RequiredArgsConstructor
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
private static final String BANK_NAME = "CBSNexora";
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
/** Contact fields that trigger Template A when they actually change. */
private static final String FIELD_MOBILE = "mobileNo";
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
private static final String FIELD_EMAIL = "emailAddress";
/** Human-readable labels for every field that can appear in the email body — mirrors the
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
private static final Map<String, String> FIELD_LABELS;
static {
FIELD_LABELS = new LinkedHashMap<>();
FIELD_LABELS.put("firstname", "First Name");
FIELD_LABELS.put("middlename", "Middle Name");
FIELD_LABELS.put("lastname", "Last Name");
FIELD_LABELS.put("fullname", "Full Name");
FIELD_LABELS.put("mobileNo", "Mobile Number");
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
FIELD_LABELS.put("emailAddress", "Email Address");
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
FIELD_LABELS.put("genderId", "Gender");
FIELD_LABELS.put("maritalStatusId", "Marital Status");
FIELD_LABELS.put("educationLevelId", "Education Level");
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
FIELD_LABELS.put("occupationId", "Occupation");
FIELD_LABELS.put("religionId", "Religion");
FIELD_LABELS.put("socialCategoryId", "Social Category");
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
FIELD_LABELS.put("nationalityId", "Nationality");
FIELD_LABELS.put("officeId", "Branch / Office");
FIELD_LABELS.put("staffId", "Relationship Manager");
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
FIELD_LABELS.put("legalFormId", "Legal Form");
FIELD_LABELS.put("salutationId", "Salutation");
}
private final EmailSenderService emailSenderService;
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
@Override
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
final String baseUrl, final String tenantId) {
final String toEmail = client.getEmailAddress();
if (toEmail == null || toEmail.isBlank()) {
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
client.getId(), pendingEdit.getId());
return;
}
try {
final String token = UUID.randomUUID().toString();
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
final boolean isContactChange = detectContactChange(payload, original);
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
final String subject;
final String body;
if (isContactChange) {
subject = "Your contact details have been updated — " + BANK_NAME;
body = buildContactChangeBody(client.getDisplayName(), payload, original,
checkedDateFormatted, pixelUrl);
} else {
subject = "Your profile details have been updated — " + BANK_NAME;
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
checkedDateFormatted, pixelUrl);
}
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
if (sent) {
String fromEmail = "noreply@cbsnexora.in";
try {
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
fromEmail = smtp.getFromEmail();
}
} catch (Exception e) {
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
}
pendingEdit.markEmailSent(token, subject, body, fromEmail);
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
} else {
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
}
} catch (final Exception e) {
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
pendingEdit.getId(), e.getMessage(), e);
}
}
// ── Template detection ────────────────────────────────────────────────────
/**
* Returns true if any of the three contact fields actually changed between
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
* falls back to false (Template B).
*/
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
if (original == null) {
return false;
}
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
final String payloadVal = stringOf(payload, field);
final String originalVal = stringOf(original, field);
// Only counts as a change when both sides have a value and they differ, OR when
// payload has a non-blank value and original didn't have the key at all.
if (payloadVal != null && !payloadVal.equals(originalVal)) {
return true;
}
}
return false;
}
// ── Template A ────────────────────────────────────────────────────────────
private String buildContactChangeBody(final String clientName, final JsonObject payload,
final JsonObject original, final String date, final String pixelUrl) {
final StringBuilder rows = new StringBuilder();
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
final String newVal = stringOf(payload, field);
final String oldVal = original != null ? stringOf(original, field) : null;
if (newVal == null) {
continue;
}
if (oldVal != null && oldVal.equals(newVal)) {
continue;
}
final String label = FIELD_LABELS.getOrDefault(field, field);
rows.append("<tr>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
.append(escHtml(newVal)).append("</td>")
.append("</tr>");
}
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
+ "<p>Dear " + escHtml(clientName) + ",</p>"
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
+ "<thead><tr>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
+ "</body></html>";
}
// ── Template B ────────────────────────────────────────────────────────────
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
final JsonObject original, final String date, final String pixelUrl) {
final StringBuilder items = new StringBuilder();
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
final String key = entry.getKey();
// Skip internal/non-display keys
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
continue;
}
final String payloadVal = stringOf(payload, key);
final String originalVal = original != null ? stringOf(original, key) : null;
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
continue;
}
final String label = FIELD_LABELS.getOrDefault(key, key);
items.append("<li>").append(escHtml(label)).append("</li>");
}
final String listHtml = items.length() > 0
? "<ul style='padding-left:20px;'>" + items + "</ul>"
: "<p><em>No specific fields identified.</em></p>";
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
+ "<p>Dear " + escHtml(clientName) + ",</p>"
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
+ "<p>The following fields were changed:</p>"
+ listHtml
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
+ "</body></html>";
}
// ── Helpers ───────────────────────────────────────────────────────────────
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
// without requiring a tenant header (it accepts the token and commits in the default
// datasource context). However we embed it as a query param so future multi-tenant
// support is easy to add without changing the stored URL.
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
}
private JsonObject parseJson(final String json) {
if (json == null || json.isBlank()) {
return null;
}
try {
final JsonElement el = JsonParser.parseString(json);
return el.isJsonObject() ? el.getAsJsonObject() : null;
} catch (final Exception e) {
return null;
}
}
private String stringOf(final JsonObject obj, final String key) {
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
return null;
}
final JsonElement el = obj.get(key);
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
}
private String escHtml(final String s) {
if (s == null) {
return "";
}
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;");
}
}
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command); CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
CommandProcessingResult approveEdit(Long clientId); /**
* Approves the pending edit and, on success, attempts to send a confirmation email to the
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
* can embed an absolute tracking-pixel URL in the email body.
*
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
* @param tenantId current tenant identifier
*/
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
CommandProcessingResult rejectEdit(Long clientId, String rejectReason); CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
} }
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException; import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException; import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService { public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */ /** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues"; private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
private final ClientPendingEditRepositoryWrapper pendingEditRepository; private final ClientPendingEditRepositoryWrapper pendingEditRepository;
private final ClientWritePlatformService clientWritePlatformService; private final ClientWritePlatformService clientWritePlatformService;
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final ClientEditEmailService clientEditEmailService;
@Transactional @Transactional
@Override @Override
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
@Transactional @Transactional
@Override @Override
public CommandProcessingResult approveEdit(final Long clientId) { public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
final AppUser checker = this.context.authenticatedUser(); final AppUser checker = this.context.authenticatedUser();
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId); final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
// Replay the staged JSON through the SAME write-service method a direct PUT would have // Replay the staged JSON through the SAME write-service method a direct PUT would have
// used, so every bit of Client's existing validation/change-detection logic runs exactly // used, so every bit of Client's existing validation/change-detection logic runs exactly
// as it would for an immediate edit — just deferred until a different user approves it. // as it would for an immediate edit — just deferred until a different user approves it.
final Client client = pendingEdit.getClient();
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson()); final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper); final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand); final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
pendingEdit.approve(checker); pendingEdit.approve(checker);
this.pendingEditRepository.saveAndFlush(pendingEdit); this.pendingEditRepository.saveAndFlush(pendingEdit);
// Send confirmation email — silently swallowed on any failure so the approval is never
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
// email tracking columns. If the send fails or the client has no email, this save is
// a no-op (email_sent remains false).
try {
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
} else {
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
}
} catch (final Exception e) {
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
pendingEdit.getId(), e);
}
this.pendingEditRepository.saveAndFlush(pendingEdit);
return result; return result;
} }
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper(); final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true"; final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
for (ClientFamilyMembersData fm : familyMembers) {
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
}
return familyMembers;
} }
@Override @Override
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper(); final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false "; final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
if (fm != null) {
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
}
return fm;
}
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
"c.gender_cv_id as genderId, cv.code_value as gender " +
"from m_family_member_children c " +
"left join m_code_value cv on c.gender_cv_id = cv.id " +
"where c.family_member_id = ?";
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
.id(rs.getLong("id"))
.fullName(rs.getString("fullName"))
.genderId(JdbcSupport.getLong(rs, "genderId"))
.gender(rs.getString("gender"))
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
.build();
}, familyMemberId);
} }
@Override @Override
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException; import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException; import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer; import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
} }
} }
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
if (childrenArray != null) {
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
for (JsonElement childElement : childrenArray) {
if (childElement.isJsonNull()) continue;
JsonObject childObj = childElement.getAsJsonObject();
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
LocalDate dob = null;
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
try {
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
} catch (DateTimeParseException e) {
LOG.warn("Could not parse dateOfBirth for child", e);
}
}
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
}
}
}
familyMember.setChildren(children);
}
@Override @Override
public void validateFamilyRequirementsForActivation(final Client client) { public void validateFamilyRequirementsForActivation(final Client client) {
if (client == null || client.getId() == null || client.getLegalForm() == null) { if (client == null || client.getId() == null || client.getLegalForm() == null) {
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId)); clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
} }
if (command.parameterExists("children")) {
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
}
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers); this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
// mothers (or one mother + an existing mother in the DB) fails fast on the offender. // mothers (or one mother + an existing mother in the DB) fails fast on the offender.
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null); validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
if (member.has("children") && !member.get("children").isJsonNull()) {
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
}
this.clientFamilyRepository.saveAndFlush(familyMember); this.clientFamilyRepository.saveAndFlush(familyMember);
} }
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null, validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
clientFamilyMember.getRelationship(), familyMemberId); clientFamilyMember.getRelationship(), familyMemberId);
if (command.parameterExists("children")) {
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
}
this.clientFamilyRepository.saveAndFlush(clientFamilyMember); this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build(); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import java.util.Map;
import org.apache.fineract.infrastructure.core.service.Page; import org.apache.fineract.infrastructure.core.service.Page;
import org.apache.fineract.portfolio.client.data.ClientPendingEditData; import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
*/ */
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset, Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
Integer limit); Integer limit);
/**
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
* edit — only populated once the approval email has been sent. Both {@code clientId} and
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
* when the record does not exist.
*/
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
} }
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName, pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
pe.submitted_date AS submittedDate, pe.submitted_date AS submittedDate,
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName, pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
pe.email_opened_at AS emailOpenedAt
FROM m_client_pending_edit pe FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id JOIN m_client c ON c.id = pe.client_id
JOIN m_appuser maker ON maker.id = pe.maker_id JOIN m_appuser maker ON maker.id = pe.maker_id
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> { private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class); final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class); final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
final boolean emailSent = rs.getBoolean("emailSent");
return ClientPendingEditData.builder() // return ClientPendingEditData.builder() //
.id(rs.getLong("id")) // .id(rs.getLong("id")) //
.clientId(rs.getLong("clientId")) // .clientId(rs.getLong("clientId")) //
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
.checkerName(rs.getString("checkerName")) // .checkerName(rs.getString("checkerName")) //
.checkedDate(checkedDate) // .checkedDate(checkedDate) //
.rejectReason(rs.getString("rejectReason")) // .rejectReason(rs.getString("rejectReason")) //
.emailSent(emailSent) //
.emailSentAt(emailSentAt) //
.emailOpenedAt(emailOpenedAt) //
.build(); .build();
}; };
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue()); return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
} }
@Override
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
final String sql = """
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
c.email_address AS clientEmail
FROM m_client_pending_edit pe
JOIN m_client c ON c.id = pe.client_id
WHERE pe.id = ? AND pe.client_id = ?
""";
try {
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("emailSubject", rs.getString("emailSubject"));
result.put("emailBody", rs.getString("emailBody"));
result.put("emailSent", rs.getBoolean("emailSent"));
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
result.put("emailFrom", rs.getString("emailFrom"));
result.put("emailTo", rs.getString("clientEmail"));
return result;
}, pendingEditId, clientId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
} }
@@ -96,6 +96,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService; import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaSystemException; import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -136,6 +137,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final CifNumberGenerator cifNumberGenerator; private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService; private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService; private final ClientChangeNotificationService clientChangeNotificationService;
private final JdbcTemplate jdbcTemplate;
@Transactional @Transactional
@Override @Override
@@ -788,13 +790,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
* client onboarded without a mobile number is having one set for the first time) — there is no * client onboarded without a mobile number is having one set for the first time) — there is no
* existing verified channel to prove possession of in that case. * existing verified channel to prove possession of in that case.
*/ */
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue, private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
final String newValue) { final String newValue) {
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) { if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName); final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName); final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
if (StringUtils.isNotBlank(oldValue)) { if (StringUtils.isNotBlank(oldValue)) {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName); if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
final Integer docCount = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
Integer.class, oldMobileExceptionDocId, clientId);
if (docCount == null || docCount == 0) {
throw new PlatformDataIntegrityException(
"error.msg.client.mobile.old.verification.exception.invalid.document",
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
}
} else {
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
}
} }
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName); this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) { } else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
@@ -862,7 +878,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) { if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName); final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
final String oldValue = clientForUpdate.getMobileNo(); final String oldValue = clientForUpdate.getMobileNo();
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue); verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
changes.put(ClientApiConstants.mobileNoParamName, newValue); changes.put(ClientApiConstants.mobileNoParamName, newValue);
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null)); clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
oldMobileNoForNotification = oldValue; oldMobileNoForNotification = oldValue;
@@ -879,7 +895,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) { if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName); final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
final String oldValue = clientForUpdate.getEmailAddress(); final String oldValue = clientForUpdate.getEmailAddress();
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue); verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
changes.put(ClientApiConstants.emailAddressParamName, newValue); changes.put(ClientApiConstants.emailAddressParamName, newValue);
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null)); clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
oldEmailAddressForNotification = oldValue; oldEmailAddressForNotification = oldValue;
@@ -19,8 +19,12 @@
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.data.KycRiskRuleData; import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule; import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository; import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
@@ -33,14 +37,18 @@ import org.springframework.transaction.annotation.Transactional;
public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService { public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlatformService {
private final ClientRiskRuleRepository repository; private final ClientRiskRuleRepository repository;
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
@Override @Override
public List<KycRiskRuleData> retrieveAllRules() { public List<KycRiskRuleData> retrieveAllRules() {
final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition(); final List<ClientRiskRule> rules = this.repository.findAllOrderedByOrderPosition();
return rules.stream().map(this::mapToData).collect(Collectors.toList()); final Map<Long, AmlTransactionRule> txnRulesBySourceId = this.amlTransactionRuleRepository.findAll().stream()
.filter(txn -> txn.getSourceClientRiskRuleId() != null)
.collect(Collectors.toMap(AmlTransactionRule::getSourceClientRiskRuleId, Function.identity(), (a, b) -> a));
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
} }
private KycRiskRuleData mapToData(final ClientRiskRule rule) { private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null; final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null; final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null; final Long occId = rule.getOccupation() != null ? rule.getOccupation().getId() : null;
@@ -62,7 +70,19 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
rule.getMaxAnnualTurnover(), rule.getMaxAnnualTurnover(),
rule.getRiskCategory(), rule.getRiskCategory(),
rule.getReason(), rule.getReason(),
rule.getOrderPosition() rule.getOrderPosition(),
rule.getAmlCheckStatus(),
rule.getBannedEntityMatch(),
rule.getWillfulDefaulter(),
rule.getEcgcSalFlag(),
rule.getDormantFlag(),
txnRule != null ? txnRule.getThresholdAmount() : null,
txnRule != null ? txnRule.getRuleType().name() : null,
txnRule != null ? txnRule.getWindowDays() : null,
txnRule != null ? txnRule.getThresholdAmountMediumRisk() : null,
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
txnRule != null ? txnRule.getMinLimitAmount() : null,
txnRule != null ? txnRule.getMaxLimitAmount() : null
); );
} }
} }
@@ -30,6 +30,7 @@ import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData; import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
import org.apache.fineract.infrastructure.imps.data.PartyData; import org.apache.fineract.infrastructure.imps.data.PartyData;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.service.AmlFundTransferEvaluationService;
import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; import org.apache.fineract.portfolio.client.service.ClientReadPlatformService;
import org.apache.fineract.portfolio.fundtransfer.data.ResolvedLimits; import org.apache.fineract.portfolio.fundtransfer.data.ResolvedLimits;
import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData; import org.apache.fineract.portfolio.fundtransfer.data.TransferResponseData;
@@ -76,6 +77,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
private final SanctionsScreeningService sanctionsScreeningService; private final SanctionsScreeningService sanctionsScreeningService;
private final TransferChannelWindowValidator windowValidator; private final TransferChannelWindowValidator windowValidator;
private final SuspenseGlService suspenseGl; private final SuspenseGlService suspenseGl;
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
// ─── /verify ────────────────────────────────────────────────────────── // ─── /verify ──────────────────────────────────────────────────────────
@@ -168,6 +170,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
eventRepository.save(FundTransferEvent.of(saved.getId(), null, saved.getStatus(), eventRepository.save(FundTransferEvent.of(saved.getId(), null, saved.getStatus(),
needsApproval ? "INITIATED_PENDING_APPROVAL" : "INITIATED_AUTO_EXECUTE", null, cashier.getId())); needsApproval ? "INITIATED_PENDING_APPROVAL" : "INITIATED_AUTO_EXECUTE", null, cashier.getId()));
amlFundTransferEvaluationService.evaluateIfSuccessful(saved);
return FundTransferReadPlatformServiceImpl.toData(saved); return FundTransferReadPlatformServiceImpl.toData(saved);
} }
@@ -190,6 +193,7 @@ public class FundTransferWritePlatformServiceImpl implements FundTransferWritePl
executeStrategy(t); executeStrategy(t);
FundTransfer saved = fundTransferRepository.save(t); FundTransfer saved = fundTransferRepository.save(t);
eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "APPROVED", null, approver.getId())); eventRepository.save(FundTransferEvent.of(saved.getId(), prev, saved.getStatus(), "APPROVED", null, approver.getId()));
amlFundTransferEvaluationService.evaluateIfSuccessful(saved);
return FundTransferReadPlatformServiceImpl.toData(saved); return FundTransferReadPlatformServiceImpl.toData(saved);
} }
@@ -26,6 +26,7 @@ import org.apache.fineract.infrastructure.imps.data.AccountData;
import org.apache.fineract.infrastructure.imps.data.ImpsRequestData; import org.apache.fineract.infrastructure.imps.data.ImpsRequestData;
import org.apache.fineract.infrastructure.imps.data.ImpsResponseData; import org.apache.fineract.infrastructure.imps.data.ImpsResponseData;
import org.apache.fineract.infrastructure.imps.data.PartyData; import org.apache.fineract.infrastructure.imps.data.PartyData;
import org.apache.fineract.portfolio.aml.service.AmlFundTransferEvaluationService;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransfer;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEvent; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEvent;
import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository; import org.apache.fineract.portfolio.fundtransfer.domain.FundTransferEventRepository;
@@ -54,6 +55,7 @@ public class NeftBatchProcessorService {
private final FundTransferEventRepository eventRepository; private final FundTransferEventRepository eventRepository;
private final ImpsSwitchClient switchClient; private final ImpsSwitchClient switchClient;
private final SuspenseGlService suspenseGl; private final SuspenseGlService suspenseGl;
private final AmlFundTransferEvaluationService amlFundTransferEvaluationService;
@Value("${fineract.imps.our-default-ifsc:BANK0000001}") @Value("${fineract.imps.our-default-ifsc:BANK0000001}")
private String ourDefaultIfsc; private String ourDefaultIfsc;
@@ -109,6 +111,7 @@ public class NeftBatchProcessorService {
fundTransferRepository.save(t); fundTransferRepository.save(t);
eventRepository.save(FundTransferEvent.of(t.getId(), prev, mapped, "NEFT_BATCH_PROCESSED", eventRepository.save(FundTransferEvent.of(t.getId(), prev, mapped, "NEFT_BATCH_PROCESSED",
"code=" + code, null)); "code=" + code, null));
amlFundTransferEvaluationService.evaluateIfSuccessful(t);
log.info("[NeftBatchProcessor] txnRef={} {} → {} utr={}", t.getTxnRef(), prev, mapped, t.getUtr()); log.info("[NeftBatchProcessor] txnRef={} {} → {} utr={}", t.getTxnRef(), prev, mapped, t.getUtr());
return mapped; return mapped;
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
@@ -484,4 +484,20 @@
<include file="parts/0362_add_share_account_edit_approval_permissions.xml" relativeToChangelogFile="true"/> <include file="parts/0362_add_share_account_edit_approval_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0421_add_master_salutation_value.xml" relativeToChangelogFile="true"/> <include file="parts/0421_add_master_salutation_value.xml" relativeToChangelogFile="true"/>
<include file="parts/0428_add_client_risk_rules.xml" relativeToChangelogFile="true"/> <include file="parts/0428_add_client_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0429_add_aml_fields_to_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0430_seed_aml_compliance_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0433_create_aml_transaction_monitoring_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0434_seed_aml_transaction_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0435_add_aml_officer_and_principal_officer_roles.xml" relativeToChangelogFile="true"/>
<include file="parts/0436_fix_missing_client_risk_rule_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0437_update_aml_columns_for_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0438_add_tiered_thresholds_and_low_balance_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0439_fill_client_risk_rule_income_turnover_nulls.xml" relativeToChangelogFile="true"/>
<include file="parts/0440_seed_aml_test_alerts.xml" relativeToChangelogFile="true"/>
<include file="parts/0441_add_aml_rule_limit_bands.xml" relativeToChangelogFile="true"/>
<include file="parts/0442_seed_branch_offices.xml" relativeToChangelogFile="true"/>
<include file="parts/0443_seed_aml_manual_referral_rule.xml" relativeToChangelogFile="true"/>
<include file="parts/0444_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0445_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
<include file="parts/0446_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

Some files were not shown because too many files have changed in this diff Show More