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

This commit is contained in:
2026-07-31 11:02:31 +05:30
parent b1cfa996b4
commit 2022dc5241
80 changed files with 4595 additions and 3 deletions
@@ -65,6 +65,8 @@ public enum JobName {
NEFT_BATCH_PROCESS("NEFT Batch Process"), //
VERIFICATION_TOKEN_CLEANUP("Verification Token Cleanup"), //
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;
@@ -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,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.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);
}
@@ -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,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.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;
@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 AmlRuleType getRuleType() {
return AmlRuleType.valueOf(ruleType);
}
public Integer getWindowDays() {
return windowDays;
}
public Long getSourceClientRiskRuleId() {
return sourceClientRiskRuleId;
}
public boolean isActive() {
return active;
}
}
@@ -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 AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
}
@@ -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
}
@@ -617,6 +617,10 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.office.getId();
}
public String getDisplayName() {
return this.displayName;
}
public String mobileNo() {
return this.mobileNo;
}
@@ -586,6 +586,15 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
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() {
return panNumber;
}
@@ -72,9 +72,24 @@ public class ClientRiskRule {
@Column(name = "risk_category", nullable = false)
private Integer riskCategory;
@Column(name = "reason", nullable = false, length = 255)
@Column(name = "reason", nullable = false, length = 500)
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)
private Integer orderPosition;
@@ -135,4 +150,24 @@ public class ClientRiskRule {
public Integer getOrderPosition() {
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;
}
}