Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1de1aab93 | ||
|
|
eb1aa767f7 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b | ||
|
|
9eac40bb5c | ||
|
|
14b2585cf5 | ||
|
|
a2fa8688ae | ||
|
|
a8ca7dd948 | ||
|
|
c8833367b9 | ||
|
|
94b0afff38 | ||
|
|
9dd4f8bedb | ||
|
|
1107610c08 | ||
|
|
2213308254 | ||
|
|
c568eb0abf | ||
|
|
1426be9531 | ||
|
|
541c6ab062 | ||
|
|
66a6a4766f | ||
|
|
b9aa29fb35 | ||
|
|
1a9adaec24 | ||
|
|
2da5a25e04 | ||
|
|
baf30cff68 | ||
|
|
f1d68d17fe | ||
|
|
84f8aa2274 | ||
|
|
7773a9e9c6 | ||
|
|
c890faee78 | ||
|
|
abc7eb22a2 | ||
|
|
d7d22382a5 | ||
|
|
8622dcaf90 | ||
|
|
7cdc25322e | ||
|
|
a4c3dbb6c4 | ||
|
|
dd8445de06 | ||
|
|
9345f2294c | ||
|
|
190cd92a85 | ||
|
|
81832b6bb7 | ||
|
|
2022dc5241 |
+10
-1
@@ -37,4 +37,13 @@ gradleExp/
|
||||
.windsurf/
|
||||
|
||||
# Application logs
|
||||
fineract-provider/logs/
|
||||
fineract-provider/logs/
|
||||
logs/
|
||||
|
||||
# Output of a local `gradlew devRun`
|
||||
devrun.log
|
||||
|
||||
# JVM crash dumps. The dev setup runs Fineract, the Angular dev server and Gradle on one
|
||||
# machine, so an out-of-memory kill drops these in the repo root.
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
+1
-1
@@ -159,7 +159,7 @@ gitVersioning.apply {
|
||||
}
|
||||
|
||||
ext['groovy.version'] = '4.0.17'
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString()
|
||||
ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20")
|
||||
|
||||
// Apply static weaving configuration to all subprojects
|
||||
subprojects { subproject ->
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
|
||||
// Configure Java compilation
|
||||
tasks.named('compileJava') {
|
||||
outputs.cacheIf { true }
|
||||
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+3
-1
@@ -436,7 +436,9 @@ public final class AccountingConstants {
|
||||
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY); //
|
||||
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
|
||||
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
|
||||
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
|
||||
|
||||
private final Integer value;
|
||||
private final String code;
|
||||
|
||||
+13
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
|
||||
this.dataValidationErrors.add(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
|
||||
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
|
||||
* args passed to it).
|
||||
*/
|
||||
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
|
||||
final Object... defaultUserMessageArgs) {
|
||||
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
|
||||
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
|
||||
this.value, defaultUserMessageArgs);
|
||||
this.dataValidationErrors.add(error);
|
||||
}
|
||||
|
||||
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
|
||||
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
|
||||
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
|
||||
|
||||
+3
@@ -38,6 +38,7 @@ public enum JobName {
|
||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||
@@ -65,6 +66,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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 for one customer inside the recurring-behaviour observation window. */
|
||||
long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
+29
@@ -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());
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* A reviewer's judgement on a customer who repeatedly trips the AML engine.
|
||||
*
|
||||
* Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this
|
||||
* transaction acceptable", this answers "is this customer's pattern of behaviour acceptable".
|
||||
* {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_aml_recurring_review")
|
||||
public class AmlRecurringReview {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
@Column(name = "alert_count_at_review", nullable = false)
|
||||
private Integer alertCountAtReview;
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 30)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "notes", nullable = false, length = 1000)
|
||||
private String notes;
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "reviewed_by", nullable = false)
|
||||
private AppUser reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on", nullable = false)
|
||||
private LocalDateTime reviewedOn;
|
||||
|
||||
protected AmlRecurringReview() {}
|
||||
|
||||
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
|
||||
final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) {
|
||||
final AmlRecurringReview review = new AmlRecurringReview();
|
||||
review.client = client;
|
||||
review.alertCountAtReview = alertCountAtReview;
|
||||
review.outcome = outcome.name();
|
||||
review.notes = notes;
|
||||
review.reviewedBy = reviewedBy;
|
||||
review.reviewedOn = LocalDateTime.now();
|
||||
return review;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public Integer getAlertCountAtReview() {
|
||||
return alertCountAtReview;
|
||||
}
|
||||
|
||||
public AmlRecurringReviewOutcome getOutcome() {
|
||||
return AmlRecurringReviewOutcome.valueOf(outcome);
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return notes;
|
||||
}
|
||||
|
||||
public AppUser getReviewedBy() {
|
||||
return reviewedBy;
|
||||
}
|
||||
|
||||
public LocalDateTime getReviewedOn() {
|
||||
return reviewedOn;
|
||||
}
|
||||
}
|
||||
+30
@@ -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.domain;
|
||||
|
||||
/** What a reviewer concluded about a customer's repeated AML activity. */
|
||||
public enum AmlRecurringReviewOutcome {
|
||||
|
||||
/** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */
|
||||
CLEARED,
|
||||
/** Not conclusive; keep watching this customer more closely. */
|
||||
MONITOR,
|
||||
/** Warrants an STR case; the reviewer escalates the individual alerts separately. */
|
||||
ESCALATED
|
||||
}
|
||||
+28
@@ -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.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AmlRecurringReviewRepository extends JpaRepository<AmlRecurringReview, Long> {
|
||||
|
||||
/** Newest first, so the caller can take the head as the latest review for a customer. */
|
||||
List<AmlRecurringReview> findByClient_IdOrderByReviewedOnDesc(Long clientId);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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() {}
|
||||
|
||||
/**
|
||||
* Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML
|
||||
* Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so
|
||||
* the two rows are always created together and the rule code is derived from it.
|
||||
*/
|
||||
public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description,
|
||||
final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays,
|
||||
final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) {
|
||||
final AmlTransactionRule rule = new AmlTransactionRule();
|
||||
rule.sourceClientRiskRuleId = sourceClientRiskRuleId;
|
||||
// rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide.
|
||||
rule.ruleCode = "CRR-" + sourceClientRiskRuleId;
|
||||
rule.description = description;
|
||||
rule.appliesTo = appliesTo.name();
|
||||
rule.ruleType = ruleType.name();
|
||||
rule.windowDays = windowDays;
|
||||
rule.thresholdAmount = thresholdAmount;
|
||||
rule.minLimitAmount = minLimitAmount;
|
||||
rule.maxLimitAmount = maxLimitAmount;
|
||||
rule.active = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
+138
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
+1
@@ -191,6 +191,7 @@ public class ClientApiConstants {
|
||||
public static final String constitutionIdParamName = "constitutionId";
|
||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||
public static final String additionalInfoParamName = "additionalInfo";
|
||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||
|
||||
|
||||
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
private Boolean alternateDipVerified;
|
||||
private Long alternateDipLookupLogId;
|
||||
private String alternateDipTimestamp;
|
||||
/**
|
||||
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
|
||||
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
|
||||
* reassigned would otherwise keep showing "Verified" forever.
|
||||
*/
|
||||
private String dipLiveStatus;
|
||||
private String alternateDipLiveStatus;
|
||||
/**
|
||||
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
|
||||
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
|
||||
* would keep reading "Clear" after the client was added to a later list snapshot.
|
||||
*/
|
||||
private String sanctionsAlertStatus;
|
||||
private Boolean nameVerified;
|
||||
private Long screeningLogId;
|
||||
private Boolean isStaff;
|
||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||
// here is silently dropped from the payload the client view loads.
|
||||
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||
data.nameVerified = clientData.nameVerified;
|
||||
data.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||
// here is silently dropped from the payload the client view loads.
|
||||
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||
data.nameVerified = clientData.nameVerified;
|
||||
data.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||
// here is silently dropped from the payload the client view loads.
|
||||
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||
data.nameVerified = clientData.nameVerified;
|
||||
data.screeningLogId = clientData.screeningLogId;
|
||||
return data;
|
||||
|
||||
+35
@@ -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;
|
||||
}
|
||||
+3
@@ -84,6 +84,9 @@ public final class ClientFamilyMembersData implements Serializable {
|
||||
private final LocalDate marriageDate;
|
||||
private final String marriageAuthority;
|
||||
|
||||
@lombok.Setter
|
||||
private java.util.List<ClientFamilyMemberChildData> children;
|
||||
|
||||
// template holder
|
||||
private final Collection<CodeValueData> relationshipIdOptions;
|
||||
private final Collection<CodeValueData> genderIdOptions;
|
||||
|
||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
||||
private final CodeValueData mainBusinessLine;
|
||||
private final String remarks;
|
||||
private final String authorizedSignatory;
|
||||
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
|
||||
private final String authorizedSignatoryClientIds;
|
||||
private final String additionalInfo;
|
||||
private final LocalDate dateOfRegistration;
|
||||
|
||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
||||
this.constitution = null;
|
||||
this.mainBusinessLine = null;
|
||||
this.authorizedSignatory = null;
|
||||
this.authorizedSignatoryClientIds = null;
|
||||
this.additionalInfo = null;
|
||||
this.dateOfRegistration = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
|
||||
*/
|
||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||
LocalDate dateOfRegistration) {
|
||||
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
|
||||
dateOfRegistration, null);
|
||||
}
|
||||
|
||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||
|
||||
this.constitution = constitution;
|
||||
this.incorpNumber = incorpNo;
|
||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
||||
this.mainBusinessLine = mainBusinessLine;
|
||||
this.remarks = remarks;
|
||||
this.authorizedSignatory = authorizedSignatory;
|
||||
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||
this.additionalInfo = additionalInfo;
|
||||
this.dateOfRegistration = dateOfRegistration;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
|
||||
import jakarta.persistence.ManyToMany;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "is_pwd")
|
||||
private Boolean isPwd;
|
||||
|
||||
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
|
||||
private ClientKyc clientKyc;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "pwd_category_cv_id")
|
||||
private CodeValue pwdCategory;
|
||||
@@ -290,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "alternate_dip_timestamp")
|
||||
private String alternateDipTimestamp;
|
||||
|
||||
/**
|
||||
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
|
||||
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
|
||||
* service was down", which previously stored both as verified.
|
||||
*/
|
||||
@Column(name = "dip_status", length = 40)
|
||||
private String dipStatus;
|
||||
|
||||
@Column(name = "alternate_dip_status", length = 40)
|
||||
private String alternateDipStatus;
|
||||
|
||||
@Column(name = "name_verified")
|
||||
private Boolean nameVerified;
|
||||
|
||||
@@ -617,6 +632,10 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return this.office.getId();
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return this.displayName;
|
||||
}
|
||||
|
||||
public String mobileNo() {
|
||||
return this.mobileNo;
|
||||
}
|
||||
|
||||
+316
@@ -25,10 +25,15 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
@@ -60,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/**
|
||||
* Scratch record of what the in-flight {@link #update} replaced, read back via
|
||||
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
|
||||
* transaction that called update(), which clears it first.
|
||||
*/
|
||||
@Transient
|
||||
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
|
||||
|
||||
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
||||
@Column(name = "kyc_status", nullable = false)
|
||||
private Integer kycStatus;
|
||||
@@ -103,6 +116,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "pan_status", length = 20)
|
||||
private String panStatus;
|
||||
|
||||
// ── PAN declaration ──────────────────────────────────────────────────────
|
||||
// Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of
|
||||
// birth, so a customer whose PAN card carries a different spelling — or a different DOB — can
|
||||
// never clear it however many times the officer retries. Rather than leave such a client
|
||||
// permanently in KYC exception, the branch takes a signed declaration recording what differs.
|
||||
// Holding that declaration is what suppresses the exception, so it is stored, not just shown.
|
||||
|
||||
/** True once a signed declaration has been accepted for a PAN that could not be verified. */
|
||||
@Column(name = "pan_declaration_taken")
|
||||
private Boolean panDeclarationTaken;
|
||||
|
||||
/** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */
|
||||
@Column(name = "pan_declaration_mismatch_fields", length = 100)
|
||||
private String panDeclarationMismatchFields;
|
||||
|
||||
/** Name exactly as printed on the physical PAN card, keyed in by the officer. */
|
||||
@Column(name = "pan_declaration_name_on_pan", length = 200)
|
||||
private String panDeclarationNameOnPan;
|
||||
|
||||
/** Date of birth exactly as printed on the physical PAN card. */
|
||||
@Column(name = "pan_declaration_dob_on_pan")
|
||||
private LocalDate panDeclarationDobOnPan;
|
||||
|
||||
/** Officer's free-text note — why the mismatch exists and what proof was sighted. */
|
||||
@Column(name = "pan_declaration_remarks", length = 500)
|
||||
private String panDeclarationRemarks;
|
||||
|
||||
@Column(name = "pan_declaration_taken_on")
|
||||
private LocalDate panDeclarationTakenOn;
|
||||
|
||||
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
||||
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
||||
@Column(name = "aadhaar_number_masked", length = 12)
|
||||
@@ -114,6 +157,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "aadhaar_verified_on")
|
||||
private LocalDate aadhaarVerifiedOn;
|
||||
|
||||
// ── Aadhaar enrolment receipt ────────────────────────────────────────────
|
||||
// The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does
|
||||
// not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar
|
||||
// itself is issued there is no number to put through the OTP flow — the check cannot pass
|
||||
// however long the branch waits. Recording the receipt is what waives the exception.
|
||||
|
||||
/** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */
|
||||
@Column(name = "aadhaar_receipt_taken")
|
||||
private Boolean aadhaarReceiptTaken;
|
||||
|
||||
/** 28-digit Enrolment ID from the slip, stored with its printed separators. */
|
||||
@Column(name = "aadhaar_enrolment_id", length = 40)
|
||||
private String aadhaarEnrolmentId;
|
||||
|
||||
/** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */
|
||||
@Column(name = "aadhaar_enrolment_date")
|
||||
private LocalDate aadhaarEnrolmentDate;
|
||||
|
||||
/** Officer's note on what was sighted. */
|
||||
@Column(name = "aadhaar_receipt_remarks", length = 500)
|
||||
private String aadhaarReceiptRemarks;
|
||||
|
||||
@Column(name = "aadhaar_receipt_taken_on")
|
||||
private LocalDate aadhaarReceiptTakenOn;
|
||||
|
||||
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
||||
@Column(name = "gstin_number", length = 15)
|
||||
private String gstinNumber;
|
||||
@@ -195,6 +263,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "freeze_authority", length = 100)
|
||||
private String freezeAuthority;
|
||||
|
||||
@Column(name = "freeze_date")
|
||||
private LocalDate freezeDate;
|
||||
|
||||
@Column(name = "freeze_amount", precision = 19, scale = 6)
|
||||
private BigDecimal freezeAmount;
|
||||
|
||||
@Column(name = "unfreeze_date")
|
||||
private LocalDate unfreezeDate;
|
||||
|
||||
@Column(name = "no_debit_date")
|
||||
private LocalDate noDebitDate;
|
||||
|
||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||
@Column(name = "form60_type", length = 20)
|
||||
private String form60Type;
|
||||
@@ -241,6 +321,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
@Column(name = "remarks", length = 500)
|
||||
private String remarks;
|
||||
|
||||
@Column(name = "is_exception", nullable = false)
|
||||
private Boolean isException;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Column(name = "exception_resolved", nullable = false)
|
||||
private Boolean exceptionResolved;
|
||||
|
||||
@Column(name = "exception_resolved_on")
|
||||
private LocalDate exceptionResolvedOn;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected ClientKyc() {}
|
||||
@@ -261,6 +353,8 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.gstinVerified = false;
|
||||
this.cinVerified = false;
|
||||
this.dinVerified = false;
|
||||
this.isException = false;
|
||||
this.exceptionResolved = false;
|
||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||
}
|
||||
|
||||
@@ -288,6 +382,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
private void applyExtendedFields(final JsonCommand command) {
|
||||
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
||||
final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken");
|
||||
this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false;
|
||||
this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null);
|
||||
this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null);
|
||||
this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan");
|
||||
this.panDeclarationRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("panDeclarationRemarks"), null);
|
||||
this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn");
|
||||
final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken");
|
||||
this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false;
|
||||
this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null);
|
||||
this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate");
|
||||
this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null);
|
||||
this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn");
|
||||
this.enterprisePan = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("enterprisePan"), null);
|
||||
this.cinNumber = StringUtils.defaultIfEmpty(
|
||||
@@ -317,6 +429,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("freezeReason"), null);
|
||||
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
||||
this.freezeDate = command.localDateValueOfParameterNamed("freezeDate");
|
||||
this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount");
|
||||
this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate");
|
||||
this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate");
|
||||
this.form60Type = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form60Type"), null);
|
||||
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
||||
@@ -325,15 +441,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
command.stringValueOfParameterNamed("form15Type"), null);
|
||||
this.form15Fy = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("form15FY"), null);
|
||||
Boolean isExceptionVal = command.booleanObjectValueOfParameterNamed("isException");
|
||||
this.isException = isExceptionVal != null ? isExceptionVal : false;
|
||||
this.exceptionReason = StringUtils.defaultIfEmpty(
|
||||
command.stringValueOfParameterNamed("exceptionReason"), null);
|
||||
Boolean exceptionResolvedVal = command.booleanObjectValueOfParameterNamed("exceptionResolved");
|
||||
this.exceptionResolved = exceptionResolvedVal != null ? exceptionResolvedVal : false;
|
||||
this.exceptionResolvedOn = command.localDateValueOfParameterNamed("exceptionResolvedOn");
|
||||
}
|
||||
|
||||
public Map<String, Object> update(final JsonCommand command) {
|
||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||
this.fieldsOverwrittenByLastUpdate.clear();
|
||||
|
||||
final String panNumberParam = "panNumber";
|
||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||
actualChanges.put(panNumberParam, newValue);
|
||||
recordOverwrite(panNumberParam, this.panNumber);
|
||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -341,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||
actualChanges.put(aadhaarParam, newValue);
|
||||
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -348,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||
actualChanges.put(gstinParam, newValue);
|
||||
recordOverwrite(gstinParam, this.gstinNumber);
|
||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||
}
|
||||
|
||||
@@ -355,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||
actualChanges.put(riskParam, newValue);
|
||||
recordOverwrite(riskParam, this.riskCategory);
|
||||
this.riskCategory = newValue;
|
||||
}
|
||||
|
||||
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||
// as an overwrite — requiring remarks in order to write remarks would be circular.
|
||||
final String remarksParam = "remarks";
|
||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||
@@ -367,6 +497,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
||||
v -> this.panAadhaarSeedingStatus = v);
|
||||
updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges,
|
||||
v -> this.panDeclarationTaken = v != null ? v : false);
|
||||
updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges,
|
||||
v -> this.panDeclarationMismatchFields = v);
|
||||
updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges,
|
||||
v -> this.panDeclarationNameOnPan = v);
|
||||
updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges,
|
||||
v -> this.panDeclarationDobOnPan = v);
|
||||
updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges,
|
||||
v -> this.panDeclarationRemarks = v);
|
||||
updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges,
|
||||
v -> this.panDeclarationTakenOn = v);
|
||||
updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges,
|
||||
v -> this.aadhaarReceiptTaken = v != null ? v : false);
|
||||
updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges,
|
||||
v -> this.aadhaarEnrolmentId = v);
|
||||
updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges,
|
||||
v -> this.aadhaarEnrolmentDate = v);
|
||||
updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges,
|
||||
v -> this.aadhaarReceiptRemarks = v);
|
||||
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
|
||||
v -> this.aadhaarReceiptTakenOn = v);
|
||||
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||
@@ -392,12 +544,23 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
||||
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
||||
v -> this.freezeAuthority = v);
|
||||
updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v);
|
||||
updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v);
|
||||
updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v);
|
||||
updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v);
|
||||
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
||||
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
||||
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
||||
v -> this.form15Applicable = v);
|
||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||
|
||||
return actualChanges;
|
||||
}
|
||||
@@ -407,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
}
|
||||
@@ -416,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
@@ -426,11 +591,51 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<BigDecimal> setter) {
|
||||
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
recordOverwrite(param, current);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes that {@code param} replaced a value the record already held. A field going from empty to
|
||||
* populated is the record being completed rather than altered, so it is deliberately not
|
||||
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||
*/
|
||||
private void recordOverwrite(final String param, final Object currentValue) {
|
||||
final boolean wasPopulated = currentValue instanceof String text
|
||||
? StringUtils.isNotBlank(text)
|
||||
: currentValue != null;
|
||||
if (wasPopulated) {
|
||||
this.fieldsOverwrittenByLastUpdate.add(param);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters whose already-populated value the most recent {@link #update} replaced.
|
||||
*
|
||||
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
|
||||
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
|
||||
* still has to save the KYC document details afterwards. Those fields are empty at that point,
|
||||
* so filling them in leaves this set empty and no audit justification is demanded — while
|
||||
* changing a document number that was already on the verified record does populate it.
|
||||
*
|
||||
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
|
||||
*/
|
||||
public Set<String> getFieldsOverwrittenByLastUpdate() {
|
||||
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
|
||||
}
|
||||
|
||||
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||
public void markInProgress() {
|
||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||
@@ -586,6 +791,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;
|
||||
}
|
||||
@@ -622,6 +836,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return panStatus;
|
||||
}
|
||||
|
||||
public Boolean getPanDeclarationTaken() {
|
||||
return panDeclarationTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the PAN exception is waived. */
|
||||
public boolean isPanDeclarationTaken() {
|
||||
return Boolean.TRUE.equals(this.panDeclarationTaken);
|
||||
}
|
||||
|
||||
public String getPanDeclarationMismatchFields() {
|
||||
return panDeclarationMismatchFields;
|
||||
}
|
||||
|
||||
public String getPanDeclarationNameOnPan() {
|
||||
return panDeclarationNameOnPan;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationDobOnPan() {
|
||||
return panDeclarationDobOnPan;
|
||||
}
|
||||
|
||||
public String getPanDeclarationRemarks() {
|
||||
return panDeclarationRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getPanDeclarationTakenOn() {
|
||||
return panDeclarationTakenOn;
|
||||
}
|
||||
|
||||
public Boolean getAadhaarReceiptTaken() {
|
||||
return aadhaarReceiptTaken;
|
||||
}
|
||||
|
||||
/** Null-safe form for callers deciding whether the Aadhaar exception is waived. */
|
||||
public boolean isAadhaarReceiptTaken() {
|
||||
return Boolean.TRUE.equals(this.aadhaarReceiptTaken);
|
||||
}
|
||||
|
||||
public String getAadhaarEnrolmentId() {
|
||||
return aadhaarEnrolmentId;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarEnrolmentDate() {
|
||||
return aadhaarEnrolmentDate;
|
||||
}
|
||||
|
||||
public String getAadhaarReceiptRemarks() {
|
||||
return aadhaarReceiptRemarks;
|
||||
}
|
||||
|
||||
public LocalDate getAadhaarReceiptTakenOn() {
|
||||
return aadhaarReceiptTakenOn;
|
||||
}
|
||||
|
||||
public String getAadhaarNumberMasked() {
|
||||
return aadhaarNumberMasked;
|
||||
}
|
||||
@@ -786,6 +1054,38 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return form15Fy;
|
||||
}
|
||||
|
||||
public Boolean getIsException() {
|
||||
return isException;
|
||||
}
|
||||
|
||||
public void setIsException(Boolean isException) {
|
||||
this.isException = isException;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return exceptionReason;
|
||||
}
|
||||
|
||||
public void setExceptionReason(String exceptionReason) {
|
||||
this.exceptionReason = exceptionReason;
|
||||
}
|
||||
|
||||
public Boolean getExceptionResolved() {
|
||||
return exceptionResolved;
|
||||
}
|
||||
|
||||
public void setExceptionResolved(Boolean exceptionResolved) {
|
||||
this.exceptionResolved = exceptionResolved;
|
||||
}
|
||||
|
||||
public LocalDate getExceptionResolvedOn() {
|
||||
return exceptionResolvedOn;
|
||||
}
|
||||
|
||||
public void setExceptionResolvedOn(LocalDate exceptionResolvedOn) {
|
||||
this.exceptionResolvedOn = exceptionResolvedOn;
|
||||
}
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = Boolean.TRUE;
|
||||
|
||||
@@ -837,4 +1137,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public Long getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public LocalDate getFreezeDate() {
|
||||
return this.freezeDate;
|
||||
}
|
||||
|
||||
public BigDecimal getFreezeAmount() {
|
||||
return this.freezeAmount;
|
||||
}
|
||||
|
||||
public LocalDate getUnfreezeDate() {
|
||||
return this.unfreezeDate;
|
||||
}
|
||||
|
||||
public LocalDate getNoDebitDate() {
|
||||
return this.noDebitDate;
|
||||
}
|
||||
}
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
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;
|
||||
|
||||
/**
|
||||
* One customer-outreach attempt against an open KYC exception.
|
||||
*
|
||||
* <p>Append-only by design — there is no setter-driven update path and the API exposes no PUT or
|
||||
* DELETE. A contact log that can be edited after the fact is worthless as evidence, and RBI's KYC
|
||||
* Master Direction expects the bank to be able to show what it told the customer and when.
|
||||
*
|
||||
* <p>Destinations are stored masked ({@code destinationMasked}); the full mobile number / email
|
||||
* lives on {@code m_client} and is resolved server-side at send time, never accepted from the
|
||||
* browser.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_client_kyc_contact_log")
|
||||
public class ClientKycContactLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "client_id", nullable = false)
|
||||
private Client client;
|
||||
|
||||
/** The KYC record the exception belongs to. Nullable — outreach can precede a KYC row. */
|
||||
@Column(name = "kyc_id")
|
||||
private Long kycId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "channel", length = 20, nullable = false)
|
||||
private KycContactChannel channel;
|
||||
|
||||
@Column(name = "notes", length = 1000, nullable = false)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "officer_name", length = 200)
|
||||
private String officerName;
|
||||
|
||||
@Column(name = "logged_by_user_id")
|
||||
private Long loggedByUserId;
|
||||
|
||||
@Column(name = "exception_reason", length = 200)
|
||||
private String exceptionReason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "delivery_status", length = 30, nullable = false)
|
||||
private KycContactDeliveryStatus deliveryStatus;
|
||||
|
||||
@Column(name = "destination_masked", length = 120)
|
||||
private String destinationMasked;
|
||||
|
||||
@Column(name = "provider", length = 40)
|
||||
private String provider;
|
||||
|
||||
@Column(name = "provider_reference", length = 120)
|
||||
private String providerReference;
|
||||
|
||||
@Column(name = "failure_reason", length = 500)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "message_body", length = 2000)
|
||||
private String messageBody;
|
||||
|
||||
@Column(name = "contacted_at", nullable = false)
|
||||
private LocalDateTime contactedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
protected ClientKycContactLog() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
public ClientKycContactLog(final Client client, final Long kycId, final KycContactChannel channel, final String notes,
|
||||
final String officerName, final Long loggedByUserId, final String exceptionReason, final LocalDateTime contactedAt) {
|
||||
this.client = client;
|
||||
this.kycId = kycId;
|
||||
this.channel = channel;
|
||||
this.notes = notes;
|
||||
this.officerName = officerName;
|
||||
this.loggedByUserId = loggedByUserId;
|
||||
this.exceptionReason = exceptionReason;
|
||||
this.contactedAt = contactedAt;
|
||||
this.createdAt = contactedAt;
|
||||
this.deliveryStatus = KycContactDeliveryStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the outcome of a platform send onto this log row. Called once, before the row is
|
||||
* persisted — the entity is append-only afterwards.
|
||||
*/
|
||||
public void recordDeliveryOutcome(final KycContactDeliveryStatus status, final String destinationMasked, final String provider,
|
||||
final String providerReference, final String failureReason, final String messageBody) {
|
||||
this.deliveryStatus = status;
|
||||
this.destinationMasked = destinationMasked;
|
||||
this.provider = provider;
|
||||
this.providerReference = providerReference;
|
||||
this.failureReason = truncate(failureReason, 500);
|
||||
this.messageBody = truncate(messageBody, 2000);
|
||||
}
|
||||
|
||||
private static String truncate(final String value, final int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public Client getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public Long getKycId() {
|
||||
return this.kycId;
|
||||
}
|
||||
|
||||
public KycContactChannel getChannel() {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
public String getNotes() {
|
||||
return this.notes;
|
||||
}
|
||||
|
||||
public String getOfficerName() {
|
||||
return this.officerName;
|
||||
}
|
||||
|
||||
public Long getLoggedByUserId() {
|
||||
return this.loggedByUserId;
|
||||
}
|
||||
|
||||
public String getExceptionReason() {
|
||||
return this.exceptionReason;
|
||||
}
|
||||
|
||||
public KycContactDeliveryStatus getDeliveryStatus() {
|
||||
return this.deliveryStatus;
|
||||
}
|
||||
|
||||
public String getDestinationMasked() {
|
||||
return this.destinationMasked;
|
||||
}
|
||||
|
||||
public String getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public String getProviderReference() {
|
||||
return this.providerReference;
|
||||
}
|
||||
|
||||
public String getFailureReason() {
|
||||
return this.failureReason;
|
||||
}
|
||||
|
||||
public String getMessageBody() {
|
||||
return this.messageBody;
|
||||
}
|
||||
|
||||
public LocalDateTime getContactedAt() {
|
||||
return this.contactedAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return this.createdAt;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 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 ClientKycContactLogRepository extends JpaRepository<ClientKycContactLog, Long> {
|
||||
|
||||
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
|
||||
|
||||
/**
|
||||
* Every contact log for the given clients in one round trip, so the exceptions grid can render
|
||||
* "last contacted" for the whole page without an N+1 query per row.
|
||||
*/
|
||||
@Query("SELECT l FROM ClientKycContactLog l WHERE l.client.id IN :clientIds ORDER BY l.contactedAt ASC")
|
||||
List<ClientKycContactLog> findByClientIds(@Param("clientIds") List<Long> clientIds);
|
||||
}
|
||||
+12
@@ -33,6 +33,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
|
||||
|
||||
boolean existsByClientId(Long clientId);
|
||||
|
||||
List<ClientKyc> findByIsExceptionTrueAndExceptionResolvedFalse();
|
||||
|
||||
/**
|
||||
* Resolved exceptions, keyed on {@code exceptionResolved} alone.
|
||||
*
|
||||
* <p>Deliberately does NOT also require {@code isException = true}. Resolving an exception
|
||||
* clears {@code isException} — that flag means "has an OPEN exception", so leaving it set on a
|
||||
* closed one would be wrong. An earlier version of this query required both flags, which made
|
||||
* every resolved record invisible in both tabs the moment it was resolved.
|
||||
*/
|
||||
List<ClientKyc> findByExceptionResolvedTrue();
|
||||
|
||||
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
|
||||
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
|
||||
|
||||
|
||||
-7
@@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository<Client, Long>, JpaSpecif
|
||||
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
||||
void removeSignatureId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Count clients whose cif_number begins with the given prefix. Used by the CIF number generator
|
||||
* (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket.
|
||||
*/
|
||||
@Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')")
|
||||
long countByCifNumberPrefix(@Param("prefix") String prefix);
|
||||
|
||||
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
||||
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
||||
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
||||
|
||||
+74
-1
@@ -72,14 +72,67 @@ 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;
|
||||
|
||||
protected ClientRiskRule() {}
|
||||
|
||||
/**
|
||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||
*/
|
||||
public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory,
|
||||
final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome,
|
||||
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
|
||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
|
||||
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
|
||||
final String dormantFlag) {
|
||||
final ClientRiskRule rule = new ClientRiskRule();
|
||||
rule.ruleName = ruleName;
|
||||
rule.reason = reason;
|
||||
rule.riskCategory = riskCategory;
|
||||
rule.orderPosition = orderPosition;
|
||||
rule.legalFormId = legalFormId;
|
||||
rule.minMonthlyIncome = minMonthlyIncome;
|
||||
rule.maxMonthlyIncome = maxMonthlyIncome;
|
||||
rule.minAnnualIncome = minAnnualIncome;
|
||||
rule.maxAnnualIncome = maxAnnualIncome;
|
||||
rule.minAnnualTurnover = minAnnualTurnover;
|
||||
rule.maxAnnualTurnover = maxAnnualTurnover;
|
||||
rule.amlCheckStatus = amlCheckStatus;
|
||||
rule.bannedEntityMatch = bannedEntityMatch;
|
||||
rule.willfulDefaulter = willfulDefaulter;
|
||||
rule.ecgcSalFlag = ecgcSalFlag;
|
||||
rule.dormantFlag = dormantFlag;
|
||||
return rule;
|
||||
}
|
||||
|
||||
public void setOccupationCategory(final CodeValue occupationCategory) {
|
||||
this.occupationCategory = occupationCategory;
|
||||
}
|
||||
|
||||
public void setOccupation(final CodeValue occupation) {
|
||||
this.occupation = occupation;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -135,4 +188,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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
|
||||
|
||||
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
||||
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
||||
|
||||
/** Highest position in use, so a new rule can be appended to the end of the catalog. */
|
||||
@Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r")
|
||||
Integer findMaxOrderPosition();
|
||||
|
||||
boolean existsByRuleNameIgnoreCase(String ruleName);
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The channel a KYC officer used (or asked the platform to use) to reach a customer about an open
|
||||
* KYC exception.
|
||||
*
|
||||
* <p>The critical distinction is {@link #isPlatformSent()}:
|
||||
* <ul>
|
||||
* <li><b>Offline channels</b> ({@code PHONE_CALL}, {@code BRANCH_VISIT}, {@code FIELD_VISIT}) are
|
||||
* recorded after the fact — the officer already did the thing, the platform only writes it
|
||||
* down.</li>
|
||||
* <li><b>Sending channels</b> ({@code SMS}, {@code EMAIL}, {@code WHATSAPP}) cause the platform to
|
||||
* actually transmit a notice to the customer at save time.</li>
|
||||
* </ul>
|
||||
* Mixing these up would let a "logged" row imply the customer was told something they never
|
||||
* received, so every call site branches on this flag rather than on the channel name.
|
||||
*/
|
||||
public enum KycContactChannel {
|
||||
|
||||
PHONE_CALL("Phone Call", false),
|
||||
SMS("SMS", true),
|
||||
EMAIL("Email", true),
|
||||
WHATSAPP("WhatsApp Message", true),
|
||||
BRANCH_VISIT("In-Branch Visit", false),
|
||||
FIELD_VISIT("Agent Field Visit", false);
|
||||
|
||||
private final String displayLabel;
|
||||
private final boolean platformSent;
|
||||
|
||||
KycContactChannel(final String displayLabel, final boolean platformSent) {
|
||||
this.displayLabel = displayLabel;
|
||||
this.platformSent = platformSent;
|
||||
}
|
||||
|
||||
public String getDisplayLabel() {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
/** True when saving a log of this channel must actually transmit a message to the customer. */
|
||||
public boolean isPlatformSent() {
|
||||
return this.platformSent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of whatever the UI sent. Accepts the enum name ({@code SMS}) as well as the
|
||||
* display label, and tolerates the descriptive suffixes the dialog uses — e.g.
|
||||
* {@code "SMS (Aadhaar OTP request)"} and {@code "Email (PAN Link request)"} — because those
|
||||
* labels describe the *purpose* of the message, not a different channel.
|
||||
*
|
||||
* @return the matching channel, or {@code null} when nothing matches.
|
||||
*/
|
||||
public static KycContactChannel fromString(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final String normalised = raw.trim().toUpperCase();
|
||||
for (final KycContactChannel channel : values()) {
|
||||
if (normalised.equals(channel.name()) || normalised.equals(channel.displayLabel.toUpperCase())) {
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
// Prefix match handles the "(…request)" suffixes. Longest label first so WHATSAPP's
|
||||
// "WHATSAPP MESSAGE" is preferred over a shorter accidental prefix.
|
||||
KycContactChannel best = null;
|
||||
for (final KycContactChannel channel : values()) {
|
||||
final String label = channel.displayLabel.toUpperCase();
|
||||
if (normalised.startsWith(label) && (best == null || label.length() > best.displayLabel.length())) {
|
||||
best = channel;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
+48
@@ -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.client.domain;
|
||||
|
||||
/**
|
||||
* Outcome of a platform-sent KYC contact notice.
|
||||
*
|
||||
* <p>Every state other than {@link #SENT} means the customer was <em>not</em> reached. The officer
|
||||
* is shown the distinction explicitly, because a contact log that silently swallows a gateway
|
||||
* failure would understate the bank's outreach obligation during a KYC audit.
|
||||
*/
|
||||
public enum KycContactDeliveryStatus {
|
||||
|
||||
/** Offline channel — nothing was transmitted and nothing was supposed to be. */
|
||||
NOT_APPLICABLE,
|
||||
|
||||
/** The gateway accepted the message for delivery. */
|
||||
SENT,
|
||||
|
||||
/** The gateway was reached but rejected the message, or the call errored. */
|
||||
FAILED,
|
||||
|
||||
/** The customer has no mobile number / email address on file for this channel. */
|
||||
SKIPPED_NO_DESTINATION,
|
||||
|
||||
/** No gateway is configured for this channel in this deployment. */
|
||||
PROVIDER_NOT_CONFIGURED;
|
||||
|
||||
public boolean isDelivered() {
|
||||
return this == SENT;
|
||||
}
|
||||
}
|
||||
+46
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
||||
@Column(name = "looked_up_on", nullable = false)
|
||||
private OffsetDateTime lookedUpOn;
|
||||
|
||||
// ── Officer decision on a non-clean result ────────────────────────────────
|
||||
// A DIP hit is a fraud-risk signal rather than a legal bar, so onboarding may continue — but
|
||||
// the officer has to say why in writing, and that reasoning is kept with the lookup.
|
||||
|
||||
/** PROCEEDED | REJECTED. Null while no decision is required or none has been taken. */
|
||||
@Column(name = "override_decision", length = 20)
|
||||
private String overrideDecision;
|
||||
|
||||
@Column(name = "override_reason")
|
||||
private String overrideReason;
|
||||
|
||||
@Column(name = "decided_by")
|
||||
private Long decidedBy;
|
||||
|
||||
@Column(name = "decided_on")
|
||||
private OffsetDateTime decidedOn;
|
||||
|
||||
public static final String STATUS_CLEAN = "CLEAN";
|
||||
public static final String STATUS_ERROR = "ERROR";
|
||||
public static final String STATUS_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE";
|
||||
public static final String DECISION_PROCEEDED = "PROCEEDED";
|
||||
public static final String DECISION_REJECTED = "REJECTED";
|
||||
|
||||
protected MobileLookupLog() {}
|
||||
|
||||
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
||||
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
||||
this.clientId = clientId;
|
||||
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
public void recordDecision(final String decision, final String reason, final Long userId) {
|
||||
this.overrideDecision = decision;
|
||||
this.overrideReason = reason;
|
||||
this.decidedBy = userId;
|
||||
this.decidedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** True when the lookup produced something the officer must consciously decide about. */
|
||||
public boolean requiresDecision() {
|
||||
return this.suspected || this.disconnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* A number may be used only when it came back clean, or when an officer explicitly chose to
|
||||
* proceed. An unreachable DoT service is NOT a pass — it means nothing was checked.
|
||||
*/
|
||||
public boolean isUsable() {
|
||||
if (STATUS_CLEAN.equals(this.lookupStatus)) {
|
||||
return true;
|
||||
}
|
||||
return DECISION_PROCEEDED.equals(this.overrideDecision);
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -19,6 +19,8 @@
|
||||
package org.apache.fineract.portfolio.mobilelookup.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -35,4 +37,29 @@ public interface MobileLookupLogRepository extends JpaRepository<MobileLookupLog
|
||||
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
||||
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
||||
|
||||
/** Non-clean lookups still waiting for an officer's decision — the DIP review queue. */
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE (l.suspected = true OR l.disconnected = true) "
|
||||
+ "AND l.overrideDecision IS NULL ORDER BY l.lookedUpOn DESC")
|
||||
List<MobileLookupLog> findPendingDecisions();
|
||||
|
||||
/**
|
||||
* Most recent lookup for a number, used by the server-side gate to confirm the browser's
|
||||
* claim that a number was verified.
|
||||
*
|
||||
* <p>
|
||||
* Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller
|
||||
* takes the first row.
|
||||
* </p>
|
||||
*/
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
|
||||
List<MobileLookupLog> findLatestByMobileNumber(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Earlier rejections of the same number, on any client. A number turned away once is a
|
||||
* standing signal: without this the next officer screens it fresh and sees no history.
|
||||
*/
|
||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber "
|
||||
+ "AND l.overrideDecision = 'REJECTED' ORDER BY l.decidedOn DESC")
|
||||
List<MobileLookupLog> findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||
}
|
||||
|
||||
+139
@@ -61,6 +61,65 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
@Column(name = "screened_on", nullable = false)
|
||||
private OffsetDateTime screenedOn;
|
||||
|
||||
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||
// A FLAGGED screening opens a case. It stays PENDING_REVIEW until an officer either
|
||||
// documents it as a different person (FALSE_POSITIVE, effective only once the Principal
|
||||
// Officer approves) or confirms it (CONFIRMED_MATCH, which freezes and gets reported).
|
||||
|
||||
@Column(name = "disposition", length = 30)
|
||||
private String disposition;
|
||||
|
||||
@Column(name = "disposition_reason")
|
||||
private String dispositionReason;
|
||||
|
||||
@Column(name = "reviewed_by")
|
||||
private Long reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_on")
|
||||
private OffsetDateTime reviewedOn;
|
||||
|
||||
@Column(name = "approved_by")
|
||||
private Long approvedBy;
|
||||
|
||||
@Column(name = "approved_on")
|
||||
private OffsetDateTime approvedOn;
|
||||
|
||||
@Column(name = "fiu_str_reference", length = 100)
|
||||
private String fiuStrReference;
|
||||
|
||||
@Column(name = "nodal_officer_informed_on")
|
||||
private OffsetDateTime nodalOfficerInformedOn;
|
||||
|
||||
@Column(name = "frozen_account_ids", length = 1000)
|
||||
private String frozenAccountIds;
|
||||
|
||||
@Column(name = "frozen_on")
|
||||
private OffsetDateTime frozenOn;
|
||||
|
||||
// ── STR tracking ──────────────────────────────────────────────────────────
|
||||
// A confirmed match is reportable to FIU-IND. Tracked here rather than as an AML alert: an
|
||||
// AML alert is built around a monetary transaction, whereas this is triggered by who the
|
||||
// customer is, so the two are surfaced as separate streams on the FIU screen.
|
||||
|
||||
/** NULL until approval; then PENDING_FILING → FILED. */
|
||||
@Column(name = "str_status", length = 30)
|
||||
private String strStatus;
|
||||
|
||||
@Column(name = "str_due_date")
|
||||
private java.time.LocalDate strDueDate;
|
||||
|
||||
@Column(name = "str_raised_on")
|
||||
private OffsetDateTime strRaisedOn;
|
||||
|
||||
public static final String STR_PENDING_FILING = "PENDING_FILING";
|
||||
public static final String STR_FILED = "FILED";
|
||||
|
||||
public static final String RESULT_FLAGGED = "FLAGGED";
|
||||
public static final String RESULT_CLEAR = "CLEAR";
|
||||
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
|
||||
public static final String DISPOSITION_FALSE_POSITIVE = "FALSE_POSITIVE";
|
||||
public static final String DISPOSITION_CONFIRMED = "CONFIRMED_MATCH";
|
||||
|
||||
protected SanctionsScreeningLog() {}
|
||||
|
||||
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
||||
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
this.clientId = clientId;
|
||||
this.ipAddress = ipAddress;
|
||||
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
// A hit opens a case; a clear screening has no case to close.
|
||||
this.disposition = RESULT_FLAGGED.equals(screeningResult) ? DISPOSITION_PENDING : null;
|
||||
}
|
||||
|
||||
// ── Alert lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records the officer's assessment of the hit. Deliberately does NOT clear the block: a
|
||||
* false positive only takes effect once {@link #approve(Long)} is called by the Principal
|
||||
* Officer, so the person who raised it cannot wave their own match through.
|
||||
*/
|
||||
public void recordDisposition(final String newDisposition, final String reason, final Long userId) {
|
||||
this.disposition = newDisposition;
|
||||
this.dispositionReason = reason;
|
||||
this.reviewedBy = userId;
|
||||
this.reviewedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
// Re-opening a previously approved alert must drop the old approval with it.
|
||||
this.approvedBy = null;
|
||||
this.approvedOn = null;
|
||||
}
|
||||
|
||||
public void approve(final Long approverId) {
|
||||
this.approvedBy = approverId;
|
||||
this.approvedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
|
||||
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
|
||||
if (strReference != null) {
|
||||
this.fiuStrReference = strReference;
|
||||
// Recording the FIU-IND acknowledgement is what closes the filing obligation, so the
|
||||
// case stops appearing as outstanding on the FIU screen.
|
||||
if (!strReference.isBlank()) {
|
||||
this.strStatus = STR_FILED;
|
||||
}
|
||||
}
|
||||
if (nodalOfficerInformedOn != null) {
|
||||
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||
}
|
||||
}
|
||||
|
||||
public void recordFreeze(final String accountIds) {
|
||||
this.frozenAccountIds = accountIds;
|
||||
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the STR obligation on approval of a confirmed match. PMLA allows seven working days
|
||||
* from forming the suspicion; seven calendar days is the stricter reading.
|
||||
*/
|
||||
public void raiseStr() {
|
||||
if (this.strStatus != null) {
|
||||
return;
|
||||
}
|
||||
this.strStatus = STR_PENDING_FILING;
|
||||
this.strRaisedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||
this.strDueDate = DateUtils.getBusinessLocalDate().plusDays(7);
|
||||
}
|
||||
|
||||
public boolean isStrPending() { return STR_PENDING_FILING.equals(this.strStatus); }
|
||||
|
||||
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
|
||||
|
||||
public boolean isApprovedFalsePositive() {
|
||||
return DISPOSITION_FALSE_POSITIVE.equals(this.disposition) && this.approvedBy != null;
|
||||
}
|
||||
|
||||
public boolean isConfirmedMatch() { return DISPOSITION_CONFIRMED.equals(this.disposition); }
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||
|
||||
public String getScreenedName() { return screenedName; }
|
||||
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
||||
public void setClientId(Long clientId) { this.clientId = clientId; }
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
||||
public String getDisposition() { return disposition; }
|
||||
public String getDispositionReason() { return dispositionReason; }
|
||||
public Long getReviewedBy() { return reviewedBy; }
|
||||
public OffsetDateTime getReviewedOn() { return reviewedOn; }
|
||||
public Long getApprovedBy() { return approvedBy; }
|
||||
public OffsetDateTime getApprovedOn() { return approvedOn; }
|
||||
public String getFiuStrReference() { return fiuStrReference; }
|
||||
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||
public String getStrStatus() { return strStatus; }
|
||||
public java.time.LocalDate getStrDueDate() { return strDueDate; }
|
||||
public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
|
||||
}
|
||||
|
||||
+28
@@ -39,4 +39,32 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
|
||||
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
|
||||
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findByDisposition(@Param("disposition") String disposition);
|
||||
|
||||
/** Every open alert — the Principal Officer's review queue. */
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition IS NOT NULL ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findAllAlerts();
|
||||
|
||||
/**
|
||||
* A name is only cleared to onboard once a false positive on that exact name has been
|
||||
* approved by the Principal Officer — the maker's own assessment is not enough.
|
||||
*/
|
||||
@Query("SELECT COUNT(l) FROM SanctionsScreeningLog l WHERE LOWER(TRIM(l.screenedName)) = LOWER(TRIM(:name)) "
|
||||
+ "AND l.disposition = 'FALSE_POSITIVE' AND l.approvedBy IS NOT NULL")
|
||||
long countApprovedFalsePositives(@Param("name") String name);
|
||||
|
||||
/** Latest alert for a client, used to decide whether their accounts must stay frozen. */
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
|
||||
+ "ORDER BY l.screenedOn DESC")
|
||||
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
|
||||
|
||||
/**
|
||||
* Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
|
||||
* Pass null for every STR regardless of whether it has been filed.
|
||||
*/
|
||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
|
||||
+ "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
|
||||
List<SanctionsScreeningLog> findStrCases(@Param("status") String status);
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+10
-1
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.contentstore.policy;
|
||||
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.Strings;
|
||||
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public class MimeContentPolicy implements ContentPolicy {
|
||||
|
||||
// CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately
|
||||
// detects it as text/plain even though it was uploaded (and named) as text/csv.
|
||||
private static final Set<String> INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain");
|
||||
|
||||
private final ContentDetectorManager contentDetectorManager;
|
||||
|
||||
@Override
|
||||
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
|
||||
var result = contentDetectorManager
|
||||
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
|
||||
|
||||
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) {
|
||||
boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType())
|
||||
|| (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType())
|
||||
&& INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType()));
|
||||
|
||||
if (!matches) {
|
||||
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
|
||||
result.getMimeType(), ctx.getMimeType()));
|
||||
}
|
||||
|
||||
+29
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
|
||||
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
|
||||
private static final int MAX_CAUSE_DEPTH = 10;
|
||||
|
||||
@Deprecated
|
||||
private final List<EntityImageIdAdapter> imageIdAdapters;
|
||||
private final ContentStoreService storeService;
|
||||
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
||||
} catch (final AbstractPlatformDomainRuleException e) {
|
||||
throw e;
|
||||
} catch (final Exception e) {
|
||||
if (isMissingFromContentStore(e)) {
|
||||
// The m_image row survives but the file it points at does not — the usual cause is a
|
||||
// database copied between environments without its file store. For the caller that is
|
||||
// simply "this entity has no image", not a server fault, so report it the same way as a
|
||||
// missing image record instead of letting it surface as a 500.
|
||||
log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType,
|
||||
entityId);
|
||||
throw new DocumentNotFoundException(entityType, entityId, -1L);
|
||||
}
|
||||
throw new DocumentInvalidRequestException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
|
||||
* way up (the file store rethrows as ContentStoreException).
|
||||
*/
|
||||
private boolean isMissingFromContentStore(final Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
|
||||
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+20
@@ -1466,6 +1466,26 @@ public class LoanProductData implements Serializable {
|
||||
this.allowAttributeOverrides = loanProductConfigurableAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy "Account Type Creation" fields, flat and keyed by their form field name.
|
||||
*
|
||||
* Set after construction rather than through a constructor parameter: the main constructor already
|
||||
* takes over a hundred arguments, and threading two more through it would touch every call site for
|
||||
* no benefit. Follows the same pattern as setLoanProductConfigurableAttributes above.
|
||||
*/
|
||||
private Map<String, Object> cbsExtensions;
|
||||
|
||||
/** The legacy Interest Rate Detail grid: one entry per interest type, in form order. */
|
||||
private List<Map<String, Object>> interestRateDetail;
|
||||
|
||||
public void setCbsExtensions(final Map<String, Object> cbsExtensions) {
|
||||
this.cbsExtensions = cbsExtensions;
|
||||
}
|
||||
|
||||
public void setInterestRateDetail(final List<Map<String, Object>> interestRateDetail) {
|
||||
this.interestRateDetail = interestRateDetail;
|
||||
}
|
||||
|
||||
public boolean isIsLinkedToFloatingInterestRates() {
|
||||
return this.isLinkedToFloatingInterestRates;
|
||||
}
|
||||
|
||||
+740
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* The legacy "Account Type Creation" configuration for a loan product.
|
||||
*
|
||||
* Fineract's loan product schema has no home for these fields, and they are specific to this
|
||||
* deployment, so they hang off {@link LoanProduct} 1:1 instead of widening m_product_loan - an
|
||||
* upstream change to that table can then never collide with them.
|
||||
*
|
||||
* Loan account creation reads this at origination time, which is why these are typed columns rather
|
||||
* than a JSON blob: callers get a compile-checked getter and reports get a plain column.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "m_product_loan_cbs_ext")
|
||||
public class LoanProductCbsExt {
|
||||
|
||||
@Id
|
||||
@Column(name = "loan_product_id")
|
||||
private Long loanProductId;
|
||||
|
||||
/**
|
||||
* Shared primary key: this and {@link #loanProductId} are the same column, so the association is
|
||||
* mapped read-only and {@code loanProductId} is the one that writes it. EclipseLink rejects an
|
||||
* entity with two writable mappings for one field.
|
||||
*/
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
|
||||
private LoanProduct loanProduct;
|
||||
|
||||
// ---- classification & legacy header fields (24) ---------------------
|
||||
|
||||
@Column(name = "product_type_group")
|
||||
private String productTypeGroup;
|
||||
|
||||
@Column(name = "product_type_sub_group")
|
||||
private String productTypeSubGroup;
|
||||
|
||||
@Column(name = "debit_int_calculation")
|
||||
private Boolean debitIntCalculation;
|
||||
|
||||
@Column(name = "separate_penal_interest")
|
||||
private Boolean separatePenalInterest;
|
||||
|
||||
@Column(name = "credit_int_calculation")
|
||||
private Boolean creditIntCalculation;
|
||||
|
||||
@Column(name = "sub_account_type_balance")
|
||||
private Boolean subAccountTypeBalance;
|
||||
|
||||
@Column(name = "final_int_rounding")
|
||||
private Boolean finalIntRounding;
|
||||
|
||||
@Column(name = "rounding_up_to")
|
||||
private Integer roundingUpTo;
|
||||
|
||||
@Column(name = "account_type_code")
|
||||
private String accountTypeCode;
|
||||
|
||||
@Column(name = "micr_transaction_code")
|
||||
private String micrTransactionCode;
|
||||
|
||||
@Column(name = "reserved_ecs_serial_number")
|
||||
private String reservedEcsSerialNumber;
|
||||
|
||||
@Column(name = "reverse_ecs_number")
|
||||
private String reverseEcsNumber;
|
||||
|
||||
@Column(name = "lock_cheque_info_enabled")
|
||||
private Boolean lockChequeInfoEnabled;
|
||||
|
||||
@Column(name = "lock_page_two_info_enabled")
|
||||
private Boolean lockPageTwoInfoEnabled;
|
||||
|
||||
@Column(name = "computerization_date")
|
||||
private LocalDate computerizationDate;
|
||||
|
||||
@Column(name = "cheque_print_description")
|
||||
private String chequePrintDescription;
|
||||
|
||||
@Column(name = "pos_account_type_description")
|
||||
private String posAccountTypeDescription;
|
||||
|
||||
@Column(name = "pfms_account_type")
|
||||
private String pfmsAccountType;
|
||||
|
||||
@Column(name = "renewal_date_add_months")
|
||||
private Integer renewalDateAddMonths;
|
||||
|
||||
@Column(name = "document_renewal_date_add_months")
|
||||
private Integer documentRenewalDateAddMonths;
|
||||
|
||||
@Column(name = "pps_limit")
|
||||
private BigDecimal ppsLimit;
|
||||
|
||||
@Column(name = "is_write_off_account_type")
|
||||
private Boolean isWriteOffAccountType;
|
||||
|
||||
@Column(name = "write_off_account_type_code")
|
||||
private String writeOffAccountTypeCode;
|
||||
|
||||
@Column(name = "renewal_grace_days")
|
||||
private Integer renewalGraceDays;
|
||||
|
||||
// ---- Cheque Book Settings (10) --------------------------------------
|
||||
|
||||
@Column(name = "cheque_book_issue_enabled")
|
||||
private Boolean chequeBookIssueEnabled;
|
||||
|
||||
@Column(name = "cheque_validation_required")
|
||||
private Boolean chequeValidationRequired;
|
||||
|
||||
@Column(name = "is_first_cheque_book_free")
|
||||
private Boolean isFirstChequeBookFree;
|
||||
|
||||
@Column(name = "cheque_printing_account_type")
|
||||
private String chequePrintingAccountType;
|
||||
|
||||
@Column(name = "allow_withdrawal_slip_usage")
|
||||
private Boolean allowWithdrawalSlipUsage;
|
||||
|
||||
@Column(name = "maintain_cheque_book_stock_registry")
|
||||
private Boolean maintainChequeBookStockRegistry;
|
||||
|
||||
@Column(name = "free_cheque_books_per_year")
|
||||
private Integer freeChequeBooksPerYear;
|
||||
|
||||
@Column(name = "allow_credit_by_cheque_number")
|
||||
private Boolean allowCreditByChequeNumber;
|
||||
|
||||
@Column(name = "credit_cheque_validation_required")
|
||||
private Boolean creditChequeValidationRequired;
|
||||
|
||||
@Column(name = "grace_days_for_stock_statement")
|
||||
private Integer graceDaysForStockStatement;
|
||||
|
||||
// ---- Additional Product Settings (37) -------------------------------
|
||||
|
||||
@Column(name = "account_type_active_for_transactions")
|
||||
private Boolean accountTypeActiveForTransactions;
|
||||
|
||||
@Column(name = "sub_type_activity_enabled")
|
||||
private Boolean subTypeActivityEnabled;
|
||||
|
||||
@Column(name = "account_opening_charges")
|
||||
private BigDecimal accountOpeningCharges;
|
||||
|
||||
@Column(name = "maximum_gold_loan_limit")
|
||||
private BigDecimal maximumGoldLoanLimit;
|
||||
|
||||
@Column(name = "last_account_number_opened")
|
||||
private String lastAccountNumberOpened;
|
||||
|
||||
@Column(name = "customer_termination_allowed")
|
||||
private Boolean customerTerminationAllowed;
|
||||
|
||||
@Column(name = "charges_for_cash_credit_above_limit")
|
||||
private BigDecimal chargesForCashCreditAboveLimit;
|
||||
|
||||
@Column(name = "death_interest_gl_account")
|
||||
private String deathInterestGLAccount;
|
||||
|
||||
@Column(name = "starting_ledger_number")
|
||||
private Integer startingLedgerNumber;
|
||||
|
||||
@Column(name = "ending_ledger_number")
|
||||
private Integer endingLedgerNumber;
|
||||
|
||||
@Column(name = "current_ledger_number")
|
||||
private Integer currentLedgerNumber;
|
||||
|
||||
@Column(name = "remittance_account_number")
|
||||
private String remittanceAccountNumber;
|
||||
|
||||
@Column(name = "is_non_resident_indian_account")
|
||||
private Boolean isNonResidentIndianAccount;
|
||||
|
||||
@Column(name = "debit_clearing_gl_account")
|
||||
private String debitClearingGLAccount;
|
||||
|
||||
@Column(name = "missed_call_alert_service_active")
|
||||
private Boolean missedCallAlertServiceActive;
|
||||
|
||||
@Column(name = "standing_instruction_charge_gl_account")
|
||||
private String standingInstructionChargeGLAccount;
|
||||
|
||||
@Column(name = "standing_instruction_debit_charge_skip_option")
|
||||
private String standingInstructionDebitChargeSkipOption;
|
||||
|
||||
@Column(name = "opening_balance_multiplier_for_remittance_charges")
|
||||
private BigDecimal openingBalanceMultiplierForRemittanceCharges;
|
||||
|
||||
@Column(name = "gold_loan_packet_number_with_year_enabled")
|
||||
private Boolean goldLoanPacketNumberWithYearEnabled;
|
||||
|
||||
@Column(name = "account_move_to_inoperative_enabled")
|
||||
private Boolean accountMoveToInoperativeEnabled;
|
||||
|
||||
@Column(name = "generate_gl_diff_entry_enabled")
|
||||
private Boolean generateGlDiffEntryEnabled;
|
||||
|
||||
@Column(name = "sub_type_wise_gl_balance_generation_enabled")
|
||||
private Boolean subTypeWiseGlBalanceGenerationEnabled;
|
||||
|
||||
@Column(name = "validate_before_deleting_debit_entry_enabled")
|
||||
private Boolean validateBeforeDeletingDebitEntryEnabled;
|
||||
|
||||
@Column(name = "rc_file_concept_enabled")
|
||||
private Boolean rcFileConceptEnabled;
|
||||
|
||||
@Column(name = "account_close_zero_balance_in_open_day_process")
|
||||
private Boolean accountCloseZeroBalanceInOpenDayProcess;
|
||||
|
||||
@Column(name = "account_close_zero_balance_at_subledger_tran_entry")
|
||||
private Boolean accountCloseZeroBalanceAtSubledgerTranEntry;
|
||||
|
||||
@Column(name = "limit_enhancement_against_clearing_screen_display_enabled")
|
||||
private Boolean limitEnhancementAgainstClearingScreenDisplayEnabled;
|
||||
|
||||
@Column(name = "advance_interest_parameter_in_ledger_enabled")
|
||||
private Boolean advanceInterestParameterInLedgerEnabled;
|
||||
|
||||
@Column(name = "vehicle_detail_in_master_enabled")
|
||||
private Boolean vehicleDetailInMasterEnabled;
|
||||
|
||||
@Column(name = "deleted_cheque_issue_enabled")
|
||||
private Boolean deletedChequeIssueEnabled;
|
||||
|
||||
@Column(name = "dp_reduce_in_document_renewal_date_enabled")
|
||||
private Boolean dpReduceInDocumentRenewalDateEnabled;
|
||||
|
||||
@Column(name = "dp_reduce_in_renewal_date_enabled")
|
||||
private Boolean dpReduceInRenewalDateEnabled;
|
||||
|
||||
@Column(name = "auto_close_at_day_end_enabled")
|
||||
private Boolean autoCloseAtDayEndEnabled;
|
||||
|
||||
@Column(name = "loan_rate_from_master_enabled")
|
||||
private Boolean loanRateFromMasterEnabled;
|
||||
|
||||
@Column(name = "cheque_short_name")
|
||||
private String chequeShortName;
|
||||
|
||||
@Column(name = "statement_print_enabled")
|
||||
private Boolean statementPrintEnabled;
|
||||
|
||||
@Column(name = "nesl_code")
|
||||
private String neslCode;
|
||||
|
||||
// ---- Interest Related Parameters (53) -------------------------------
|
||||
|
||||
@Column(name = "drawing_power_and_limit_generation_procedure")
|
||||
private String drawingPowerAndLimitGenerationProcedure;
|
||||
|
||||
@Column(name = "advances_interest_calculation_method")
|
||||
private String advancesInterestCalculationMethod;
|
||||
|
||||
@Column(name = "against_interest_calculation_enabled")
|
||||
private Boolean againstInterestCalculationEnabled;
|
||||
|
||||
@Column(name = "day_wise_against_clearing_interest_rounding")
|
||||
private Boolean dayWiseAgainstClearingInterestRounding;
|
||||
|
||||
@Column(name = "interest_calculation_one_fourth_method")
|
||||
private String interestCalculationOneFourthMethod;
|
||||
|
||||
@Column(name = "against_clearing_interest_procedure_type")
|
||||
private String againstClearingInterestProcedureType;
|
||||
|
||||
@Column(name = "consider_debit_closing_amount_for_against_clearing")
|
||||
private Boolean considerDebitClosingAmountForAgainstClearing;
|
||||
|
||||
@Column(name = "interest_rounding_method")
|
||||
private String interestRoundingMethod;
|
||||
|
||||
@Column(name = "penalty_interest_calculation_enabled")
|
||||
private Boolean penaltyInterestCalculationEnabled;
|
||||
|
||||
@Column(name = "penalty_interest_calculation_method")
|
||||
private String penaltyInterestCalculationMethod;
|
||||
|
||||
@Column(name = "penalty_interest_procedure_number")
|
||||
private String penaltyInterestProcedureNumber;
|
||||
|
||||
@Column(name = "overdrawn_interest_calculation_method")
|
||||
private String overdrawnInterestCalculationMethod;
|
||||
|
||||
@Column(name = "calculate_overdrawn_interest_after_renewal")
|
||||
private Boolean calculateOverdrawnInterestAfterRenewal;
|
||||
|
||||
@Column(name = "interest_on_credit_balance")
|
||||
private Boolean interestOnCreditBalance;
|
||||
|
||||
@Column(name = "penalty_interest_date_different_than_regular_interest")
|
||||
private Boolean penaltyInterestDateDifferentThanRegularInterest;
|
||||
|
||||
@Column(name = "fixed_deposit_overdraft_certificate_sort_order")
|
||||
private String fixedDepositOverdraftCertificateSortOrder;
|
||||
|
||||
@Column(name = "use_document_submission_date_for_interest")
|
||||
private Boolean useDocumentSubmissionDateForInterest;
|
||||
|
||||
@Column(name = "loan_management_system_renewal_penalty_active")
|
||||
private Boolean loanManagementSystemRenewalPenaltyActive;
|
||||
|
||||
@Column(name = "minimum_normal_interest_amount")
|
||||
private BigDecimal minimumNormalInterestAmount;
|
||||
|
||||
@Column(name = "minimum_interest_calculation_method")
|
||||
private String minimumInterestCalculationMethod;
|
||||
|
||||
@Column(name = "minimum_against_clearing_interest_amount")
|
||||
private BigDecimal minimumAgainstClearingInterestAmount;
|
||||
|
||||
@Column(name = "limit_increase_upto_interest_amount_in_interest_calculation")
|
||||
private Boolean limitIncreaseUptoInterestAmountInInterestCalculation;
|
||||
|
||||
@Column(name = "minimum_penalty_interest_amount")
|
||||
private BigDecimal minimumPenaltyInterestAmount;
|
||||
|
||||
@Column(name = "days_for_limit_increase_upto_interest_amount")
|
||||
private Integer daysForLimitIncreaseUptoInterestAmount;
|
||||
|
||||
@Column(name = "additional_interest_rate_for_fixed_deposit_overdraft")
|
||||
private BigDecimal additionalInterestRateForFixedDepositOverdraft;
|
||||
|
||||
@Column(name = "consider_dd_payorder_in_against_clearing_interest_calculation")
|
||||
private Boolean considerDdPayorderInAgainstClearingInterestCalculation;
|
||||
|
||||
@Column(name = "grace_days_after_renew_date")
|
||||
private Integer graceDaysAfterRenewDate;
|
||||
|
||||
@Column(name = "apply_undebited_interest_concept_enabled")
|
||||
private Boolean applyUndebitedInterestConceptEnabled;
|
||||
|
||||
@Column(name = "interest_register_print_format_type")
|
||||
private String interestRegisterPrintFormatType;
|
||||
|
||||
@Column(name = "normal_grace_days_after_overdue")
|
||||
private Integer normalGraceDaysAfterOverdue;
|
||||
|
||||
@Column(name = "allow_against_clearing_entry")
|
||||
private Boolean allowAgainstClearingEntry;
|
||||
|
||||
@Column(name = "allow_no_balance_entry")
|
||||
private Boolean allowNoBalanceEntry;
|
||||
|
||||
@Column(name = "fd_od_penalty_interest_combined_with_normal_interest")
|
||||
private Boolean fixedDepositOverdraftPenaltyInterestCombinedWithNormalInterest;
|
||||
|
||||
@Column(name = "allow_no_balance_entry_with_password")
|
||||
private Boolean allowNoBalanceEntryWithPassword;
|
||||
|
||||
@Column(name = "undebited_simple_interest_account_wise")
|
||||
private Boolean undebitedSimpleInterestAccountWise;
|
||||
|
||||
@Column(name = "daily_interest_calculation_enabled")
|
||||
private Boolean dailyInterestCalculationEnabled;
|
||||
|
||||
@Column(name = "daily_interest_calculation_start_date")
|
||||
private String dailyInterestCalculationStartDate;
|
||||
|
||||
@Column(name = "non_performing_asset_penal_interest_rate_update")
|
||||
private Boolean nonPerformingAssetPenalInterestRateUpdate;
|
||||
|
||||
@Column(name = "non_performing_asset_penal_interest_rate")
|
||||
private BigDecimal nonPerformingAssetPenalInterestRate;
|
||||
|
||||
@Column(name = "fd_od_penal_interest_rate_update_in_branch_process")
|
||||
private Boolean fixedDepositOverdraftPenalInterestRateUpdateInBranchProcess;
|
||||
|
||||
@Column(name = "add_penal_interest_rate")
|
||||
private BigDecimal addPenalInterestRate;
|
||||
|
||||
@Column(name = "atyp14_simple_interest_enabled")
|
||||
private Boolean atyp14SimpleInterestEnabled;
|
||||
|
||||
@Column(name = "fixed_deposit_overdraft_margin_type")
|
||||
private String fixedDepositOverdraftMarginType;
|
||||
|
||||
@Column(name = "check_master_detail_edited_passing_pending_enabled")
|
||||
private Boolean checkMasterDetailEditedPassingPendingEnabled;
|
||||
|
||||
@Column(name = "credit_debit_interest_calculation_separate_enabled")
|
||||
private Boolean creditDebitInterestCalculationSeparateEnabled;
|
||||
|
||||
@Column(name = "on_time_interest_provision_enabled")
|
||||
private Boolean onTimeInterestProvisionEnabled;
|
||||
|
||||
@Column(name = "check_against_clearing_enabled")
|
||||
private Boolean checkAgainstClearingEnabled;
|
||||
|
||||
@Column(name = "gst_on_penalty_enabled")
|
||||
private Boolean gstOnPenaltyEnabled;
|
||||
|
||||
@Column(name = "penalty_interest_grace_days")
|
||||
private Integer penaltyInterestGraceDays;
|
||||
|
||||
@Column(name = "overdrawn_interest_rate")
|
||||
private BigDecimal overdrawnInterestRate;
|
||||
|
||||
@Column(name = "calculate_daily_limit_enhancement_tod_enabled")
|
||||
private Boolean calculateDailyLimitEnhancementTodEnabled;
|
||||
|
||||
@Column(name = "fixed_deposit_overdraft_multiple_interest_rate_enabled")
|
||||
private Boolean fixedDepositOverdraftMultipleInterestRateEnabled;
|
||||
|
||||
@Column(name = "grace_days_after_overdue_for_moratorium")
|
||||
private Integer graceDaysAfterOverdueForMoratorium;
|
||||
|
||||
// ---- Credit Bureau Settings (CIBIL) (8) ----------------------------
|
||||
|
||||
@Column(name = "cibil_account_type")
|
||||
private String cibilAccountType;
|
||||
|
||||
@Column(name = "cibil_commercial_account_type")
|
||||
private String cibilCommercialAccountType;
|
||||
|
||||
@Column(name = "highmark_account_type")
|
||||
private String highmarkAccountType;
|
||||
|
||||
@Column(name = "highmark_commercial_account_type")
|
||||
private String highmarkCommercialAccountType;
|
||||
|
||||
@Column(name = "experian_account_type")
|
||||
private String experianAccountType;
|
||||
|
||||
@Column(name = "experian_commercial_account_type")
|
||||
private String experianCommercialAccountType;
|
||||
|
||||
@Column(name = "equifax_account_type")
|
||||
private String equifaxAccountType;
|
||||
|
||||
@Column(name = "equifax_commercial_account_type")
|
||||
private String equifaxCommercialAccountType;
|
||||
|
||||
// ---- RBI Compliance & Product Category (6) -------------------------
|
||||
|
||||
@Column(name = "loan_product_category")
|
||||
private String loanProductCategory;
|
||||
|
||||
@Column(name = "penal_charge_method")
|
||||
private String penalChargeMethod;
|
||||
|
||||
@Column(name = "is_floating_rate")
|
||||
private Boolean isFloatingRate;
|
||||
|
||||
@Column(name = "foreclosure_charges_applicable")
|
||||
private Boolean foreclosureChargesApplicable;
|
||||
|
||||
@Column(name = "key_fact_statement_enabled")
|
||||
private Boolean keyFactStatementEnabled;
|
||||
|
||||
@Column(name = "annual_percentage_rate_calculation_enabled")
|
||||
private Boolean annualPercentageRateCalculationEnabled;
|
||||
|
||||
// ---- Channel & Transaction Limits (23) ------------------------------
|
||||
|
||||
@Column(name = "is_mobile_app_enabled")
|
||||
private Boolean isMobileAppEnabled;
|
||||
|
||||
@Column(name = "is_atm_enabled")
|
||||
private Boolean isAtmEnabled;
|
||||
|
||||
@Column(name = "atm_yearly_charge_enabled")
|
||||
private Boolean atmYearlyChargeEnabled;
|
||||
|
||||
@Column(name = "atm_minimum_balance")
|
||||
private BigDecimal atmMinimumBalance;
|
||||
|
||||
@Column(name = "email_active_enabled")
|
||||
private Boolean emailActiveEnabled;
|
||||
|
||||
@Column(name = "is_imps_enabled")
|
||||
private Boolean isImpsEnabled;
|
||||
|
||||
@Column(name = "is_upi_enabled")
|
||||
private Boolean isUpiEnabled;
|
||||
|
||||
@Column(name = "is_pos_enabled")
|
||||
private Boolean isPosEnabled;
|
||||
|
||||
@Column(name = "is_bbps_enabled")
|
||||
private Boolean isBbpsEnabled;
|
||||
|
||||
@Column(name = "inter_bank_limit_per_day")
|
||||
private BigDecimal interBankLimitPerDay;
|
||||
|
||||
@Column(name = "inter_bank_limit_per_transaction")
|
||||
private BigDecimal interBankLimitPerTransaction;
|
||||
|
||||
@Column(name = "neft_rtgs_limit_per_day")
|
||||
private BigDecimal neftRtgsLimitPerDay;
|
||||
|
||||
@Column(name = "neft_rtgs_limit_per_transaction")
|
||||
private BigDecimal neftRtgsLimitPerTransaction;
|
||||
|
||||
@Column(name = "neft_cutoff_limit")
|
||||
private BigDecimal neftCutoffLimit;
|
||||
|
||||
@Column(name = "rtgs_cutoff_limit")
|
||||
private BigDecimal rtgsCutoffLimit;
|
||||
|
||||
@Column(name = "neft_rtgs_minimum_balance")
|
||||
private BigDecimal neftRtgsMinimumBalance;
|
||||
|
||||
@Column(name = "is_charge_required_neft_rtgs_cbs")
|
||||
private Boolean isChargeRequiredNeftRtgsCbs;
|
||||
|
||||
@Column(name = "is_charge_required_neft_rtgs_mobile")
|
||||
private Boolean isChargeRequiredNeftRtgsMobile;
|
||||
|
||||
@Column(name = "imps_limit_per_day")
|
||||
private BigDecimal impsLimitPerDay;
|
||||
|
||||
@Column(name = "imps_limit_per_transaction")
|
||||
private BigDecimal impsLimitPerTransaction;
|
||||
|
||||
@Column(name = "imps_limit_per_month")
|
||||
private BigDecimal impsLimitPerMonth;
|
||||
|
||||
@Column(name = "imps_min_limit")
|
||||
private BigDecimal impsMinLimit;
|
||||
|
||||
@Column(name = "imps_monthly_transaction_count")
|
||||
private Integer impsMonthlyTransactionCount;
|
||||
|
||||
// ---- POS Facility & ATM CSV Settings (10) ---------------------------
|
||||
|
||||
@Column(name = "max_per_transaction_limit_atm")
|
||||
private BigDecimal maxPerTransactionLimitAtm;
|
||||
|
||||
@Column(name = "max_day_transaction_limit_atm")
|
||||
private BigDecimal maxDayTransactionLimitAtm;
|
||||
|
||||
@Column(name = "max_per_transaction_limit_pos")
|
||||
private BigDecimal maxPerTransactionLimitPos;
|
||||
|
||||
@Column(name = "max_day_transaction_limit_pos")
|
||||
private BigDecimal maxDayTransactionLimitPos;
|
||||
|
||||
@Column(name = "max_per_transaction_limit_ecom")
|
||||
private BigDecimal maxPerTransactionLimitEcom;
|
||||
|
||||
@Column(name = "max_day_transaction_limit_ecom")
|
||||
private BigDecimal maxDayTransactionLimitEcom;
|
||||
|
||||
@Column(name = "max_per_transaction_limit_upi")
|
||||
private BigDecimal maxPerTransactionLimitUpi;
|
||||
|
||||
@Column(name = "max_day_transaction_limit_upi")
|
||||
private BigDecimal maxDayTransactionLimitUpi;
|
||||
|
||||
@Column(name = "atm_csv_product_code")
|
||||
private String atmCsvProductCode;
|
||||
|
||||
@Column(name = "atm_csv_type_code")
|
||||
private String atmCsvTypeCode;
|
||||
|
||||
// ---- Auto Interest Post Parameters (4) -----------------------------
|
||||
|
||||
@Column(name = "auto_interest_post_type")
|
||||
private String autoInterestPostType;
|
||||
|
||||
@Column(name = "auto_interest_post_when")
|
||||
private String autoInterestPostWhen;
|
||||
|
||||
@Column(name = "auto_interest_post_fix_day")
|
||||
private Integer autoInterestPostFixDay;
|
||||
|
||||
@Column(name = "auto_last_interest_date")
|
||||
private String autoLastInterestDate;
|
||||
|
||||
// ---- Foreclosure / Early Account Closure (3) -----------------------
|
||||
|
||||
@Column(name = "calculate_early_account_close_charge")
|
||||
private Boolean calculateEarlyAccountCloseCharge;
|
||||
|
||||
@Column(name = "early_account_close_charge")
|
||||
private BigDecimal earlyAccountCloseCharge;
|
||||
|
||||
@Column(name = "early_account_close_period_in_months")
|
||||
private Integer earlyAccountClosePeriodInMonths;
|
||||
|
||||
// ---- Terms step (3) ------------------------------------------------
|
||||
|
||||
@Column(name = "debit_balance_allow")
|
||||
private Boolean debitBalanceAllow;
|
||||
|
||||
@Column(name = "allow_debit_trans_after_due_date")
|
||||
private Boolean allowDebitTransAfterDueDate;
|
||||
|
||||
@Column(name = "branch_level_account_type")
|
||||
private Boolean branchLevelAccountType;
|
||||
|
||||
// ---- legacy cheque / standing instruction / minimum balance charges (24)
|
||||
|
||||
@Column(name = "cheque_book_charge_enabled")
|
||||
private Boolean chequeBookChargeEnabled;
|
||||
|
||||
@Column(name = "cheque_return_charge_enabled")
|
||||
private Boolean chequeReturnChargeEnabled;
|
||||
|
||||
@Column(name = "ic_charge_debit_enabled")
|
||||
private Boolean icChargeDebitEnabled;
|
||||
|
||||
@Column(name = "cash_bundle_charge_enabled")
|
||||
private Boolean cashBundleChargeEnabled;
|
||||
|
||||
@Column(name = "cash_bundle_charge_with_pan_limit")
|
||||
private BigDecimal cashBundleChargeWithPanLimit;
|
||||
|
||||
@Column(name = "per_cheque_entry_charge")
|
||||
private BigDecimal perChequeEntryCharge;
|
||||
|
||||
@Column(name = "per_cheque_entry_service_charge")
|
||||
private BigDecimal perChequeEntryServiceCharge;
|
||||
|
||||
@Column(name = "per_inward_cheque_entry_charge")
|
||||
private BigDecimal perInwardChequeEntryCharge;
|
||||
|
||||
@Column(name = "per_outward_cheque_entry_charge")
|
||||
private BigDecimal perOutwardChequeEntryCharge;
|
||||
|
||||
@Column(name = "creditor_standing_instruction_fail_charge")
|
||||
private BigDecimal creditorStandingInstructionFailCharge;
|
||||
|
||||
@Column(name = "debtor_standing_instruction_fail_charge")
|
||||
private BigDecimal debtorStandingInstructionFailCharge;
|
||||
|
||||
@Column(name = "minimum_balance_checking_type")
|
||||
private String minimumBalanceCheckingType;
|
||||
|
||||
@Column(name = "auto_minimum_balance_charge")
|
||||
private Boolean autoMinimumBalanceCharge;
|
||||
|
||||
@Column(name = "calculate_daily_minimum_balance_charges")
|
||||
private Boolean calculateDailyMinimumBalanceCharges;
|
||||
|
||||
@Column(name = "minimum_balance_charge_enabled")
|
||||
private Boolean minimumBalanceChargeEnabled;
|
||||
|
||||
@Column(name = "minimum_balance_charge_amount")
|
||||
private BigDecimal minimumBalanceChargeAmount;
|
||||
|
||||
@Column(name = "minimum_balance_charge_for_cheque_issue")
|
||||
private BigDecimal minimumBalanceChargeForChequeIssue;
|
||||
|
||||
@Column(name = "minimum_balance_charge_method")
|
||||
private String minimumBalanceChargeMethod;
|
||||
|
||||
@Column(name = "months_after_min_balance_charge_calculate")
|
||||
private Integer monthsAfterMinBalanceChargeCalculate;
|
||||
|
||||
@Column(name = "for_cheque_issue")
|
||||
private BigDecimal forChequeIssue;
|
||||
|
||||
@Column(name = "for_cheque_issue_avg")
|
||||
private BigDecimal forChequeIssueAvg;
|
||||
|
||||
@Column(name = "for_withdrawal_slip")
|
||||
private BigDecimal forWithdrawalSlip;
|
||||
|
||||
@Column(name = "for_withdrawal_slip_avg")
|
||||
private BigDecimal forWithdrawalSlipAvg;
|
||||
|
||||
@Column(name = "calculate_daily_against_clearing_charge")
|
||||
private Boolean calculateDailyAgainstClearingCharge;
|
||||
|
||||
// ---- custom GL account mappings (14) --------------------------------
|
||||
|
||||
@Column(name = "int_receivables_gl_account_id")
|
||||
private Long intReceivablesGlAccountId;
|
||||
|
||||
@Column(name = "int_payable_gl_account_id")
|
||||
private Long intPayableGlAccountId;
|
||||
|
||||
@Column(name = "deaf_int_payable_gl_account_id")
|
||||
private Long deafIntPayableGlAccountId;
|
||||
|
||||
@Column(name = "int_overdue_reserve_account_id")
|
||||
private Long intOverdueReserveAccountId;
|
||||
|
||||
@Column(name = "undebited_interest_account_id")
|
||||
private Long undebitedInterestAccountId;
|
||||
|
||||
@Column(name = "penal_int_cr_gl_account_id")
|
||||
private Long penalIntCrGlAccountId;
|
||||
|
||||
@Column(name = "rd_matured_gl_account_id")
|
||||
private Long rdMaturedGlAccountId;
|
||||
|
||||
@Column(name = "gl_account_id")
|
||||
private Long glAccountId;
|
||||
|
||||
@Column(name = "ic_charge_gl_account_id")
|
||||
private Long icChargeGlAccountId;
|
||||
|
||||
@Column(name = "inoperative_gl_account_id")
|
||||
private Long inoperativeGlAccountId;
|
||||
|
||||
@Column(name = "credit_balance_liability_gl_account_id")
|
||||
private Long creditBalanceLiabilityGlAccountId;
|
||||
|
||||
@Column(name = "credit_balance_asset_gl_account_id")
|
||||
private Long creditBalanceAssetGlAccountId;
|
||||
|
||||
@Column(name = "overdue_accounts_gl_account_id")
|
||||
private Long overdueAccountsGlAccountId;
|
||||
|
||||
@Column(name = "rebate_gl_account_id")
|
||||
private Long rebateGlAccountId;
|
||||
|
||||
public LoanProductCbsExt(final LoanProduct loanProduct) {
|
||||
this.loanProduct = loanProduct;
|
||||
this.loanProductId = loanProduct.getId();
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanProductCbsExtRepository extends JpaRepository<LoanProductCbsExt, Long> {
|
||||
|
||||
/**
|
||||
* A product created before this table existed - or one saved without any legacy field filled in - has
|
||||
* no row here, so callers must handle the empty case rather than assuming one exists.
|
||||
*/
|
||||
Optional<LoanProductCbsExt> findByLoanProductId(Long loanProductId);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
/**
|
||||
* One row of the legacy "Interest Rate Detail" grid on the loan product form.
|
||||
*
|
||||
* The grid is a fixed four rows - Normal Debit, Normal Credit, Against Clearing and Penalty - each
|
||||
* carrying a Normal and a Special min/max rate plus the period they apply over. It is a child table
|
||||
* rather than twenty-four columns on {@link LoanProductCbsExt} because the interest type reads far
|
||||
* better as data than as part of a column name: against_clearing_special_max_rate and its siblings
|
||||
* are almost unreadable, and any new interest type would mean another migration.
|
||||
*
|
||||
* The rows are fixed today, so {@link #INTEREST_TYPES} is the full set and the table carries a unique
|
||||
* constraint on (product, interest type) - a replayed save updates in place instead of doubling the
|
||||
* grid.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "m_product_loan_cbs_interest_rate", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = { "loan_product_id", "interest_type" }, name = "mplcir_unq_product_interest_type") })
|
||||
public class LoanProductCbsInterestRate extends AbstractPersistableCustom<Long> {
|
||||
|
||||
/** The grid's rows, in the order the form shows them. */
|
||||
public static final String[] INTEREST_TYPES = { "Normal Debit", "Normal Credit", "Against Clearing", "Penalty" };
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "loan_product_id", nullable = false)
|
||||
private LoanProduct loanProduct;
|
||||
|
||||
@Column(name = "interest_type", nullable = false, length = 30)
|
||||
private String interestType;
|
||||
|
||||
@Column(name = "calculation")
|
||||
private Boolean calculation;
|
||||
|
||||
@Column(name = "normal_min_rate", precision = 9, scale = 6)
|
||||
private BigDecimal normalMinRate;
|
||||
|
||||
@Column(name = "normal_max_rate", precision = 9, scale = 6)
|
||||
private BigDecimal normalMaxRate;
|
||||
|
||||
@Column(name = "special_min_rate", precision = 9, scale = 6)
|
||||
private BigDecimal specialMinRate;
|
||||
|
||||
@Column(name = "special_max_rate", precision = 9, scale = 6)
|
||||
private BigDecimal specialMaxRate;
|
||||
|
||||
@Column(name = "period", length = 20)
|
||||
private String period;
|
||||
|
||||
public LoanProductCbsInterestRate(final LoanProduct loanProduct, final String interestType) {
|
||||
this.loanProduct = loanProduct;
|
||||
this.interestType = interestType;
|
||||
}
|
||||
|
||||
/** True when the officer left this row untouched, so it need not be persisted. */
|
||||
public boolean isEmpty() {
|
||||
return !Boolean.TRUE.equals(this.calculation) && this.normalMinRate == null && this.normalMaxRate == null
|
||||
&& this.specialMinRate == null && this.specialMaxRate == null && (this.period == null || this.period.isBlank());
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanproduct.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface LoanProductCbsInterestRateRepository extends JpaRepository<LoanProductCbsInterestRate, Long> {
|
||||
|
||||
List<LoanProductCbsInterestRate> findByLoanProductIdOrderById(Long loanProductId);
|
||||
|
||||
LoanProductCbsInterestRate findByLoanProductIdAndInterestType(Long loanProductId, String interestType);
|
||||
}
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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.accounting.dividendpaidreport.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* "Total Dividend Paid" report — derived entirely from the existing Dividend Payable GL
|
||||
* account's debit-side journal entries, exactly how real-world CBS/UCB systems report this (a
|
||||
* query over the ledger, not a separate "Dividend Paid" account). Dividend Payable is only ever
|
||||
* debited by an actual member payout (see ShareAccountSchedularServiceImpl.postDividend — via
|
||||
* the native DIVIDEND_PAYOUT savings transaction's accounting), so every debit entry against it
|
||||
* is, by construction, a real dividend payment.
|
||||
*/
|
||||
@Path("/v1/accounting/dividend-paid-report")
|
||||
@Component
|
||||
@Tag(name = "Dividend Paid Report", description = "Total share dividend actually paid to members in a date range, derived from the Dividend Payable GL account's ledger.")
|
||||
@RequiredArgsConstructor
|
||||
public class DividendPaidReportApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create();
|
||||
|
||||
private static final int FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS = 201;
|
||||
private static final int JOURNAL_ENTRY_TYPE_DEBIT = 2;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getDividendPaidReport(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
|
||||
final Long payableDividendsAccountId = jdbcTemplate
|
||||
.queryForList("SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ?", Long.class,
|
||||
FINANCIAL_ACTIVITY_PAYABLE_DIVIDENDS)
|
||||
.stream().findFirst().orElse(null);
|
||||
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("fromDate", fromDate);
|
||||
result.addProperty("toDate", toDate);
|
||||
if (payableDividendsAccountId == null) {
|
||||
// Dividend Payable hasn't been mapped under Accounting -> Financial Activity Mappings yet.
|
||||
result.addProperty("totalPaid", BigDecimal.ZERO);
|
||||
result.addProperty("payoutCount", 0);
|
||||
result.add("entries", new JsonArray());
|
||||
result.addProperty("glAccountConfigured", false);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
final Map<String, Object> totals = jdbcTemplate.queryForMap("""
|
||||
SELECT COALESCE(SUM(amount), 0) AS total_paid, COUNT(*) AS payout_count
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||
|
||||
final List<Map<String, Object>> entryRows = jdbcTemplate.queryForList("""
|
||||
SELECT entry_date, amount, transaction_id
|
||||
FROM acc_gl_journal_entry
|
||||
WHERE account_id = ? AND type_enum = ? AND reversed = false AND entry_date BETWEEN ? AND ?
|
||||
ORDER BY entry_date DESC
|
||||
""", payableDividendsAccountId, JOURNAL_ENTRY_TYPE_DEBIT, from, to);
|
||||
|
||||
result.addProperty("totalPaid", (BigDecimal) totals.get("total_paid"));
|
||||
result.addProperty("payoutCount", ((Number) totals.get("payout_count")).intValue());
|
||||
result.addProperty("glAccountConfigured", true);
|
||||
|
||||
final JsonArray entries = new JsonArray();
|
||||
for (final Map<String, Object> row : entryRows) {
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("entryDate", row.get("entry_date").toString());
|
||||
entry.addProperty("amount", (BigDecimal) row.get("amount"));
|
||||
entry.addProperty("transactionId", (String) row.get("transaction_id"));
|
||||
entries.add(entry);
|
||||
}
|
||||
result.add("entries", entries);
|
||||
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject errorBody = new JsonObject();
|
||||
errorBody.addProperty("message", message);
|
||||
errorBody.addProperty("defaultUserMessage", message);
|
||||
errorBody.addProperty("httpStatus", httpStatus);
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(errorBody)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+5
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
|
||||
accountMapping = paymentChannelSpecificAccountMapping;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountMapping == null) {
|
||||
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
|
||||
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
|
||||
}
|
||||
glAccount = accountMapping.getGlAccount();
|
||||
}
|
||||
return glAccount;
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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.accounting.profitandloss.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Date;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Real Profit & Loss (Net Profit) for a date range, computed live from posted General Ledger
|
||||
* journal entries — not the demo/sample data shown by the GL Reports module. Used by the Share
|
||||
* Dividend "Create Dividend" screen so a bank officer can see actual profit for the dividend
|
||||
* period and set the dividend as a percentage of it, instead of typing a total amount blind.
|
||||
*
|
||||
* Standard double-entry rule: an Income account (classification_enum=4) increases with a CREDIT
|
||||
* and decreases with a DEBIT; an Expense account (classification_enum=5) increases with a DEBIT
|
||||
* and decreases with a CREDIT. Reversed entries are excluded.
|
||||
*/
|
||||
@Path("/v1/accounting/profitandloss")
|
||||
@Component
|
||||
@Tag(name = "Profit And Loss", description = "Real net profit for a date range, computed from posted GL journal entries.")
|
||||
@RequiredArgsConstructor
|
||||
public class ProfitAndLossApiResource {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String getProfitAndLoss(@QueryParam("fromDate") final String fromDate, @QueryParam("toDate") final String toDate) {
|
||||
if (fromDate == null || fromDate.isBlank() || toDate == null || toDate.isBlank()) {
|
||||
throw apiError(400, "fromDate and toDate (yyyy-MM-dd) are both required");
|
||||
}
|
||||
final Date from;
|
||||
final Date to;
|
||||
try {
|
||||
from = Date.valueOf(fromDate);
|
||||
to = Date.valueOf(toDate);
|
||||
} catch (final IllegalArgumentException e) {
|
||||
throw apiError(400, "fromDate and toDate must be in yyyy-MM-dd format");
|
||||
}
|
||||
|
||||
final Map<String, Object> row = jdbcTemplate.queryForMap("""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 4 AND je.type_enum = 1 THEN je.amount
|
||||
WHEN a.classification_enum = 4 AND je.type_enum = 2 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_income,
|
||||
COALESCE(SUM(CASE WHEN a.classification_enum = 5 AND je.type_enum = 2 THEN je.amount
|
||||
WHEN a.classification_enum = 5 AND je.type_enum = 1 THEN -je.amount
|
||||
ELSE 0 END), 0) AS total_expense
|
||||
FROM acc_gl_journal_entry je
|
||||
JOIN acc_gl_account a ON a.id = je.account_id
|
||||
WHERE je.reversed = false
|
||||
AND je.entry_date BETWEEN ? AND ?
|
||||
""", from, to);
|
||||
|
||||
final BigDecimal totalIncome = (BigDecimal) row.get("total_income");
|
||||
final BigDecimal totalExpense = (BigDecimal) row.get("total_expense");
|
||||
final BigDecimal netProfit = totalIncome.subtract(totalExpense);
|
||||
|
||||
return gson.toJson(Map.of("fromDate", fromDate, "toDate", toDate, "totalIncome", totalIncome, "totalExpense", totalExpense,
|
||||
"netProfit", netProfit));
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
return new WebApplicationException(
|
||||
Response.status(httpStatus).entity(gson.toJson(Map.of("message", message))).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+5
@@ -154,6 +154,11 @@ public class SecurityConfig {
|
||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||
.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
|
||||
// packages here without a Fineract Bearer token; security is enforced by the
|
||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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.infrastructure.databaseexport.api;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
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 jakarta.ws.rs.core.Response;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/databaseexport")
|
||||
@Component
|
||||
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseExportApiResource {
|
||||
|
||||
private final DatabaseExportService service;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
|
||||
@GET
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List export requests, optionally filtered by status")
|
||||
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
|
||||
return this.service.retrieveAll(status);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{requestId}")
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve a single export request")
|
||||
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
|
||||
return this.service.retrieveOne(requestId);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Raise a request for a full database export")
|
||||
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
|
||||
final Long id = this.service.requestExport(reason);
|
||||
return Map.of("resourceId", id);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{requestId}/approve")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Approve an export request (admin only, never the requester)")
|
||||
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
|
||||
this.service.approve(requestId);
|
||||
return Map.of("resourceId", requestId, "status", "APPROVED");
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{requestId}/reject")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Reject an export request with a reason")
|
||||
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
|
||||
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
|
||||
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
|
||||
? json.get("rejectionReason").getAsString()
|
||||
: null;
|
||||
this.service.reject(requestId, reason);
|
||||
return Map.of("resourceId", requestId, "status", "REJECTED");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{requestId}/download")
|
||||
@Produces({ "application/zip" })
|
||||
@Operation(summary = "Download the approved export as a ZIP of CSV files")
|
||||
public Response download(@PathParam("requestId") final Long requestId) {
|
||||
final byte[] archive = this.service.generateExport(requestId);
|
||||
return Response.ok(archive)
|
||||
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
|
||||
.header("Content-Length", archive.length)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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.infrastructure.databaseexport.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||
|
||||
/**
|
||||
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
|
||||
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "m_database_export_request")
|
||||
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
|
||||
|
||||
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
|
||||
public static final String STATUS_APPROVED = "APPROVED";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
/** Approved and the dump has actually been downloaded at least once. */
|
||||
public static final String STATUS_GENERATED = "GENERATED";
|
||||
|
||||
@Column(name = "status", length = 30, nullable = false)
|
||||
private String status;
|
||||
|
||||
@Column(name = "reason", nullable = false)
|
||||
private String reason;
|
||||
|
||||
@Column(name = "requested_by", nullable = false)
|
||||
private Long requestedBy;
|
||||
|
||||
@Column(name = "requested_by_username", length = 100)
|
||||
private String requestedByUsername;
|
||||
|
||||
@Column(name = "requested_on", nullable = false)
|
||||
private LocalDateTime requestedOn;
|
||||
|
||||
@Column(name = "approved_by")
|
||||
private Long approvedBy;
|
||||
|
||||
@Column(name = "approved_by_username", length = 100)
|
||||
private String approvedByUsername;
|
||||
|
||||
@Column(name = "approved_on")
|
||||
private LocalDateTime approvedOn;
|
||||
|
||||
@Column(name = "rejection_reason")
|
||||
private String rejectionReason;
|
||||
|
||||
@Column(name = "generated_on")
|
||||
private LocalDateTime generatedOn;
|
||||
|
||||
@Column(name = "generated_by")
|
||||
private Long generatedBy;
|
||||
|
||||
@Column(name = "file_name", length = 255)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "file_size_bytes")
|
||||
private Long fileSizeBytes;
|
||||
|
||||
@Column(name = "row_count")
|
||||
private Long rowCount;
|
||||
|
||||
protected DatabaseExportRequest() {
|
||||
// Default constructor for JPA
|
||||
}
|
||||
|
||||
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||
final LocalDateTime requestedOn) {
|
||||
this.status = STATUS_PENDING_APPROVAL;
|
||||
this.reason = reason;
|
||||
this.requestedBy = requestedBy;
|
||||
this.requestedByUsername = requestedByUsername;
|
||||
this.requestedOn = requestedOn;
|
||||
}
|
||||
|
||||
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||
final LocalDateTime requestedOn) {
|
||||
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public Long getRequestedBy() {
|
||||
return this.requestedBy;
|
||||
}
|
||||
|
||||
public boolean isPendingApproval() {
|
||||
return STATUS_PENDING_APPROVAL.equals(this.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
|
||||
* second copy has to be asked for and approved again — so every copy of the database that exists outside
|
||||
* the system can be traced back to an approval that was granted for it specifically.
|
||||
*/
|
||||
public boolean isDownloadable() {
|
||||
return STATUS_APPROVED.equals(this.status);
|
||||
}
|
||||
|
||||
public boolean isAlreadyDownloaded() {
|
||||
return STATUS_GENERATED.equals(this.status);
|
||||
}
|
||||
|
||||
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
|
||||
this.status = STATUS_APPROVED;
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedByUsername = approvedByUsername;
|
||||
this.approvedOn = approvedOn;
|
||||
}
|
||||
|
||||
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
|
||||
final String rejectionReason) {
|
||||
this.status = STATUS_REJECTED;
|
||||
this.approvedBy = approvedBy;
|
||||
this.approvedByUsername = approvedByUsername;
|
||||
this.approvedOn = approvedOn;
|
||||
this.rejectionReason = rejectionReason;
|
||||
}
|
||||
|
||||
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
|
||||
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
|
||||
final Long fileSizeBytes, final Long rowCount) {
|
||||
this.status = STATUS_GENERATED;
|
||||
this.generatedBy = generatedBy;
|
||||
this.generatedOn = generatedOn;
|
||||
this.fileName = fileName;
|
||||
this.fileSizeBytes = fileSizeBytes;
|
||||
this.rowCount = rowCount;
|
||||
}
|
||||
}
|
||||
+42
@@ -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.infrastructure.databaseexport.domain;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
|
||||
|
||||
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
|
||||
|
||||
long countByRequestedByAndStatus(Long requestedBy, String status);
|
||||
|
||||
/**
|
||||
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
|
||||
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
|
||||
*/
|
||||
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
|
||||
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
|
||||
}
|
||||
+42
@@ -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.infrastructure.databaseexport.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Requesting, approving and generating a full export of the tenant database. */
|
||||
public interface DatabaseExportService {
|
||||
|
||||
Long requestExport(String reason);
|
||||
|
||||
List<Map<String, Object>> retrieveAll(String status);
|
||||
|
||||
Map<String, Object> retrieveOne(Long requestId);
|
||||
|
||||
void approve(Long requestId);
|
||||
|
||||
void reject(Long requestId, String rejectionReason);
|
||||
|
||||
/**
|
||||
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
|
||||
* admin has already approved.
|
||||
*/
|
||||
byte[] generateExport(Long requestId);
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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.infrastructure.databaseexport.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
|
||||
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DatabaseExportServiceImpl implements DatabaseExportService {
|
||||
|
||||
private static final String PERMISSION = "DATABASEEXPORT";
|
||||
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
|
||||
private static final int MAX_ROWS_PER_TABLE = 200_000;
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DatabaseExportRequestRepository repository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public Long requestExport(final String reason) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_" + PERMISSION);
|
||||
|
||||
if (reason == null || reason.trim().length() < 10) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
|
||||
"A reason of at least 10 characters is required for a database export request.");
|
||||
}
|
||||
|
||||
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
|
||||
// approver to rubber-stamp in bulk.
|
||||
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
|
||||
"You already have a database export request awaiting approval.");
|
||||
}
|
||||
|
||||
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
|
||||
LocalDateTime.now());
|
||||
this.repository.saveAndFlush(request);
|
||||
|
||||
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
|
||||
return request.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveAll(final String status) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||
|
||||
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
|
||||
// client `requestedbyusername` and leave every camelCase field on the screen empty.
|
||||
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
|
||||
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
|
||||
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
|
||||
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||
+ "FROM m_database_export_request ");
|
||||
|
||||
if (status != null && !status.trim().isEmpty()) {
|
||||
sql.append("WHERE status = ? ORDER BY id DESC");
|
||||
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
|
||||
}
|
||||
sql.append("ORDER BY id DESC");
|
||||
return this.jdbcTemplate.queryForList(sql.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> retrieveOne(final Long requestId) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
|
||||
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
|
||||
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||
+ "FROM m_database_export_request WHERE id = ?",
|
||||
requestId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void approve(final Long requestId) {
|
||||
final AppUser approver = this.context.authenticatedUser();
|
||||
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||
|
||||
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||
validatePending(request);
|
||||
validateNotSelfApproval(request, approver);
|
||||
|
||||
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
|
||||
this.repository.saveAndFlush(request);
|
||||
|
||||
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void reject(final Long requestId, final String rejectionReason) {
|
||||
final AppUser approver = this.context.authenticatedUser();
|
||||
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||
|
||||
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||
validatePending(request);
|
||||
|
||||
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
|
||||
"A reason is required when rejecting a database export request.");
|
||||
}
|
||||
|
||||
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
|
||||
this.repository.saveAndFlush(request);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public byte[] generateExport(final Long requestId) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
|
||||
|
||||
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
|
||||
// calls must not both find it unspent.
|
||||
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
|
||||
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
|
||||
"Export request not found."));
|
||||
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
|
||||
// being turned away with a message about approval that no longer describes their situation.
|
||||
if (request.isAlreadyDownloaded()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
|
||||
"This export has already been downloaded. Raise a new request to take another copy.");
|
||||
}
|
||||
if (!request.isDownloadable()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
|
||||
"This export request has not been approved. Current status: " + request.getStatus() + ".");
|
||||
}
|
||||
validateIsRequester(request, user);
|
||||
|
||||
final List<String> tables = listTenantTables();
|
||||
long totalRows = 0;
|
||||
|
||||
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
|
||||
for (final String table : tables) {
|
||||
try {
|
||||
totalRows += writeTableAsCsv(zip, table);
|
||||
} catch (final Exception e) {
|
||||
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
|
||||
log.error("Database export: skipping table {} — {}", table, e.getMessage());
|
||||
}
|
||||
}
|
||||
writeManifest(zip, request, tables, totalRows, user);
|
||||
} catch (final IOException e) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
|
||||
"Failed to generate the database export: " + e.getMessage());
|
||||
}
|
||||
|
||||
final byte[] archive = buffer.toByteArray();
|
||||
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
|
||||
+ ".zip";
|
||||
|
||||
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
|
||||
this.repository.saveAndFlush(request);
|
||||
|
||||
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
|
||||
user.getId(), tables.size(), totalRows, archive.length);
|
||||
return archive;
|
||||
}
|
||||
|
||||
private List<String> listTenantTables() {
|
||||
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
|
||||
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
|
||||
}
|
||||
|
||||
/** Writes one table as a CSV entry and returns how many rows it contained. */
|
||||
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
|
||||
// The table name comes from information_schema, never from user input, so it cannot carry injection.
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
|
||||
|
||||
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
|
||||
if (rows.isEmpty()) {
|
||||
// Still emit the header so an empty table is visibly empty rather than missing.
|
||||
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
|
||||
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
|
||||
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||
zip.write('\n');
|
||||
zip.closeEntry();
|
||||
return 0;
|
||||
}
|
||||
|
||||
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
|
||||
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||
zip.write('\n');
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final StringBuilder line = new StringBuilder();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
if (i > 0) {
|
||||
line.append(',');
|
||||
}
|
||||
line.append(csvEscape(row.get(columns.get(i))));
|
||||
}
|
||||
line.append('\n');
|
||||
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
zip.closeEntry();
|
||||
return rows.size();
|
||||
}
|
||||
|
||||
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
|
||||
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
|
||||
final long totalRows, final AppUser downloadedBy) throws IOException {
|
||||
final Map<String, Object> details = retrieveOne(request.getId());
|
||||
final StringBuilder manifest = new StringBuilder();
|
||||
manifest.append("CBSNexora — Full Database Export\n");
|
||||
manifest.append("=================================\n\n");
|
||||
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
|
||||
manifest.append("Handle under your institution's data protection policy.\n\n");
|
||||
manifest.append("Request ID : ").append(request.getId()).append('\n');
|
||||
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
|
||||
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
|
||||
manifest.append("Reason : ").append(details.get("reason")).append('\n');
|
||||
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
|
||||
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
|
||||
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
|
||||
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
|
||||
manifest.append("Tables exported : ").append(tables.size()).append('\n');
|
||||
manifest.append("Rows exported : ").append(totalRows).append('\n');
|
||||
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
|
||||
|
||||
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
|
||||
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
|
||||
private String csvEscape(final Object value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
final String text = String.valueOf(value);
|
||||
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
|
||||
return '"' + text.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
|
||||
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
|
||||
"error.msg.databaseexport.not.found", "Export request not found."));
|
||||
}
|
||||
|
||||
private void validatePending(final DatabaseExportRequest request) {
|
||||
if (!request.isPendingApproval()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
|
||||
"This request has already been actioned. Current status: " + request.getStatus() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
|
||||
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
|
||||
* approver download would collapse the two-person control back into one.
|
||||
*/
|
||||
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
|
||||
if (!user.getId().equals(request.getRequestedBy())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
|
||||
"Only the user who raised this export request can download the archive.");
|
||||
}
|
||||
}
|
||||
|
||||
/** The entire control is that two different people are involved, so this is the check that enforces it. */
|
||||
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
|
||||
if (approver.getId().equals(request.getRequestedBy())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
|
||||
"A database export cannot be approved by the same user who requested it.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -102,12 +102,23 @@ public interface ShareAccountApiConstants {
|
||||
|
||||
String shareEntityType = "share";
|
||||
|
||||
/** Required on a redeem-shares (withdrawal) request — Board Resolution Number and Date of
|
||||
* Meeting authorising the withdrawal (cooperative-sector share capital governance). */
|
||||
String redeemresolutionnumber_paramname = "resolutionNumber";
|
||||
|
||||
String redeemmeetingdate_paramname = "meetingDate";
|
||||
|
||||
/** Loan this share account is held as margin/security against — optional, set via the
|
||||
* account update command. When present, a redeem request is checked against the loan's
|
||||
* outstanding balance. */
|
||||
String linkedloanid_paramname = "linkedLoanId";
|
||||
|
||||
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
|
||||
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
|
||||
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
|
||||
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
|
||||
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
|
||||
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
|
||||
numberofshares_paramname, purchasedprice_paramname)));
|
||||
numberofshares_paramname, purchasedprice_paramname, linkedloanid_paramname)));
|
||||
|
||||
}
|
||||
|
||||
+33
@@ -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;
|
||||
}
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
+72
@@ -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());
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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.DefaultValue;
|
||||
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.AmlRecurringAlertData;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService;
|
||||
import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/aml/recurring-alerts")
|
||||
@Component
|
||||
@Tag(name = "AML Recurring Alerts",
|
||||
description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.")
|
||||
@RequiredArgsConstructor
|
||||
public class AmlRecurringAlertApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||
private final AmlRecurringAlertWritePlatformService writePlatformService;
|
||||
private final DefaultToApiJsonSerializer<AmlRecurringAlertData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Retrieve the recurring-alert review queue",
|
||||
description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. "
|
||||
+ "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.")
|
||||
public String retrieveRecurringAlerts(
|
||||
@QueryParam("minAlerts") @DefaultValue("3") final int minAlerts,
|
||||
@QueryParam("windowDays") @DefaultValue("90") final int windowDays,
|
||||
@QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) {
|
||||
this.context.authenticatedUser();
|
||||
final List<AmlRecurringAlertData> rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1),
|
||||
onlyNeedingReview);
|
||||
return this.toApiJsonSerializer.serialize(rows);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{clientId}/review")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Record a review of a customer's repeated AML activity",
|
||||
description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert "
|
||||
+ "count grows beyond the count reviewed here.")
|
||||
public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
|
||||
final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(),
|
||||
request.getWindowDays() == null ? 0 : request.getWindowDays());
|
||||
return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}";
|
||||
}
|
||||
}
|
||||
+42
@@ -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.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class AmlRecurringReviewRequest {
|
||||
|
||||
@Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR")
|
||||
private String outcome;
|
||||
|
||||
@Schema(description = "Why the customer keeps surfacing and what was decided",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String notes;
|
||||
|
||||
@Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it",
|
||||
example = "90")
|
||||
private Integer windowDays;
|
||||
}
|
||||
+118
@@ -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));
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+35
@@ -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.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @param riskCategory
|
||||
* severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating
|
||||
* {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog
|
||||
* ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity.
|
||||
*/
|
||||
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, Integer riskCategory)
|
||||
implements Serializable {
|
||||
}
|
||||
+26
@@ -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 {
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One customer who has tripped the AML engine repeatedly inside the observation window.
|
||||
*
|
||||
* @param alertCount
|
||||
* alerts in the window — what the threshold is compared against
|
||||
* @param highRiskCount
|
||||
* how many of those came from a High severity catalog rule
|
||||
* @param distinctRules
|
||||
* number of different rules involved; one rule firing five times is a weaker signal than
|
||||
* five different rules firing once, and the reviewer needs to see which it is
|
||||
* @param openCount
|
||||
* alerts still awaiting a per-transaction disposition
|
||||
* @param escalatedCount
|
||||
* alerts already escalated to an STR case
|
||||
* @param needsReview
|
||||
* true when there is no review yet, or new alerts have arrived since the last one
|
||||
* @param alerts
|
||||
* the triggering transactions themselves, so the reviewer can see why this customer keeps
|
||||
* surfacing without leaving the row
|
||||
*/
|
||||
public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory,
|
||||
int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount,
|
||||
LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List<String> ruleCodes, boolean needsReview,
|
||||
Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn,
|
||||
String lastReviewNotes, LocalDate windowFrom, List<AmlAlertData> alerts) implements Serializable {
|
||||
}
|
||||
+28
@@ -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 {
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
@@ -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 {
|
||||
}
|
||||
+33
@@ -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 {}
|
||||
+24
@@ -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 {
|
||||
|
||||
}
|
||||
+153
@@ -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());
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+49
@@ -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;
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user