Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08519da8b1 | ||
|
|
cad7f54c47 | ||
|
|
8724c54d9b | ||
|
|
5c0a482f49 | ||
|
|
0e07e60328 | ||
|
|
e227e8ae0f | ||
|
|
231919b62b | ||
|
|
9eac40bb5c | ||
|
|
14b2585cf5 | ||
|
|
a2fa8688ae | ||
|
|
a8ca7dd948 | ||
|
|
c8833367b9 | ||
|
|
94b0afff38 | ||
|
|
9dd4f8bedb | ||
|
|
1107610c08 | ||
|
|
2213308254 | ||
|
|
c568eb0abf | ||
|
|
1426be9531 | ||
|
|
541c6ab062 | ||
|
|
66a6a4766f | ||
|
|
b9aa29fb35 | ||
|
|
84f8aa2274 | ||
|
|
7773a9e9c6 | ||
|
|
c890faee78 | ||
|
|
abc7eb22a2 |
+1
-1
@@ -159,7 +159,7 @@ gitVersioning.apply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ext['groovy.version'] = '4.0.17'
|
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
|
// Apply static weaving configuration to all subprojects
|
||||||
subprojects { subproject ->
|
subprojects { subproject ->
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@
|
|||||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() {
|
|||||||
// Configure Java compilation
|
// Configure Java compilation
|
||||||
tasks.named('compileJava') {
|
tasks.named('compileJava') {
|
||||||
outputs.cacheIf { true }
|
outputs.cacheIf { true }
|
||||||
dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply)
|
||||||
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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), //
|
CASH_AT_TELLER(102, "cashAtTeller", GLAccountType.ASSET), //
|
||||||
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
|
||||||
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
|
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 Integer value;
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
+20
-1
@@ -137,7 +137,7 @@ public class CommandSourceService {
|
|||||||
String permission = commandSource.getPermissionCode();
|
String permission = commandSource.getPermissionCode();
|
||||||
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
|
||||||
if (isMakerChecker || result.isRollbackTransaction()) {
|
if (isMakerChecker || result.isRollbackTransaction()) {
|
||||||
if (isApprovedByChecker || user.isCheckerSuperUser()) {
|
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
|
||||||
commandSource.markAsChecked(user);
|
commandSource.markAsChecked(user);
|
||||||
} else {
|
} else {
|
||||||
if (commandSource.isSanitized()) {
|
if (commandSource.isSanitized()) {
|
||||||
@@ -151,6 +151,25 @@ public class CommandSourceService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
|
||||||
|
if (!user.isCheckerSuperUser()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|
||||||
|
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
|
||||||
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+8
@@ -2211,6 +2211,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CommandWrapperBuilder createCbsBaseInterestRate() {
|
||||||
|
this.actionName = "CREATE";
|
||||||
|
this.entityName = "CBSBASEINTERESTRATE";
|
||||||
|
this.entityId = null;
|
||||||
|
this.href = "/fixeddepositproducts/baseinterestrates";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||||
|
|||||||
+2
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
|
|||||||
|
|
||||||
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
|
||||||
|
|
||||||
|
boolean isMakerCheckerEnabled();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
|
||||||
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
|
||||||
|
|||||||
+1
@@ -38,6 +38,7 @@ public enum JobName {
|
|||||||
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
GENERATE_RD_SCEHDULE("Generate Mandatory Savings Schedule"), //
|
||||||
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
|
||||||
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
|
||||||
|
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
|
||||||
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
|
||||||
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
|
||||||
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
|
||||||
|
|||||||
+9
@@ -35,4 +35,13 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
|
|||||||
@Param("since") LocalDateTime since);
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
long countByClientId(Long clientId);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+59
@@ -52,6 +52,26 @@ public class AmlTransactionRule {
|
|||||||
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
@Column(name = "threshold_amount", precision = 19, scale = 6)
|
||||||
private BigDecimal thresholdAmount;
|
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)
|
@Column(name = "rule_type", nullable = false, length = 20)
|
||||||
private String ruleType;
|
private String ruleType;
|
||||||
|
|
||||||
@@ -66,6 +86,29 @@ public class AmlTransactionRule {
|
|||||||
|
|
||||||
protected AmlTransactionRule() {}
|
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() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -86,6 +129,22 @@ public class AmlTransactionRule {
|
|||||||
return thresholdAmount;
|
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() {
|
public AmlRuleType getRuleType() {
|
||||||
return AmlRuleType.valueOf(ruleType);
|
return AmlRuleType.valueOf(ruleType);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -19,9 +19,17 @@
|
|||||||
package org.apache.fineract.portfolio.aml.domain;
|
package org.apache.fineract.portfolio.aml.domain;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransactionRule, Long> {
|
||||||
|
|
||||||
List<AmlTransactionRule> findByActiveTrueAndAppliesTo(String appliesTo);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -191,6 +191,7 @@ public class ClientApiConstants {
|
|||||||
public static final String constitutionIdParamName = "constitutionId";
|
public static final String constitutionIdParamName = "constitutionId";
|
||||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||||
|
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||||
public static final String additionalInfoParamName = "additionalInfo";
|
public static final String additionalInfoParamName = "additionalInfo";
|
||||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
private Boolean alternateDipVerified;
|
private Boolean alternateDipVerified;
|
||||||
private Long alternateDipLookupLogId;
|
private Long alternateDipLookupLogId;
|
||||||
private String alternateDipTimestamp;
|
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 Boolean nameVerified;
|
||||||
private Long screeningLogId;
|
private Long screeningLogId;
|
||||||
private Boolean isStaff;
|
private Boolean isStaff;
|
||||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
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.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
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.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
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.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
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 LocalDate marriageDate;
|
||||||
private final String marriageAuthority;
|
private final String marriageAuthority;
|
||||||
|
|
||||||
|
@lombok.Setter
|
||||||
|
private java.util.List<ClientFamilyMemberChildData> children;
|
||||||
|
|
||||||
// template holder
|
// template holder
|
||||||
private final Collection<CodeValueData> relationshipIdOptions;
|
private final Collection<CodeValueData> relationshipIdOptions;
|
||||||
private final Collection<CodeValueData> genderIdOptions;
|
private final Collection<CodeValueData> genderIdOptions;
|
||||||
|
|||||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
private final CodeValueData mainBusinessLine;
|
private final CodeValueData mainBusinessLine;
|
||||||
private final String remarks;
|
private final String remarks;
|
||||||
private final String authorizedSignatory;
|
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 String additionalInfo;
|
||||||
private final LocalDate dateOfRegistration;
|
private final LocalDate dateOfRegistration;
|
||||||
|
|
||||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.constitution = null;
|
this.constitution = null;
|
||||||
this.mainBusinessLine = null;
|
this.mainBusinessLine = null;
|
||||||
this.authorizedSignatory = null;
|
this.authorizedSignatory = null;
|
||||||
|
this.authorizedSignatoryClientIds = null;
|
||||||
this.additionalInfo = null;
|
this.additionalInfo = null;
|
||||||
this.dateOfRegistration = 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,
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
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.constitution = constitution;
|
||||||
this.incorpNumber = incorpNo;
|
this.incorpNumber = incorpNo;
|
||||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.mainBusinessLine = mainBusinessLine;
|
this.mainBusinessLine = mainBusinessLine;
|
||||||
this.remarks = remarks;
|
this.remarks = remarks;
|
||||||
this.authorizedSignatory = authorizedSignatory;
|
this.authorizedSignatory = authorizedSignatory;
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||||
this.additionalInfo = additionalInfo;
|
this.additionalInfo = additionalInfo;
|
||||||
this.dateOfRegistration = dateOfRegistration;
|
this.dateOfRegistration = dateOfRegistration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable;
|
|||||||
import jakarta.persistence.ManyToMany;
|
import jakarta.persistence.ManyToMany;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OneToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "is_pwd")
|
@Column(name = "is_pwd")
|
||||||
private Boolean isPwd;
|
private Boolean isPwd;
|
||||||
|
|
||||||
|
@OneToOne(mappedBy = "client", fetch = FetchType.LAZY)
|
||||||
|
private ClientKyc clientKyc;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "pwd_category_cv_id")
|
@JoinColumn(name = "pwd_category_cv_id")
|
||||||
private CodeValue pwdCategory;
|
private CodeValue pwdCategory;
|
||||||
@@ -290,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "alternate_dip_timestamp")
|
@Column(name = "alternate_dip_timestamp")
|
||||||
private String alternateDipTimestamp;
|
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")
|
@Column(name = "name_verified")
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
|
|
||||||
|
|||||||
+247
@@ -25,10 +25,15 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
@@ -60,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
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 */
|
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
||||||
@Column(name = "kyc_status", nullable = false)
|
@Column(name = "kyc_status", nullable = false)
|
||||||
private Integer kycStatus;
|
private Integer kycStatus;
|
||||||
@@ -103,6 +116,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "pan_status", length = 20)
|
@Column(name = "pan_status", length = 20)
|
||||||
private String panStatus;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Aadhaar ──────────────────────────────────────────────────────────────
|
||||||
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
/** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */
|
||||||
@Column(name = "aadhaar_number_masked", length = 12)
|
@Column(name = "aadhaar_number_masked", length = 12)
|
||||||
@@ -114,6 +157,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "aadhaar_verified_on")
|
@Column(name = "aadhaar_verified_on")
|
||||||
private LocalDate aadhaarVerifiedOn;
|
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) ─────────────────────────
|
// ── GSTIN (mandatory for NON_INDIVIDUAL clients) ─────────────────────────
|
||||||
@Column(name = "gstin_number", length = 15)
|
@Column(name = "gstin_number", length = 15)
|
||||||
private String gstinNumber;
|
private String gstinNumber;
|
||||||
@@ -195,6 +263,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "freeze_authority", length = 100)
|
@Column(name = "freeze_authority", length = 100)
|
||||||
private String freezeAuthority;
|
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 ───────────────────────────────────────────────
|
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||||
@Column(name = "form60_type", length = 20)
|
@Column(name = "form60_type", length = 20)
|
||||||
private String form60Type;
|
private String form60Type;
|
||||||
@@ -302,6 +382,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
private void applyExtendedFields(final JsonCommand command) {
|
private void applyExtendedFields(final JsonCommand command) {
|
||||||
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus);
|
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(
|
this.enterprisePan = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("enterprisePan"), null);
|
command.stringValueOfParameterNamed("enterprisePan"), null);
|
||||||
this.cinNumber = StringUtils.defaultIfEmpty(
|
this.cinNumber = StringUtils.defaultIfEmpty(
|
||||||
@@ -331,6 +429,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
command.stringValueOfParameterNamed("freezeReason"), null);
|
command.stringValueOfParameterNamed("freezeReason"), null);
|
||||||
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
this.freezeAuthority = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("freezeAuthority"), null);
|
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(
|
this.form60Type = StringUtils.defaultIfEmpty(
|
||||||
command.stringValueOfParameterNamed("form60Type"), null);
|
command.stringValueOfParameterNamed("form60Type"), null);
|
||||||
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
this.form60Date = command.localDateValueOfParameterNamed("form60Date");
|
||||||
@@ -350,11 +452,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
public Map<String, Object> update(final JsonCommand command) {
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||||
|
this.fieldsOverwrittenByLastUpdate.clear();
|
||||||
|
|
||||||
final String panNumberParam = "panNumber";
|
final String panNumberParam = "panNumber";
|
||||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||||
actualChanges.put(panNumberParam, newValue);
|
actualChanges.put(panNumberParam, newValue);
|
||||||
|
recordOverwrite(panNumberParam, this.panNumber);
|
||||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||||
actualChanges.put(aadhaarParam, newValue);
|
actualChanges.put(aadhaarParam, newValue);
|
||||||
|
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||||
actualChanges.put(gstinParam, newValue);
|
actualChanges.put(gstinParam, newValue);
|
||||||
|
recordOverwrite(gstinParam, this.gstinNumber);
|
||||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
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";
|
final String remarksParam = "remarks";
|
||||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||||
@@ -388,6 +497,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges,
|
||||||
v -> this.panAadhaarSeedingStatus = v);
|
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, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
|
||||||
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
|
||||||
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
|
||||||
@@ -413,6 +544,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v);
|
||||||
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges,
|
||||||
v -> this.freezeAuthority = v);
|
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);
|
updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v);
|
||||||
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v);
|
||||||
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges,
|
||||||
@@ -435,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(param);
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,11 +591,51 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
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. */
|
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||||
public void markInProgress() {
|
public void markInProgress() {
|
||||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||||
@@ -659,6 +836,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
return panStatus;
|
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() {
|
public String getAadhaarNumberMasked() {
|
||||||
return aadhaarNumberMasked;
|
return aadhaarNumberMasked;
|
||||||
}
|
}
|
||||||
@@ -906,4 +1137,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
public Long getVersion() {
|
public Long getVersion() {
|
||||||
return this.version;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-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")
|
@Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId")
|
||||||
void removeSignatureId(@Param("clientId") Long 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 ───────────────────────────────────────────
|
// ── Pre-save uniqueness probes ───────────────────────────────────────────
|
||||||
// Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear
|
// 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.
|
// field-level error BEFORE saveAndFlush — and importantly before the external KYC API call.
|
||||||
|
|||||||
+38
@@ -95,6 +95,44 @@ public class ClientRiskRule {
|
|||||||
|
|
||||||
protected ClientRiskRule() {}
|
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() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository<ClientRiskRule,
|
|||||||
|
|
||||||
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
@Query("SELECT r FROM ClientRiskRule r ORDER BY r.orderPosition ASC")
|
||||||
List<ClientRiskRule> findAllOrderedByOrderPosition();
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
|||||||
@Column(name = "looked_up_on", nullable = false)
|
@Column(name = "looked_up_on", nullable = false)
|
||||||
private OffsetDateTime lookedUpOn;
|
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() {}
|
protected MobileLookupLog() {}
|
||||||
|
|
||||||
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
||||||
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
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;
|
package org.apache.fineract.portfolio.mobilelookup.domain;
|
||||||
|
|
||||||
import java.util.List;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
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")
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
||||||
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
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)
|
@Column(name = "screened_on", nullable = false)
|
||||||
private OffsetDateTime screenedOn;
|
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() {}
|
protected SanctionsScreeningLog() {}
|
||||||
|
|
||||||
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
||||||
@@ -75,8 +134,75 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
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 ─────────────────────────────────────────────────────────────
|
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public String getScreenedName() { return screenedName; }
|
public String getScreenedName() { return screenedName; }
|
||||||
@@ -89,4 +215,17 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
public void setClientId(Long clientId) { this.clientId = clientId; }
|
public void setClientId(Long clientId) { this.clientId = clientId; }
|
||||||
public String getIpAddress() { return ipAddress; }
|
public String getIpAddress() { return ipAddress; }
|
||||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
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")
|
@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);
|
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.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||||
|
|||||||
+10
-1
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.infrastructure.contentstore.policy;
|
package org.apache.fineract.infrastructure.contentstore.policy;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.Strings;
|
import org.apache.commons.lang3.Strings;
|
||||||
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
|
|||||||
@Component
|
@Component
|
||||||
public class MimeContentPolicy implements ContentPolicy {
|
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;
|
private final ContentDetectorManager contentDetectorManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
|
|||||||
var result = contentDetectorManager
|
var result = contentDetectorManager
|
||||||
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
|
.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!",
|
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
|
||||||
result.getMimeType(), ctx.getMimeType()));
|
result.getMimeType(), ctx.getMimeType()));
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -18,6 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.infrastructure.documentmanagement.service;
|
package org.apache.fineract.infrastructure.documentmanagement.service;
|
||||||
|
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.nio.file.NoSuchFileException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -38,6 +40,9 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
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
|
@Deprecated
|
||||||
private final List<EntityImageIdAdapter> imageIdAdapters;
|
private final List<EntityImageIdAdapter> imageIdAdapters;
|
||||||
private final ContentStoreService storeService;
|
private final ContentStoreService storeService;
|
||||||
@@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService {
|
|||||||
} catch (final AbstractPlatformDomainRuleException e) {
|
} catch (final AbstractPlatformDomainRuleException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (final Exception 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);
|
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.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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.StaffAssignmentHistory</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.Group</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.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.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
@@ -61,6 +61,11 @@ public class ConfigurationDomainServiceJpa implements ConfigurationDomainService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isMakerCheckerEnabled() {
|
||||||
|
return getGlobalConfigurationPropertyData(GlobalConfigurationConstants.MAKER_CHECKER).isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
public List<String> getAllowedLoanStatusesForExternalAssetTransfer() {
|
||||||
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
final GlobalConfigurationPropertyData property = getGlobalConfigurationPropertyData(
|
||||||
|
|||||||
+5
@@ -154,6 +154,11 @@ public class SecurityConfig {
|
|||||||
// a Fineract Bearer token; security is enforced at the JWT envelope
|
// a Fineract Bearer token; security is enforced at the JWT envelope
|
||||||
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
// (signature + encryption) by ImpsJwtUtil / ImpsCryptoUtil inside the handler.
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/imps/**")).permitAll()
|
||||||
|
// Client edit approval email open-tracking pixel — hit by the client's email client
|
||||||
|
// loading a 1×1 <img> tag; no Fineract session available. The token in the URL
|
||||||
|
// path is the only capability credential; the endpoint always returns a GIF so
|
||||||
|
// token validity is not leaked via different HTTP responses.
|
||||||
|
.requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/clients/edit/pixel/*")).permitAll()
|
||||||
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
// NPCI clearing-house receiver — the real, separate NPCI service pushes routed
|
||||||
// packages here without a Fineract Bearer token; security is enforced by the
|
// packages here without a Fineract Bearer token; security is enforced by the
|
||||||
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
// envelope's RSA signature/encryption (NpciCryptoUtil) inside the handler.
|
||||||
|
|||||||
+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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -33,8 +33,10 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
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.AmlAlertReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
import org.apache.fineract.portfolio.aml.service.AmlAlertWritePlatformService;
|
||||||
|
import org.apache.fineract.portfolio.aml.service.JournalEntryReferralService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/aml/alerts")
|
@Path("/v1/aml/alerts")
|
||||||
@@ -46,7 +48,9 @@ public class AmlAlertApiResource {
|
|||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final AmlAlertReadPlatformService readPlatformService;
|
private final AmlAlertReadPlatformService readPlatformService;
|
||||||
private final AmlAlertWritePlatformService writePlatformService;
|
private final AmlAlertWritePlatformService writePlatformService;
|
||||||
|
private final JournalEntryReferralService referralService;
|
||||||
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<AmlAlertData> toApiJsonSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<ManualReferralData> referralJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@@ -76,4 +80,28 @@ public class AmlAlertApiResource {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
return this.toApiJsonSerializer.serialize(updated);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
+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;
|
||||||
|
}
|
||||||
+8
-1
@@ -22,7 +22,14 @@ import java.io.Serializable;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
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,
|
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 ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
|
||||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) implements Serializable {
|
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
|
||||||
|
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 {
|
||||||
|
}
|
||||||
+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 {}
|
||||||
+25
-4
@@ -19,12 +19,15 @@
|
|||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
|
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
|
||||||
|
|
||||||
private final AmlAlertRepository repository;
|
private final AmlAlertRepository repository;
|
||||||
|
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlAlertData> retrieve(String statusFilter) {
|
public List<AmlAlertData> retrieve(String statusFilter) {
|
||||||
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
||||||
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
||||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
|
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
|
||||||
.collect(Collectors.toList());
|
// lookup inside the map step would re-read the same catalog rows repeatedly.
|
||||||
|
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
|
||||||
|
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
|
||||||
|
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private AmlAlertData mapToData(AmlAlert alert) {
|
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
|
||||||
|
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
|
||||||
|
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
|
||||||
|
.toList();
|
||||||
|
final Map<Long, Integer> byId = new HashMap<>();
|
||||||
|
if (sourceIds.isEmpty()) {
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
this.clientRiskRuleRepository.findAllById(sourceIds).forEach(rule -> byId.put(rule.getId(), rule.getRiskCategory()));
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
|
||||||
|
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
|
||||||
|
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
|
||||||
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
|
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
|
||||||
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
||||||
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
||||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
||||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
|
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
|
||||||
|
public interface AmlRecurringAlertNotificationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once per transaction after the engine has saved its alerts. Raises a notification for
|
||||||
|
* AML reviewers when this customer's alert count crosses the recurring-behaviour threshold.
|
||||||
|
*
|
||||||
|
* @param alertsJustRaised
|
||||||
|
* how many alerts this transaction produced, so the count before it can be derived
|
||||||
|
*/
|
||||||
|
void onAlertsRaised(Client client, int alertsJustRaised);
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.notification.service.UserNotificationService;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a notification to AML reviewers when one customer keeps tripping the engine.
|
||||||
|
*
|
||||||
|
* The review queue on the AML Rules Master screen is the durable record; this is the nudge that
|
||||||
|
* stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the
|
||||||
|
* customer's office, resolved by {@link UserNotificationService}.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already
|
||||||
|
* treats as "this customer has done this enough times to warrant another look".
|
||||||
|
*/
|
||||||
|
private static final int RECURRING_THRESHOLD = 3;
|
||||||
|
private static final int WINDOW_DAYS = 90;
|
||||||
|
|
||||||
|
private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT";
|
||||||
|
private static final String OBJECT_TYPE = "AML_RECURRING";
|
||||||
|
private static final String EVENT_TYPE = "recurringAmlActivity";
|
||||||
|
|
||||||
|
private final AmlAlertRepository alertRepository;
|
||||||
|
private final UserNotificationService userNotificationService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAlertsRaised(final Client client, final int alertsJustRaised) {
|
||||||
|
if (client == null || alertsJustRaised <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay();
|
||||||
|
final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since);
|
||||||
|
final long before = after - alertsJustRaised;
|
||||||
|
|
||||||
|
// Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every
|
||||||
|
// alert once the threshold is passed. Notifying every time would bury the reviewer in
|
||||||
|
// duplicates for a customer they have already been told about; notifying only on the
|
||||||
|
// first crossing would go permanently silent on a customer who keeps offending.
|
||||||
|
if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String content = String.format(
|
||||||
|
"%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.",
|
||||||
|
client.getDisplayName(), after, WINDOW_DAYS);
|
||||||
|
|
||||||
|
this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null,
|
||||||
|
client.getOffice() == null ? null : client.getOffice().getId());
|
||||||
|
log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// Consistent with AmlDomainServiceImpl: AML observation must never break the transaction
|
||||||
|
// it is watching, and a notification is the least important part of that observation.
|
||||||
|
log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.aml.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||||
|
|
||||||
|
public interface AmlRecurringAlertReadPlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}.
|
||||||
|
*
|
||||||
|
* @param onlyNeedingReview
|
||||||
|
* when true, hides customers already reviewed at their current alert count
|
||||||
|
*/
|
||||||
|
List<AmlRecurringAlertData> retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview);
|
||||||
|
|
||||||
|
/** Alerts in the window for one customer, used to record a review against the count seen. */
|
||||||
|
int alertCountInWindow(Long clientId, int windowDays);
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the recurring-alert review queue.
|
||||||
|
*
|
||||||
|
* The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so
|
||||||
|
* the rule-severity resolution lives in exactly one place and a customer's High-risk count here can
|
||||||
|
* never disagree with the Risk Category shown on the alert itself.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService {
|
||||||
|
|
||||||
|
private static final int HIGH_RISK = 3;
|
||||||
|
|
||||||
|
private final AmlAlertReadPlatformService alertReadPlatformService;
|
||||||
|
private final AmlRecurringReviewRepository reviewRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<AmlRecurringAlertData> retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) {
|
||||||
|
final LocalDate windowFrom = LocalDate.now().minusDays(windowDays);
|
||||||
|
final LocalDateTime cutoff = windowFrom.atStartOfDay();
|
||||||
|
|
||||||
|
// Grouped in insertion order of the (already newest-first) alert list, so each customer's
|
||||||
|
// nested alerts stay newest-first without a second sort.
|
||||||
|
final Map<Long, List<AmlAlertData>> byClient = this.alertReadPlatformService.retrieve(null).stream()
|
||||||
|
.filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff))
|
||||||
|
.collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
final List<Long> qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts)
|
||||||
|
.map(Map.Entry::getKey).toList();
|
||||||
|
if (qualifying.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<Long, ClientSummary> summaries = clientSummaries(qualifying);
|
||||||
|
|
||||||
|
final List<AmlRecurringAlertData> rows = new ArrayList<>();
|
||||||
|
for (final Long clientId : qualifying) {
|
||||||
|
final List<AmlAlertData> alerts = byClient.get(clientId);
|
||||||
|
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
// Reviewed at a lower count means new alerts have landed since — the customer has to
|
||||||
|
// come back, otherwise one early review would silence them permanently.
|
||||||
|
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
|
||||||
|
if (onlyNeedingReview && !needsReview) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null));
|
||||||
|
rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(),
|
||||||
|
summary.riskCategory(), alerts.size(),
|
||||||
|
(int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(),
|
||||||
|
(int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(),
|
||||||
|
(int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(),
|
||||||
|
(int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(),
|
||||||
|
alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||||
|
alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null),
|
||||||
|
alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null),
|
||||||
|
alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview,
|
||||||
|
lastReview == null ? null : lastReview.getAlertCountAtReview(),
|
||||||
|
lastReview == null ? null : lastReview.getOutcome().name(),
|
||||||
|
lastReview == null ? null : lastReview.getReviewedBy().getUsername(),
|
||||||
|
lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(),
|
||||||
|
windowFrom, alerts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worst offenders first; most recent activity breaks ties.
|
||||||
|
rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed()
|
||||||
|
.thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int alertCountInWindow(final Long clientId, final int windowDays) {
|
||||||
|
final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay();
|
||||||
|
return (int) this.alertReadPlatformService.retrieve(null).stream()
|
||||||
|
.filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Account number and current KYC risk rating, fetched once for the whole queue. */
|
||||||
|
private Map<Long, ClientSummary> clientSummaries(final List<Long> clientIds) {
|
||||||
|
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||||
|
final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory "
|
||||||
|
+ "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")";
|
||||||
|
final Map<Long, ClientSummary> byId = new HashMap<>();
|
||||||
|
this.jdbcTemplate.query(sql, rs -> {
|
||||||
|
byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"),
|
||||||
|
rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory")));
|
||||||
|
}, clientIds.toArray());
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ClientSummary(String accountNo, Integer riskCategory) {}
|
||||||
|
}
|
||||||
+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.service;
|
||||||
|
|
||||||
|
public interface AmlRecurringAlertWritePlatformService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count
|
||||||
|
* the reviewer was looking at so the customer only resurfaces when that count grows.
|
||||||
|
*
|
||||||
|
* @param windowDays
|
||||||
|
* observation window the reviewer had on screen, so the recorded count matches it
|
||||||
|
* @return the id of the stored review
|
||||||
|
*/
|
||||||
|
Long recordReview(Long clientId, String outcome, String notes, int windowDays);
|
||||||
|
}
|
||||||
+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.aml.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
|
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService {
|
||||||
|
|
||||||
|
private static final String RESOURCE = "amlrecurringreview";
|
||||||
|
private static final int DEFAULT_WINDOW_DAYS = 90;
|
||||||
|
|
||||||
|
private final AmlRecurringReviewRepository reviewRepository;
|
||||||
|
private final AmlRecurringAlertReadPlatformService readPlatformService;
|
||||||
|
private final ClientRepositoryWrapper clientRepository;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) {
|
||||||
|
final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes);
|
||||||
|
final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS;
|
||||||
|
|
||||||
|
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
||||||
|
// Counted here rather than taken from the request: a caller-supplied count could be used to
|
||||||
|
// silence a customer at an inflated number they were never actually reviewed at.
|
||||||
|
final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window);
|
||||||
|
|
||||||
|
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
|
||||||
|
this.context.authenticatedUser());
|
||||||
|
return this.reviewRepository.saveAndFlush(review).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
|
||||||
|
final List<ApiParameterError> errors = new ArrayList<>();
|
||||||
|
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||||
|
|
||||||
|
validator.reset().parameter("clientId").value(clientId).notNull();
|
||||||
|
validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000);
|
||||||
|
validator.reset().parameter("outcome").value(outcome).notBlank();
|
||||||
|
|
||||||
|
AmlRecurringReviewOutcome parsed = null;
|
||||||
|
if (StringUtils.isNotBlank(outcome)) {
|
||||||
|
for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) {
|
||||||
|
if (candidate.name().equalsIgnoreCase(outcome.trim())) {
|
||||||
|
parsed = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed == null) {
|
||||||
|
validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new PlatformApiDataValidationException(errors);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-3
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.aml.service;
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -27,6 +28,8 @@ import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
|||||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKyc;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -35,8 +38,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Transactional
|
@Transactional
|
||||||
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||||
|
|
||||||
|
private static final int MEDIUM_RISK = 2;
|
||||||
|
private static final int HIGH_RISK = 3;
|
||||||
|
|
||||||
private final AmlTransactionRuleRepository ruleRepository;
|
private final AmlTransactionRuleRepository ruleRepository;
|
||||||
private final AmlAlertRepository alertRepository;
|
private final AmlAlertRepository alertRepository;
|
||||||
|
private final ClientKycRepository clientKycRepository;
|
||||||
|
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||||
@@ -52,24 +60,54 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
|||||||
raised.add(alertRepository.save(alert));
|
raised.add(alertRepository.save(alert));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every transaction type funnels through here, so the recurring-behaviour check is done once
|
||||||
|
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
|
||||||
|
// the batch size lets the service tell a first-time offender from a repeat one without
|
||||||
|
// re-querying what it just wrote.
|
||||||
|
if (!raised.isEmpty()) {
|
||||||
|
recurringAlertNotificationService.onAlertsRaised(context.getClient(), raised.size());
|
||||||
|
}
|
||||||
return raised;
|
return raised;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||||
if (rule.getThresholdAmount() == null) {
|
final BigDecimal threshold = resolveThreshold(rule, context);
|
||||||
|
if (threshold == null) {
|
||||||
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
// Pattern-only VELOCITY rules (e.g. same-day movement) need bespoke detection logic
|
||||||
// per rule and are not yet evaluated here - they exist in the catalog for future work.
|
// per rule and are not yet evaluated here - they exist in the catalog for future work.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
switch (rule.getRuleType()) {
|
switch (rule.getRuleType()) {
|
||||||
case THRESHOLD:
|
case THRESHOLD:
|
||||||
return context.getAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
return context.getAmount().compareTo(threshold) >= 0;
|
||||||
case STRUCTURING:
|
case STRUCTURING:
|
||||||
case VELOCITY:
|
case VELOCITY:
|
||||||
return context.getWindowAggregateAmount() != null
|
return context.getWindowAggregateAmount() != null
|
||||||
&& context.getWindowAggregateAmount().compareTo(rule.getThresholdAmount()) >= 0;
|
&& context.getWindowAggregateAmount().compareTo(threshold) >= 0;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules with both tiered columns null use {@code thresholdAmount} for every client, unchanged
|
||||||
|
* from prior behaviour. Tiered rules look up the transacting client's current KYC risk category
|
||||||
|
* and pick the matching tier, falling back to {@code thresholdAmount} for Low-risk/unclassified
|
||||||
|
* clients.
|
||||||
|
*/
|
||||||
|
private BigDecimal resolveThreshold(AmlTransactionRule rule, AmlTransactionContext context) {
|
||||||
|
if (rule.getThresholdAmountMediumRisk() == null && rule.getThresholdAmountHighRisk() == null) {
|
||||||
|
return rule.getThresholdAmount();
|
||||||
|
}
|
||||||
|
final Integer riskCategory = clientKycRepository.findByClientId(context.getClient().getId()).map(ClientKyc::getRiskCategoryValue)
|
||||||
|
.orElse(null);
|
||||||
|
if (riskCategory != null && riskCategory == HIGH_RISK && rule.getThresholdAmountHighRisk() != null) {
|
||||||
|
return rule.getThresholdAmountHighRisk();
|
||||||
|
}
|
||||||
|
if (riskCategory != null && riskCategory == MEDIUM_RISK && rule.getThresholdAmountMediumRisk() != null) {
|
||||||
|
return rule.getThresholdAmountMediumRisk();
|
||||||
|
}
|
||||||
|
return rule.getThresholdAmount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a user mark a ledger entry as cleared or suspicious from the general ledger screen.
|
||||||
|
*
|
||||||
|
* The rule engine raises alerts on its own; this is the manual path for an entry a reviewer's eye catches that no rule
|
||||||
|
* fired on. A suspicious mark goes down exactly the same road as an escalated engine alert - it raises an
|
||||||
|
* {@link org.apache.fineract.portfolio.aml.domain.AmlAlert} against the MANUAL_REFERRAL rule and escalates it, so the
|
||||||
|
* case lands in the Principal Officer's STR queue with the usual seven working day FIU clock running.
|
||||||
|
*/
|
||||||
|
public interface JournalEntryReferralService {
|
||||||
|
|
||||||
|
/** disposition: WHITELIST | ESCALATE. */
|
||||||
|
ManualReferralData refer(Long journalEntryId, String disposition, String notes);
|
||||||
|
|
||||||
|
/** Every mark made from the ledger, so the screen can show what has already been dealt with. */
|
||||||
|
List<ManualReferralData> retrieveAll();
|
||||||
|
}
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.aml.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.aml.data.ManualReferralData;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||||
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
|
import org.springframework.dao.EmptyResultDataAccessException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional
|
||||||
|
public class JournalEntryReferralServiceImpl implements JournalEntryReferralService {
|
||||||
|
|
||||||
|
/** Seeded inactive by 0443 so the evaluation engine never fires it. */
|
||||||
|
private static final String MANUAL_RULE_CODE = "MANUAL_REFERRAL";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AmlAlertRepository alertRepository;
|
||||||
|
private final AmlStrCaseRepository strCaseRepository;
|
||||||
|
private final AmlTransactionRuleRepository ruleRepository;
|
||||||
|
private final ClientRepositoryWrapper clientRepository;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final ClientRiskEscalationService clientRiskEscalationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A journal entry carries no customer of its own, so the customer has to be reached through whatever the entry is
|
||||||
|
* posted against.
|
||||||
|
*
|
||||||
|
* The entity_id / entity_type_enum pair is the primary route and the one that matters: it is exactly how
|
||||||
|
* JournalEntryReadPlatformServiceImpl derives the account number the ledger screen shows, so anything displaying an
|
||||||
|
* account number resolves here. entity_type_enum follows PortfolioProductType - LOAN(1), SAVING(2), CLIENT(5) - and
|
||||||
|
* a savings-type entity may be either a savings or a current account, which live in separate tables.
|
||||||
|
*
|
||||||
|
* The transaction-id columns are kept as a fallback for entries that carry one but no entity. A purely manual GL
|
||||||
|
* posting reaches none of these and comes back with a null clientId, which is what makes it unreferrable.
|
||||||
|
*/
|
||||||
|
private static final String RESOLVE_SQL = """
|
||||||
|
SELECT je.amount AS amount,
|
||||||
|
je.currency_code AS currencyCode,
|
||||||
|
je.savings_transaction_id AS savingsTxnId,
|
||||||
|
je.loan_transaction_id AS loanTxnId,
|
||||||
|
je.entity_type_enum AS entityType,
|
||||||
|
COALESCE(
|
||||||
|
esa.client_id,
|
||||||
|
eca.client_id,
|
||||||
|
eloan.client_id,
|
||||||
|
CASE WHEN je.entity_type_enum = 5 THEN je.entity_id END,
|
||||||
|
sa.client_id,
|
||||||
|
l.client_id,
|
||||||
|
ct.client_id
|
||||||
|
) AS clientId
|
||||||
|
FROM acc_gl_journal_entry je
|
||||||
|
LEFT JOIN m_savings_account esa ON esa.id = je.entity_id AND je.entity_type_enum = 2
|
||||||
|
LEFT JOIN m_current_account eca ON eca.id = je.entity_id AND je.entity_type_enum = 2
|
||||||
|
LEFT JOIN m_loan eloan ON eloan.id = je.entity_id AND je.entity_type_enum = 1
|
||||||
|
LEFT JOIN m_savings_account_transaction sat ON sat.id = je.savings_transaction_id
|
||||||
|
LEFT JOIN m_savings_account sa ON sa.id = sat.savings_account_id
|
||||||
|
LEFT JOIN m_loan_transaction lt ON lt.id = je.loan_transaction_id
|
||||||
|
LEFT JOIN m_loan l ON l.id = lt.loan_id
|
||||||
|
LEFT JOIN m_client_transaction ct ON ct.id = je.client_transaction_id
|
||||||
|
WHERE je.id = ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String LIST_SQL = """
|
||||||
|
SELECT a.id AS alertId,
|
||||||
|
a.transaction_id AS journalEntryId,
|
||||||
|
a.client_id AS clientId,
|
||||||
|
c.display_name AS clientDisplayName,
|
||||||
|
a.amount AS amount,
|
||||||
|
a.currency_code AS currencyCode,
|
||||||
|
a.raised_on AS raisedOn,
|
||||||
|
a.status AS status,
|
||||||
|
a.disposition AS disposition,
|
||||||
|
u.username AS reviewedByUsername,
|
||||||
|
a.review_notes AS reviewNotes,
|
||||||
|
s.id AS strCaseId
|
||||||
|
FROM m_aml_alert a
|
||||||
|
JOIN m_aml_transaction_rule r ON r.id = a.rule_id AND r.rule_code = ?
|
||||||
|
LEFT JOIN m_client c ON c.id = a.client_id
|
||||||
|
LEFT JOIN m_appuser u ON u.id = a.reviewed_by
|
||||||
|
LEFT JOIN m_aml_str_case s ON s.alert_id = a.id
|
||||||
|
ORDER BY a.raised_on DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ManualReferralData refer(final Long journalEntryId, final String disposition, final String notes) {
|
||||||
|
if (journalEntryId == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.required",
|
||||||
|
"A journal entry must be given to refer.");
|
||||||
|
}
|
||||||
|
final boolean escalate = "ESCALATE".equalsIgnoreCase(disposition);
|
||||||
|
if (!escalate && !"WHITELIST".equalsIgnoreCase(disposition)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.disposition.invalid",
|
||||||
|
"Disposition must be WHITELIST or ESCALATE.", disposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
final AmlTransactionRule rule = ruleRepository.findByRuleCode(MANUAL_RULE_CODE)
|
||||||
|
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.aml.referral.rule.missing",
|
||||||
|
"The MANUAL_REFERRAL rule is not seeded; run the tenant migrations."));
|
||||||
|
|
||||||
|
// Marking the same entry twice would put two cases in front of the Principal Officer for
|
||||||
|
// one transaction, so the first mark stands until it is explicitly reworked.
|
||||||
|
if (!alertRepository.findByRule_IdAndTransactionId(rule.getId(), journalEntryId).isEmpty()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.already.marked",
|
||||||
|
"This ledger entry has already been marked.", journalEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> row = loadEntry(journalEntryId);
|
||||||
|
final Number clientIdValue = (Number) row.get("clientId");
|
||||||
|
if (clientIdValue == null) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.no.client",
|
||||||
|
"This ledger entry has no customer behind it, so it cannot be referred.", journalEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Client client = clientRepository.findOneWithNotFoundDetection(clientIdValue.longValue());
|
||||||
|
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||||
|
final String currencyCode = (String) row.get("currencyCode");
|
||||||
|
|
||||||
|
// transaction_id holds the journal entry id, which is what the ledger screen keys on. The
|
||||||
|
// MANUAL_REFERRAL rule tells these apart from engine alerts, whose transaction_id is a
|
||||||
|
// savings or loan transaction id.
|
||||||
|
final AmlAlert alert = AmlAlert.raise(transactionTypeOf(row), journalEntryId, client, rule, amount, currencyCode,
|
||||||
|
LocalDateTime.now());
|
||||||
|
alertRepository.save(alert);
|
||||||
|
|
||||||
|
Long strCaseId = null;
|
||||||
|
if (escalate) {
|
||||||
|
alert.escalate(context.authenticatedUser(), notes);
|
||||||
|
alertRepository.save(alert);
|
||||||
|
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
|
||||||
|
clientRiskEscalationService.onStrCaseEscalated(strCase);
|
||||||
|
strCaseId = strCase.getId();
|
||||||
|
} else {
|
||||||
|
alert.whitelist(context.authenticatedUser(), notes);
|
||||||
|
alertRepository.save(alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ManualReferralData(alert.getId(), journalEntryId, client.getId(), client.getDisplayName(), amount,
|
||||||
|
currencyCode, alert.getRaisedOn(), alert.getStatus().name(),
|
||||||
|
alert.getDisposition() == null ? null : alert.getDisposition().name(),
|
||||||
|
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewNotes(),
|
||||||
|
strCaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ManualReferralData> retrieveAll() {
|
||||||
|
return jdbcTemplate.query(LIST_SQL, (rs, rowNum) -> new ManualReferralData(rs.getLong("alertId"),
|
||||||
|
rs.getLong("journalEntryId"), rs.getLong("clientId"), rs.getString("clientDisplayName"),
|
||||||
|
rs.getBigDecimal("amount"), rs.getString("currencyCode"),
|
||||||
|
rs.getTimestamp("raisedOn") == null ? null : rs.getTimestamp("raisedOn").toLocalDateTime(),
|
||||||
|
rs.getString("status"), rs.getString("disposition"), rs.getString("reviewedByUsername"),
|
||||||
|
rs.getString("reviewNotes"), rs.getObject("strCaseId") == null ? null : rs.getLong("strCaseId")),
|
||||||
|
MANUAL_RULE_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loadEntry(final Long journalEntryId) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.queryForMap(RESOLVE_SQL, journalEntryId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.aml.referral.journal.entry.not.found",
|
||||||
|
"Journal entry " + journalEntryId + " does not exist.", journalEntryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TRANSFER covers everything that is neither a savings nor a loan posting.
|
||||||
|
*
|
||||||
|
* The entity type is checked first because a system-generated entry often carries entity_id without a transaction
|
||||||
|
* id; falling straight through to the transaction columns would mislabel those as transfers.
|
||||||
|
*/
|
||||||
|
private AmlTransactionType transactionTypeOf(final Map<String, Object> row) {
|
||||||
|
final Number entityType = (Number) row.get("entityType");
|
||||||
|
if (entityType != null) {
|
||||||
|
if (entityType.intValue() == 2) {
|
||||||
|
return AmlTransactionType.SAVINGS;
|
||||||
|
}
|
||||||
|
if (entityType.intValue() == 1) {
|
||||||
|
return AmlTransactionType.LOAN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.get("savingsTxnId") != null) {
|
||||||
|
return AmlTransactionType.SAVINGS;
|
||||||
|
}
|
||||||
|
if (row.get("loanTxnId") != null) {
|
||||||
|
return AmlTransactionType.LOAN;
|
||||||
|
}
|
||||||
|
return AmlTransactionType.TRANSFER;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -634,6 +634,13 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
throw apiError(409, "This share withdrawal request was already actioned by another manager.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this redemption brought the account's holding to zero, close the share account right
|
||||||
|
// here in the SAME approval action — no separate "Share Closure Request" maker-checker step.
|
||||||
|
// Safe to do inline (unlike a raw manual "Close" from the account screen) because the
|
||||||
|
// redemption amount itself already went through this same Maker-Checker review; there are no
|
||||||
|
// shares left for the native close command to force-redeem without oversight.
|
||||||
|
final boolean accountClosed = closeShareAccountIfSharesZero(shareAccountId, requestedDateObj.toString());
|
||||||
|
|
||||||
final JsonObject res = new JsonObject();
|
final JsonObject res = new JsonObject();
|
||||||
res.addProperty("resourceId", id);
|
res.addProperty("resourceId", id);
|
||||||
res.addProperty("status", "APPROVED");
|
res.addProperty("status", "APPROVED");
|
||||||
@@ -646,9 +653,35 @@ public class ClerkShareWithdrawalRequestApiResource {
|
|||||||
} else {
|
} else {
|
||||||
res.addProperty("settlementWarning", settlementNote);
|
res.addProperty("settlementWarning", settlementNote);
|
||||||
}
|
}
|
||||||
|
if (accountClosed) {
|
||||||
|
res.addProperty("accountClosed", true);
|
||||||
|
}
|
||||||
return gson.toJson(res);
|
return gson.toJson(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the share account when this redemption has just brought its holding to zero — the
|
||||||
|
* account closes immediately on approval, in the same step as the withdrawal itself, rather than
|
||||||
|
* needing a separate closure request to be submitted and approved afterwards.
|
||||||
|
*/
|
||||||
|
private boolean closeShareAccountIfSharesZero(final Long shareAccountId, final String requestedDateStr) {
|
||||||
|
final Long remainingShares = jdbcTemplate.queryForObject("SELECT total_approved_shares FROM m_share_account WHERE id = ?",
|
||||||
|
Long.class, shareAccountId);
|
||||||
|
if (remainingShares == null || remainingShares != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject closePayload = new JsonObject();
|
||||||
|
closePayload.addProperty("locale", "en");
|
||||||
|
closePayload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||||
|
closePayload.addProperty("closedDate", formatForFineract(requestedDateStr));
|
||||||
|
closePayload.addProperty("note", "Closed automatically — all shares withdrawn on approval of this Share Withdrawal Request.");
|
||||||
|
final CommandWrapper closeCommand = new CommandWrapperBuilder().createAccountCommand("share", shareAccountId, "close")
|
||||||
|
.withJson(closePayload.toString()).build();
|
||||||
|
commandsSourceWritePlatformService.logCommandSource(closeCommand);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
private record Settlement(BigDecimal amount, Long savingsTransactionId) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+11
-3
@@ -797,7 +797,9 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the token number is unique for today's date among non-rejected requests.
|
* Ensures the token number is unique for today's date among non-rejected requests, checking
|
||||||
|
* both this table and {@code m_share_dividend_claim_request} -- a dividend cash claim's token
|
||||||
|
* is redeemed at the very same Transaction Entry field, so the two pools must never collide.
|
||||||
* {@code excludeId} is set during resubmit to skip the record being edited.
|
* {@code excludeId} is set during resubmit to skip the record being edited.
|
||||||
*/
|
*/
|
||||||
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
private void validateTokenUniqueToday(final String tokNo, final Long excludeId) {
|
||||||
@@ -810,10 +812,16 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
sql.append(" AND id <> ?");
|
sql.append(" AND id <> ?");
|
||||||
params.add(excludeId);
|
params.add(excludeId);
|
||||||
}
|
}
|
||||||
final long count = Optional.ofNullable(
|
final long ownCount = Optional.ofNullable(
|
||||||
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray())
|
||||||
).orElse(0L);
|
).orElse(0L);
|
||||||
if (count > 0) {
|
final long otherCount = Optional.ofNullable(
|
||||||
|
jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_share_dividend_claim_request "
|
||||||
|
+ "WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE AND status <> 'REJECTED'",
|
||||||
|
Long.class, tokNo)
|
||||||
|
).orElse(0L);
|
||||||
|
if (ownCount + otherCount > 0) {
|
||||||
throw apiError(400,
|
throw apiError(400,
|
||||||
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
"Token number '" + tokNo + "' is already used today. Please enter a different token number.");
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-3
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.client.api;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
@@ -31,7 +32,9 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.Context;
|
import jakarta.ws.rs.core.Context;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.core.UriInfo;
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
import org.apache.fineract.infrastructure.core.api.ApiRequestParameterHelper;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
@@ -43,8 +46,11 @@ import org.apache.fineract.infrastructure.core.service.Page;
|
|||||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientPendingEditRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
|
import org.apache.fineract.portfolio.client.service.ClientEditWritePlatformService;
|
||||||
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
import org.apache.fineract.portfolio.client.service.ClientPendingEditReadPlatformService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/clients")
|
@Path("/v1/clients")
|
||||||
@@ -56,9 +62,21 @@ import org.springframework.stereotype.Component;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ClientEditApiResource {
|
public class ClientEditApiResource {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ClientEditApiResource.class);
|
||||||
|
|
||||||
|
/** Standard 1×1 transparent GIF — returned by the tracking-pixel endpoint regardless of
|
||||||
|
* whether the token matched, to avoid leaking token validity via response differences. */
|
||||||
|
private static final byte[] TRANSPARENT_GIF = {
|
||||||
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, (byte) 0x80, 0x00, 0x00,
|
||||||
|
(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00, 0x00, 0x00, 0x21, (byte) 0xF9, 0x04, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
|
||||||
|
};
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
private final ClientEditWritePlatformService clientEditWritePlatformService;
|
||||||
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
private final ClientPendingEditReadPlatformService clientPendingEditReadPlatformService;
|
||||||
|
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
private final ApiRequestParameterHelper apiRequestParameterHelper;
|
||||||
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
|
||||||
@@ -124,14 +142,26 @@ public class ClientEditApiResource {
|
|||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
@Operation(summary = "Approve a pending Client details edit",
|
@Operation(summary = "Approve a pending Client details edit",
|
||||||
description = "Applies the proposed changes to the client. The maker of the edit cannot also approve it — no exceptions.")
|
description = "Applies the proposed changes to the client and sends a confirmation email to the client. "
|
||||||
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId) {
|
+ "The maker of the edit cannot also approve it — no exceptions.")
|
||||||
|
public String approvePendingEdit(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||||
|
@Context final HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
if (this.context.authenticatedUser().hasNotPermissionForAnyOf("APPROVEEDIT_CLIENT")) {
|
||||||
throw new NoAuthorizationException("User has no authority to approve client edits");
|
throw new NoAuthorizationException("User has no authority to approve client edits");
|
||||||
}
|
}
|
||||||
|
|
||||||
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId);
|
// Derive base URL from the incoming request so the tracking pixel URL is absolute and
|
||||||
|
// reachable by the client's email client without needing a configured server.url property.
|
||||||
|
final String baseUrl = httpRequest.getScheme() + "://" + httpRequest.getServerName()
|
||||||
|
+ (httpRequest.getServerPort() == 443 || httpRequest.getServerPort() == 80 ? ""
|
||||||
|
: ":" + httpRequest.getServerPort());
|
||||||
|
|
||||||
|
// Tenant ID from the Fineract-specific header (same header the frontend sends).
|
||||||
|
final String tenantId = httpRequest.getHeader("Fineract-Platform-TenantId");
|
||||||
|
|
||||||
|
final CommandProcessingResult result = this.clientEditWritePlatformService.approveEdit(clientId, baseUrl,
|
||||||
|
tenantId != null ? tenantId : "default");
|
||||||
|
|
||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
@@ -161,4 +191,55 @@ public class ClientEditApiResource {
|
|||||||
|
|
||||||
return this.toApiJsonSerializer.serialize(result);
|
return this.toApiJsonSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Email preview (authenticated) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{clientId}/edit/{pendingEditId}/email-preview")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve the email that was sent to the client when their edit was approved",
|
||||||
|
description = "Returns the exact subject, HTML body, sent timestamp and opened timestamp for the given "
|
||||||
|
+ "pending edit record. Returns 404 if the record does not exist or does not belong to the given client.")
|
||||||
|
public Response emailPreview(@PathParam("clientId") @Parameter(description = "clientId") final Long clientId,
|
||||||
|
@PathParam("pendingEditId") @Parameter(description = "pendingEditId") final Long pendingEditId) {
|
||||||
|
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
|
final Map<String, Object> preview = this.clientPendingEditReadPlatformService.retrieveEmailPreview(clientId, pendingEditId);
|
||||||
|
if (preview == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
return Response.ok(preview, MediaType.APPLICATION_JSON).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tracking pixel (PUBLIC — no auth) ─────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("edit/pixel/{token}")
|
||||||
|
@Produces("image/gif")
|
||||||
|
@Operation(summary = "Email open tracking pixel",
|
||||||
|
description = "Public endpoint — loaded by the client's email client when they open the approval confirmation "
|
||||||
|
+ "email. Records email_opened_at on the matching pending-edit row. Always returns a 1×1 transparent GIF "
|
||||||
|
+ "regardless of whether the token matched, to avoid leaking token validity.")
|
||||||
|
public Response trackEmailOpen(@PathParam("token") final String token) {
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
|
try {
|
||||||
|
final var pendingEditOpt = pendingEditRepository.findByEmailTrackingToken(token);
|
||||||
|
if (pendingEditOpt.isPresent()) {
|
||||||
|
final var pendingEdit = pendingEditOpt.get();
|
||||||
|
pendingEdit.markEmailOpened();
|
||||||
|
pendingEditRepository.saveAndFlush(pendingEdit);
|
||||||
|
LOG.debug("Email open recorded for pending edit {} via token", pendingEdit.getId());
|
||||||
|
}
|
||||||
|
} catch (final Exception e) {
|
||||||
|
// Never fail a pixel request — email clients don't handle errors well.
|
||||||
|
LOG.warn("Error recording email open for token {}: {}", token, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.ok(TRANSPARENT_GIF, "image/gif")
|
||||||
|
.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
.header("Pragma", "no-cache")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
@@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||||
|
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||||
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
|
import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService;
|
||||||
|
import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Path("/v1/kyc/risk-rules")
|
@Path("/v1/kyc/risk-rules")
|
||||||
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
|
|||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final KycRiskRulesReadPlatformService readPlatformService;
|
private final KycRiskRulesReadPlatformService readPlatformService;
|
||||||
|
private final KycRiskRulesWritePlatformService writePlatformService;
|
||||||
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
|
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
|
|||||||
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
|
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
|
||||||
return this.toApiJsonSerializer.serialize(rules);
|
return this.toApiJsonSerializer.serialize(rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Add a KYC/AML risk rule",
|
||||||
|
description = "Appends a rule to the catalog. Supplying any transaction band or observation window also "
|
||||||
|
+ "creates the matching live-transaction rule, linked back to the new catalog entry. Returns the "
|
||||||
|
+ "persisted rule in the same shape as the catalog GET.")
|
||||||
|
public String createRiskRule(final KycRiskRuleRequest request) {
|
||||||
|
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||||
|
return this.toApiJsonSerializer.serialize(rule);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
|||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
|
|
||||||
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
|
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
|
||||||
final EmailTemplateService emailTemplateService) {
|
final EmailTemplateService emailTemplateService,
|
||||||
|
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.otpLogRepository = otpLogRepository;
|
this.otpLogRepository = otpLogRepository;
|
||||||
this.emailSenderService = emailSenderService;
|
this.emailSenderService = emailSenderService;
|
||||||
this.emailTemplateService = emailTemplateService;
|
this.emailTemplateService = emailTemplateService;
|
||||||
|
this.mobileLookupService = mobileLookupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate ─────────────────────────────────────────────────────────────
|
// ── Generate ─────────────────────────────────────────────────────────────
|
||||||
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
|||||||
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
|
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
|
||||||
|
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
|
||||||
|
// credential, so this is refused at the point of dispatch rather than merely warned about
|
||||||
|
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
|
||||||
|
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
|
||||||
|
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
|
||||||
|
return OtpGenerateResponse.builder().success(false)
|
||||||
|
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
|
||||||
|
+ "Contact the customer through an alternate channel and obtain a new number.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
final OtpLog log = new OtpLog();
|
final OtpLog log = new OtpLog();
|
||||||
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
||||||
log.setProvider(PROVIDER_LINCPAY);
|
log.setProvider(PROVIDER_LINCPAY);
|
||||||
|
|||||||
+6
-4
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
||||||
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
|
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
|
||||||
authorizedSignatoryParamName, additionalInfoParamName, dateOfRegistrationParamName, datatables));
|
authorizedSignatoryParamName, authorizedSignatoryClientIdsParamName, additionalInfoParamName,
|
||||||
|
dateOfRegistrationParamName, datatables));
|
||||||
|
|
||||||
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
|
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
|
||||||
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
||||||
@@ -65,12 +66,13 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
alternateDipVerifiedParamName, alternateDipLookupLogIdParamName, alternateDipTimestampParamName,
|
||||||
nameVerifiedParamName, screeningLogIdParamName,
|
nameVerifiedParamName, screeningLogIdParamName,
|
||||||
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
submittedOnDateParamName, clientNonPersonDetailsParamName, displaynameParamName, legalFormIdParamName, isStaffParamName,
|
||||||
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName));
|
mobileOldOtpIdParamName, mobileNewOtpIdParamName, emailOldOtpIdParamName, emailNewOtpIdParamName,
|
||||||
|
"oldMobileVerificationExceptionDocumentId"));
|
||||||
|
|
||||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||||
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
|
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
|
||||||
dateOfRegistrationParamName));
|
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
||||||
|
|||||||
+5
@@ -533,6 +533,11 @@ public final class ClientDataValidator {
|
|||||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
baseDataValidator.reset().parameter(ClientApiConstants.mobileOldOtpIdParamName).value(mobileOldOtpId).ignoreIfNull()
|
||||||
.longGreaterThanZero();
|
.longGreaterThanZero();
|
||||||
}
|
}
|
||||||
|
if (this.fromApiJsonHelper.parameterExists("oldMobileVerificationExceptionDocumentId", element)) {
|
||||||
|
final Long exceptionDocId = this.fromApiJsonHelper.extractLongNamed("oldMobileVerificationExceptionDocumentId", element);
|
||||||
|
baseDataValidator.reset().parameter("oldMobileVerificationExceptionDocumentId").value(exceptionDocId).ignoreIfNull()
|
||||||
|
.longGreaterThanZero();
|
||||||
|
}
|
||||||
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
|
if (this.fromApiJsonHelper.parameterExists(ClientApiConstants.mobileNewOtpIdParamName, element)) {
|
||||||
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
final Long mobileNewOtpId = this.fromApiJsonHelper.extractLongNamed(ClientApiConstants.mobileNewOtpIdParamName, element);
|
||||||
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
baseDataValidator.reset().parameter(ClientApiConstants.mobileNewOtpIdParamName).value(mobileNewOtpId).ignoreIfNull()
|
||||||
|
|||||||
+32
-2
@@ -53,6 +53,22 @@ public final class ClientKycApiConstants {
|
|||||||
|
|
||||||
// ── Extended KYC fields ──────────────────────────────────────────────────
|
// ── Extended KYC fields ──────────────────────────────────────────────────
|
||||||
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus";
|
public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus";
|
||||||
|
|
||||||
|
// ── PAN declaration (accepted when the PAN check cannot pass) ─────────────
|
||||||
|
public static final String panDeclarationTakenParam = "panDeclarationTaken";
|
||||||
|
public static final String panDeclarationMismatchFieldsParam = "panDeclarationMismatchFields";
|
||||||
|
public static final String panDeclarationNameOnPanParam = "panDeclarationNameOnPan";
|
||||||
|
public static final String panDeclarationDobOnPanParam = "panDeclarationDobOnPan";
|
||||||
|
public static final String panDeclarationRemarksParam = "panDeclarationRemarks";
|
||||||
|
public static final String panDeclarationTakenOnParam = "panDeclarationTakenOn";
|
||||||
|
|
||||||
|
// ── Aadhaar enrolment receipt (accepted when Aadhaar is not yet issued) ───
|
||||||
|
public static final String aadhaarReceiptTakenParam = "aadhaarReceiptTaken";
|
||||||
|
public static final String aadhaarEnrolmentIdParam = "aadhaarEnrolmentId";
|
||||||
|
public static final String aadhaarEnrolmentDateParam = "aadhaarEnrolmentDate";
|
||||||
|
public static final String aadhaarReceiptRemarksParam = "aadhaarReceiptRemarks";
|
||||||
|
public static final String aadhaarReceiptTakenOnParam = "aadhaarReceiptTakenOn";
|
||||||
|
|
||||||
public static final String enterprisePanParam = "enterprisePan";
|
public static final String enterprisePanParam = "enterprisePan";
|
||||||
public static final String cinNumberParam = "cinNumber";
|
public static final String cinNumberParam = "cinNumber";
|
||||||
public static final String dinNumberParam = "dinNumber";
|
public static final String dinNumberParam = "dinNumber";
|
||||||
@@ -72,6 +88,10 @@ public final class ClientKycApiConstants {
|
|||||||
public static final String accountStatusParam = "accountStatus";
|
public static final String accountStatusParam = "accountStatus";
|
||||||
public static final String freezeReasonParam = "freezeReason";
|
public static final String freezeReasonParam = "freezeReason";
|
||||||
public static final String freezeAuthorityParam = "freezeAuthority";
|
public static final String freezeAuthorityParam = "freezeAuthority";
|
||||||
|
public static final String freezeDateParam = "freezeDate";
|
||||||
|
public static final String freezeAmountParam = "freezeAmount";
|
||||||
|
public static final String unfreezeDateParam = "unfreezeDate";
|
||||||
|
public static final String noDebitDateParam = "noDebitDate";
|
||||||
public static final String form60TypeParam = "form60Type";
|
public static final String form60TypeParam = "form60Type";
|
||||||
public static final String form60DateParam = "form60Date";
|
public static final String form60DateParam = "form60Date";
|
||||||
public static final String form15ApplicableParam = "form15Applicable";
|
public static final String form15ApplicableParam = "form15Applicable";
|
||||||
@@ -90,9 +110,14 @@ public final class ClientKycApiConstants {
|
|||||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||||
|
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
|
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
|
||||||
|
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||||
|
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||||
|
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||||
|
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||||
|
|
||||||
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||||
@@ -101,9 +126,14 @@ public final class ClientKycApiConstants {
|
|||||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||||
|
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||||
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam,
|
||||||
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam));
|
panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam,
|
||||||
|
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
|
||||||
|
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
|
||||||
|
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
|
||||||
|
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
|
||||||
|
|
||||||
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
|
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
|
||||||
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||||
|
|||||||
+30
@@ -19,6 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.client.data;
|
package org.apache.fineract.portfolio.client.data;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@@ -52,11 +53,29 @@ public final class ClientKycData {
|
|||||||
private final String panCategory;
|
private final String panCategory;
|
||||||
private final String panStatus;
|
private final String panStatus;
|
||||||
|
|
||||||
|
// ── PAN declaration ───────────────────────────────────────────────────────
|
||||||
|
/** Signed customer declaration accepted in place of a passing PAN check. */
|
||||||
|
private final Boolean panDeclarationTaken;
|
||||||
|
/** "NAME", "DOB", "NAME,DOB", or "NONE" when PAN could not be checked at all. */
|
||||||
|
private final String panDeclarationMismatchFields;
|
||||||
|
private final String panDeclarationNameOnPan;
|
||||||
|
private final LocalDate panDeclarationDobOnPan;
|
||||||
|
private final String panDeclarationRemarks;
|
||||||
|
private final LocalDate panDeclarationTakenOn;
|
||||||
|
|
||||||
// ── Aadhaar ───────────────────────────────────────────────────────────────
|
// ── Aadhaar ───────────────────────────────────────────────────────────────
|
||||||
private final String aadhaarNumberMasked;
|
private final String aadhaarNumberMasked;
|
||||||
private final Boolean aadhaarVerified;
|
private final Boolean aadhaarVerified;
|
||||||
private final LocalDate aadhaarVerifiedOn;
|
private final LocalDate aadhaarVerifiedOn;
|
||||||
|
|
||||||
|
// ── Aadhaar enrolment receipt ─────────────────────────────────────────────
|
||||||
|
/** Enrolment slip accepted for a customer who has applied for Aadhaar but holds none yet. */
|
||||||
|
private final Boolean aadhaarReceiptTaken;
|
||||||
|
private final String aadhaarEnrolmentId;
|
||||||
|
private final LocalDate aadhaarEnrolmentDate;
|
||||||
|
private final String aadhaarReceiptRemarks;
|
||||||
|
private final LocalDate aadhaarReceiptTakenOn;
|
||||||
|
|
||||||
// ── GSTIN ─────────────────────────────────────────────────────────────────
|
// ── GSTIN ─────────────────────────────────────────────────────────────────
|
||||||
private final String gstinNumber;
|
private final String gstinNumber;
|
||||||
private final Boolean gstinVerified;
|
private final Boolean gstinVerified;
|
||||||
@@ -65,7 +84,14 @@ public final class ClientKycData {
|
|||||||
// ── Enterprise / Non-Individual ──────────────────────────────────────────
|
// ── Enterprise / Non-Individual ──────────────────────────────────────────
|
||||||
private final String enterprisePan;
|
private final String enterprisePan;
|
||||||
private final String cinNumber;
|
private final String cinNumber;
|
||||||
|
private final Boolean cinVerified;
|
||||||
|
private final LocalDate cinVerifiedOn;
|
||||||
private final LocalDate incorporationDate;
|
private final LocalDate incorporationDate;
|
||||||
|
// DIN of a director — read back so an entity re-opened for edit can show the verification
|
||||||
|
// already obtained for each authorized signatory instead of asking for it again.
|
||||||
|
private final String dinNumber;
|
||||||
|
private final Boolean dinVerified;
|
||||||
|
private final LocalDate dinVerifiedOn;
|
||||||
|
|
||||||
// ── KYC Document ─────────────────────────────────────────────────────────
|
// ── KYC Document ─────────────────────────────────────────────────────────
|
||||||
private final String kycDocumentName;
|
private final String kycDocumentName;
|
||||||
@@ -84,6 +110,10 @@ public final class ClientKycData {
|
|||||||
private final String accountStatus;
|
private final String accountStatus;
|
||||||
private final String freezeReason;
|
private final String freezeReason;
|
||||||
private final String freezeAuthority;
|
private final String freezeAuthority;
|
||||||
|
private final LocalDate freezeDate;
|
||||||
|
private final BigDecimal freezeAmount;
|
||||||
|
private final LocalDate unfreezeDate;
|
||||||
|
private final LocalDate noDebitDate;
|
||||||
|
|
||||||
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
// ── Form 60 / Form 15G/15H ───────────────────────────────────────────────
|
||||||
private final String form60Type;
|
private final String form60Type;
|
||||||
|
|||||||
+52
@@ -19,6 +19,7 @@
|
|||||||
package org.apache.fineract.portfolio.client.data;
|
package org.apache.fineract.portfolio.client.data;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +52,45 @@ public class ClientKycRequest {
|
|||||||
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
|
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
|
||||||
private String panAadhaarSeedingStatus;
|
private String panAadhaarSeedingStatus;
|
||||||
|
|
||||||
|
@Schema(description = "True when a signed customer declaration has been accepted in place of a passing "
|
||||||
|
+ "PAN check. Suppresses the automatic 'PAN Card Verification Failed' KYC exception.", example = "true")
|
||||||
|
private Boolean panDeclarationTaken;
|
||||||
|
|
||||||
|
@Schema(description = "Which client details differ from the PAN card: NAME, DOB, NAME,DOB, or NONE when "
|
||||||
|
+ "PAN could not be checked at all", example = "NAME")
|
||||||
|
private String panDeclarationMismatchFields;
|
||||||
|
|
||||||
|
@Schema(description = "Name exactly as printed on the physical PAN card", example = "RAJESH KUMAR SHARMA")
|
||||||
|
private String panDeclarationNameOnPan;
|
||||||
|
|
||||||
|
@Schema(description = "Date of birth exactly as printed on the physical PAN card", example = "12 May 1988")
|
||||||
|
private String panDeclarationDobOnPan;
|
||||||
|
|
||||||
|
@Schema(description = "Officer's note on why the mismatch exists and what proof was sighted",
|
||||||
|
example = "Client's PAN carries expanded middle name; passport sighted.")
|
||||||
|
private String panDeclarationRemarks;
|
||||||
|
|
||||||
|
@Schema(description = "Date the declaration was accepted", example = "07 August 2026")
|
||||||
|
private String panDeclarationTakenOn;
|
||||||
|
|
||||||
|
@Schema(description = "True when the customer's Aadhaar enrolment slip has been sighted and recorded, for a "
|
||||||
|
+ "customer who has applied for Aadhaar but does not hold one yet. Suppresses the automatic "
|
||||||
|
+ "'Aadhaar Verification Failed' KYC exception.", example = "true")
|
||||||
|
private Boolean aadhaarReceiptTaken;
|
||||||
|
|
||||||
|
@Schema(description = "28-digit Enrolment ID (EID) printed on the UIDAI enrolment slip",
|
||||||
|
example = "1234/12345/12345 12/05/2026 11:30:15")
|
||||||
|
private String aadhaarEnrolmentId;
|
||||||
|
|
||||||
|
@Schema(description = "Enrolment date printed on the slip", example = "12 May 2026")
|
||||||
|
private String aadhaarEnrolmentDate;
|
||||||
|
|
||||||
|
@Schema(description = "Officer's note on what was sighted", example = "Original UIDAI enrolment slip sighted.")
|
||||||
|
private String aadhaarReceiptRemarks;
|
||||||
|
|
||||||
|
@Schema(description = "Date the receipt was accepted", example = "07 August 2026")
|
||||||
|
private String aadhaarReceiptTakenOn;
|
||||||
|
|
||||||
@Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
|
@Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F")
|
||||||
private String enterprisePan;
|
private String enterprisePan;
|
||||||
|
|
||||||
@@ -96,6 +136,18 @@ public class ClientKycRequest {
|
|||||||
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
|
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
|
||||||
private String freezeAuthority;
|
private String freezeAuthority;
|
||||||
|
|
||||||
|
@Schema(description = "Date of account freeze (null if not frozen)")
|
||||||
|
private String freezeDate;
|
||||||
|
|
||||||
|
@Schema(description = "Amount frozen (null if not frozen)")
|
||||||
|
private BigDecimal freezeAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Date the account was unfrozen (null unless accountStatus is UNFREEZE)")
|
||||||
|
private String unfreezeDate;
|
||||||
|
|
||||||
|
@Schema(description = "Date the no-debit restriction takes effect (null unless accountStatus is NO_DEBIT)")
|
||||||
|
private String noDebitDate;
|
||||||
|
|
||||||
@Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
|
@Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60")
|
||||||
private String form60Type;
|
private String form60Type;
|
||||||
|
|
||||||
|
|||||||
+14
@@ -41,4 +41,18 @@ public class ClientPendingEditData {
|
|||||||
private final String checkerName;
|
private final String checkerName;
|
||||||
private final OffsetDateTime checkedDate;
|
private final OffsetDateTime checkedDate;
|
||||||
private final String rejectReason;
|
private final String rejectReason;
|
||||||
|
|
||||||
|
// ── Email tracking fields ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether a confirmation email was successfully sent to the client after approval. */
|
||||||
|
private final Boolean emailSent;
|
||||||
|
|
||||||
|
/** Timestamp of the successful send. Null when {@code emailSent} is false. */
|
||||||
|
private final OffsetDateTime emailSentAt;
|
||||||
|
|
||||||
|
/** Timestamp of the first pixel fetch by the client's email client. Null until opened. */
|
||||||
|
private final OffsetDateTime emailOpenedAt;
|
||||||
|
|
||||||
|
// emailSubject and emailBody are deliberately NOT included in the list DTO —
|
||||||
|
// they can be large (TEXT) and are only fetched via the email-preview endpoint.
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-1
@@ -46,6 +46,20 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
private final String willfulDefaulter;
|
private final String willfulDefaulter;
|
||||||
private final String ecgcSalFlag;
|
private final String ecgcSalFlag;
|
||||||
private final String dormantFlag;
|
private final String dormantFlag;
|
||||||
|
/** Populated only for rules that also have a live-transaction-monitoring counterpart in
|
||||||
|
* {@code m_aml_transaction_rule} (linked via source_client_risk_rule_id) - distinct from the
|
||||||
|
* client-onboarding income/turnover fields above, which drive KYC-time risk categorization. */
|
||||||
|
private final BigDecimal txnThresholdAmount;
|
||||||
|
private final String txnRuleType;
|
||||||
|
private final Integer txnWindowDays;
|
||||||
|
/** Risk-category-tiered overrides of {@link #txnThresholdAmount}, populated only when the
|
||||||
|
* linked {@code m_aml_transaction_rule} row has tiering configured (e.g. RTGS monitoring). */
|
||||||
|
private final BigDecimal txnThresholdAmountMediumRisk;
|
||||||
|
private final BigDecimal txnThresholdAmountHighRisk;
|
||||||
|
/** Declarative band the rule covers, for display on the AML Rules Master screen. Never
|
||||||
|
* used in matching; a null maximum means the rule is genuinely open-ended. */
|
||||||
|
private final BigDecimal txnMinLimitAmount;
|
||||||
|
private final BigDecimal txnMaxLimitAmount;
|
||||||
|
|
||||||
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
|
public KycRiskRuleData(final Long id, final String ruleName, final Integer legalFormId,
|
||||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||||
@@ -55,7 +69,10 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
|
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover,
|
||||||
final Integer riskCategory, final String reason, final Integer orderPosition,
|
final Integer riskCategory, final String reason, final Integer orderPosition,
|
||||||
final String amlCheckStatus, final String bannedEntityMatch,
|
final String amlCheckStatus, final String bannedEntityMatch,
|
||||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag) {
|
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||||
|
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||||
|
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||||
|
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.ruleName = ruleName;
|
this.ruleName = ruleName;
|
||||||
this.legalFormId = legalFormId;
|
this.legalFormId = legalFormId;
|
||||||
@@ -77,6 +94,13 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
this.willfulDefaulter = willfulDefaulter;
|
this.willfulDefaulter = willfulDefaulter;
|
||||||
this.ecgcSalFlag = ecgcSalFlag;
|
this.ecgcSalFlag = ecgcSalFlag;
|
||||||
this.dormantFlag = dormantFlag;
|
this.dormantFlag = dormantFlag;
|
||||||
|
this.txnThresholdAmount = txnThresholdAmount;
|
||||||
|
this.txnRuleType = txnRuleType;
|
||||||
|
this.txnWindowDays = txnWindowDays;
|
||||||
|
this.txnThresholdAmountMediumRisk = txnThresholdAmountMediumRisk;
|
||||||
|
this.txnThresholdAmountHighRisk = txnThresholdAmountHighRisk;
|
||||||
|
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||||
|
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -162,4 +186,32 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
public String getDormantFlag() {
|
public String getDormantFlag() {
|
||||||
return dormantFlag;
|
return dormantFlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTxnThresholdAmount() {
|
||||||
|
return txnThresholdAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTxnRuleType() {
|
||||||
|
return txnRuleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTxnWindowDays() {
|
||||||
|
return txnWindowDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTxnThresholdAmountMediumRisk() {
|
||||||
|
return txnThresholdAmountMediumRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTxnThresholdAmountHighRisk() {
|
||||||
|
return txnThresholdAmountHighRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTxnMinLimitAmount() {
|
||||||
|
return txnMinLimitAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTxnMaxLimitAmount() {
|
||||||
|
return txnMaxLimitAmount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 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 io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for {@code POST /v1/kyc/risk-rules}.
|
||||||
|
*
|
||||||
|
* Mirrors the columns the AML Rules Master screen displays. The {@code txn*} fields describe the
|
||||||
|
* live-transaction counterpart of the rule; supplying any of them creates a companion row in
|
||||||
|
* {@code m_aml_transaction_rule} linked back to the new {@code m_client_risk_rule} row. Leave them
|
||||||
|
* all blank for a pure KYC-time profile rule (income / turnover / occupation driven).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class KycRiskRuleRequest {
|
||||||
|
|
||||||
|
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String ruleName;
|
||||||
|
|
||||||
|
@Schema(description = "Reason / criteria text explaining why the rule exists",
|
||||||
|
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
@Schema(description = "Risk category the rule assigns: 1 Low, 2 Medium, 3 High",
|
||||||
|
requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
|
||||||
|
private Integer riskCategory;
|
||||||
|
|
||||||
|
@Schema(description = "Position in the catalog. Omit to append after the last existing rule.")
|
||||||
|
private Integer orderPosition;
|
||||||
|
|
||||||
|
@Schema(description = "Legal form the rule is restricted to (1 Person, 2 Entity). Null applies to all.")
|
||||||
|
private Integer legalFormId;
|
||||||
|
|
||||||
|
@Schema(description = "Occupation category code value the rule is restricted to")
|
||||||
|
private Long occupationCategoryCvId;
|
||||||
|
|
||||||
|
@Schema(description = "Occupation code value the rule is restricted to")
|
||||||
|
private Long occupationCvId;
|
||||||
|
|
||||||
|
private BigDecimal minMonthlyIncome;
|
||||||
|
private BigDecimal maxMonthlyIncome;
|
||||||
|
private BigDecimal minAnnualIncome;
|
||||||
|
private BigDecimal maxAnnualIncome;
|
||||||
|
private BigDecimal minAnnualTurnover;
|
||||||
|
private BigDecimal maxAnnualTurnover;
|
||||||
|
|
||||||
|
@Schema(description = "AML check status the rule records", example = "FLAGGED")
|
||||||
|
private String amlCheckStatus;
|
||||||
|
|
||||||
|
@Schema(description = "Banned entity match flag", example = "NO")
|
||||||
|
private String bannedEntityMatch;
|
||||||
|
|
||||||
|
@Schema(description = "Willful defaulter flag", example = "NO")
|
||||||
|
private String willfulDefaulter;
|
||||||
|
|
||||||
|
@Schema(description = "ECGC/SAL flag", example = "NO")
|
||||||
|
private String ecgcSalFlag;
|
||||||
|
|
||||||
|
@Schema(description = "Dormancy flag", example = "ACTIVE")
|
||||||
|
private String dormantFlag;
|
||||||
|
|
||||||
|
@Schema(description = "Lower bound of the transaction band the rule covers (display only)")
|
||||||
|
private BigDecimal txnMinLimitAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Upper bound of the transaction band. Null means the rule is open-ended.")
|
||||||
|
private BigDecimal txnMaxLimitAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Amount at which the rule fires on a live transaction. Defaults to the "
|
||||||
|
+ "lower bound of the band when omitted.")
|
||||||
|
private BigDecimal txnThresholdAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Observation window in days. Omit or 0 for per-transaction.", example = "30")
|
||||||
|
private Integer txnWindowDays;
|
||||||
|
|
||||||
|
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
||||||
|
private String txnRuleType;
|
||||||
|
|
||||||
|
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
|
||||||
|
private String txnAppliesTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
||||||
|
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
||||||
|
*/
|
||||||
|
public boolean hasTransactionRule() {
|
||||||
|
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
||||||
|
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_family_member_children")
|
||||||
|
public class ClientFamilyMemberChildren extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "family_member_id", nullable = false)
|
||||||
|
private ClientFamilyMembers familyMember;
|
||||||
|
|
||||||
|
@Column(name = "full_name", nullable = false)
|
||||||
|
private String fullName;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "gender_cv_id", nullable = false)
|
||||||
|
private CodeValue gender;
|
||||||
|
|
||||||
|
@Column(name = "date_of_birth", nullable = true)
|
||||||
|
private LocalDate dateOfBirth;
|
||||||
|
|
||||||
|
protected ClientFamilyMemberChildren() {
|
||||||
|
// Default constructor
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientFamilyMemberChildren(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||||
|
final LocalDate dateOfBirth) {
|
||||||
|
this.familyMember = familyMember;
|
||||||
|
this.fullName = fullName;
|
||||||
|
this.gender = gender;
|
||||||
|
this.dateOfBirth = dateOfBirth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ClientFamilyMemberChildren fromJson(final ClientFamilyMembers familyMember, final String fullName, final CodeValue gender,
|
||||||
|
final LocalDate dateOfBirth) {
|
||||||
|
return new ClientFamilyMemberChildren(familyMember, fullName, gender, dateOfBirth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientFamilyMembers getFamilyMember() {
|
||||||
|
return this.familyMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFamilyMember(ClientFamilyMembers familyMember) {
|
||||||
|
this.familyMember = familyMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFullName() {
|
||||||
|
return this.fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFullName(String fullName) {
|
||||||
|
this.fullName = fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CodeValue getGender() {
|
||||||
|
return this.gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGender(CodeValue gender) {
|
||||||
|
this.gender = gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getDateOfBirth() {
|
||||||
|
return this.dateOfBirth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDateOfBirth(LocalDate dateOfBirth) {
|
||||||
|
this.dateOfBirth = dateOfBirth;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -151,6 +151,9 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "modified_on")
|
@Column(name = "modified_on")
|
||||||
private LocalDateTime modifiedOn;
|
private LocalDateTime modifiedOn;
|
||||||
|
|
||||||
|
@jakarta.persistence.OneToMany(cascade = jakarta.persistence.CascadeType.ALL, orphanRemoval = true, fetch = jakarta.persistence.FetchType.EAGER, mappedBy = "familyMember")
|
||||||
|
private java.util.List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||||
|
|
||||||
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
|
private ClientFamilyMembers(final Client client, final String firstName, final String middleName, final String lastName,
|
||||||
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
|
final String qualification, final String mobileNumber, final Long age, final Boolean isDependent, final CodeValue relationship,
|
||||||
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
|
final CodeValue maritalStatus, final CodeValue gender, final LocalDate dateOfBirth, final CodeValue profession,
|
||||||
@@ -356,4 +359,19 @@ public class ClientFamilyMembers extends AbstractPersistableCustom<Long> {
|
|||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public Long getModifiedBy() { return modifiedBy; }
|
public Long getModifiedBy() { return modifiedBy; }
|
||||||
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
public LocalDateTime getModifiedOn() { return modifiedOn; }
|
||||||
|
|
||||||
|
public java.util.List<ClientFamilyMemberChildren> getChildren() {
|
||||||
|
return this.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChildren(java.util.List<ClientFamilyMemberChildren> children) {
|
||||||
|
if (this.children == null) {
|
||||||
|
this.children = new java.util.ArrayList<>();
|
||||||
|
} else {
|
||||||
|
this.children.clear();
|
||||||
|
}
|
||||||
|
if (children != null) {
|
||||||
|
this.children.addAll(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-3
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "authorized_signatory", length = 500, nullable = true)
|
@Column(name = "authorized_signatory", length = 500, nullable = true)
|
||||||
private String authorizedSignatory;
|
private String authorizedSignatory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client ids of the signatories named in {@link #authorizedSignatory}, comma separated and in the
|
||||||
|
* same order. Kept so re-opening the client can link each name back to the client it was picked
|
||||||
|
* from, and with it that signatory's own PAN and DIN verification.
|
||||||
|
*/
|
||||||
|
@Column(name = "authorized_signatory_client_ids", length = 500, nullable = true)
|
||||||
|
private String authorizedSignatoryClientIds;
|
||||||
|
|
||||||
@Column(name = "additional_info", length = 500, nullable = true)
|
@Column(name = "additional_info", length = 500, nullable = true)
|
||||||
private String additionalInfo;
|
private String additionalInfo;
|
||||||
|
|
||||||
@@ -99,9 +107,9 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
|
|
||||||
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
|
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
|
||||||
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
|
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||||
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
|
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
|
||||||
additionalInfo, dateOfRegistration);
|
additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ClientNonPerson() {
|
protected ClientNonPerson() {
|
||||||
@@ -110,7 +118,7 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
|
|
||||||
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
|
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
|
||||||
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
|
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
|
||||||
final LocalDate dateOfRegistration) {
|
final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
this.authorizedSignatory = authorizedSignatory.trim();
|
this.authorizedSignatory = authorizedSignatory.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(additionalInfo)) {
|
if (StringUtils.isNotBlank(additionalInfo)) {
|
||||||
this.additionalInfo = additionalInfo.trim();
|
this.additionalInfo = additionalInfo.trim();
|
||||||
}
|
}
|
||||||
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
|
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName,
|
||||||
|
this.authorizedSignatoryClientIds)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName);
|
||||||
|
actualChanges.put(ClientApiConstants.authorizedSignatoryClientIdsParamName, newValue);
|
||||||
|
this.authorizedSignatoryClientIds = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
|
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
|
||||||
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
|
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
|
||||||
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
|
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
|
||||||
|
|||||||
+54
@@ -89,6 +89,41 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "pending_lock_key", length = 60, unique = true)
|
@Column(name = "pending_lock_key", length = 60, unique = true)
|
||||||
private String pendingLockKey;
|
private String pendingLockKey;
|
||||||
|
|
||||||
|
// ── Email tracking ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Random UUID generated at send time; embedded in the 1×1 tracking-pixel URL so the public
|
||||||
|
* pixel endpoint never exposes integer IDs. Null when no email has been sent yet. */
|
||||||
|
@Column(name = "email_tracking_token", length = 36, unique = true)
|
||||||
|
private String emailTrackingToken;
|
||||||
|
|
||||||
|
/** TRUE only after sendEmailWithAttachment() returns without throwing. */
|
||||||
|
@Column(name = "email_sent", nullable = false)
|
||||||
|
private Boolean emailSent = Boolean.FALSE;
|
||||||
|
|
||||||
|
/** Timestamp of the successful send. Null when emailSent is false. */
|
||||||
|
@Column(name = "email_sent_at")
|
||||||
|
private OffsetDateTime emailSentAt;
|
||||||
|
|
||||||
|
/** Set by the public tracking-pixel endpoint the first time the client's email client loads
|
||||||
|
* the 1×1 img tag. Null until opened. */
|
||||||
|
@Column(name = "email_opened_at")
|
||||||
|
private OffsetDateTime emailOpenedAt;
|
||||||
|
|
||||||
|
/** Exact subject line that was sent — stored so the "View Email" dialog can display it
|
||||||
|
* without regenerating the template. */
|
||||||
|
@Column(name = "email_subject", length = 300)
|
||||||
|
private String emailSubject;
|
||||||
|
|
||||||
|
/** Exact HTML body that was sent — stored alongside email_subject. */
|
||||||
|
@Column(name = "email_body", columnDefinition = "TEXT")
|
||||||
|
private String emailBody;
|
||||||
|
|
||||||
|
/** Exact sender address configured at the time the email was dispatched. */
|
||||||
|
@Column(name = "email_from", length = 255)
|
||||||
|
private String emailFrom;
|
||||||
|
|
||||||
|
// ── Factory / lifecycle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
public static ClientPendingEdit submit(final Client client, final String payloadJson, final String originalJson,
|
||||||
final AppUser maker) {
|
final AppUser maker) {
|
||||||
final ClientPendingEdit edit = new ClientPendingEdit();
|
final ClientPendingEdit edit = new ClientPendingEdit();
|
||||||
@@ -99,6 +134,7 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
edit.makerId = maker.getId();
|
edit.makerId = maker.getId();
|
||||||
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
edit.submittedDate = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
edit.pendingLockKey = lockKeyFor(client.getId());
|
edit.pendingLockKey = lockKeyFor(client.getId());
|
||||||
|
edit.emailSent = Boolean.FALSE;
|
||||||
return edit;
|
return edit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,4 +165,22 @@ public class ClientPendingEdit extends AbstractPersistableCustom<Long> {
|
|||||||
this.rejectReason = reason;
|
this.rejectReason = reason;
|
||||||
this.pendingLockKey = null;
|
this.pendingLockKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called after a successful email send. Records the token (for pixel tracking), the exact
|
||||||
|
* subject and body, and marks the row as sent. */
|
||||||
|
public void markEmailSent(final String token, final String subject, final String body, final String fromAddress) {
|
||||||
|
this.emailTrackingToken = token;
|
||||||
|
this.emailSubject = subject;
|
||||||
|
this.emailBody = body;
|
||||||
|
this.emailFrom = fromAddress;
|
||||||
|
this.emailSent = Boolean.TRUE;
|
||||||
|
this.emailSentAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the tracking-pixel endpoint. No-op if already opened (idempotent). */
|
||||||
|
public void markEmailOpened() {
|
||||||
|
if (this.emailOpenedAt == null) {
|
||||||
|
this.emailOpenedAt = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -27,4 +27,7 @@ public interface ClientPendingEditRepository extends JpaRepository<ClientPending
|
|||||||
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
Optional<ClientPendingEdit> findByClientIdAndStatus(Long clientId, String status);
|
||||||
|
|
||||||
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
List<ClientPendingEdit> findByStatusOrderBySubmittedDateDesc(String status);
|
||||||
|
|
||||||
|
/** Used by the public tracking-pixel endpoint to look up the row without exposing integer IDs. */
|
||||||
|
Optional<ClientPendingEdit> findByEmailTrackingToken(String emailTrackingToken);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -43,6 +43,12 @@ public class ClientPendingEditRepositoryWrapper {
|
|||||||
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
return this.repository.findByStatusOrderBySubmittedDateDesc(ClientPendingEditStatus.PENDING.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Looks up a pending edit by its UUID tracking token — used by the public tracking-pixel endpoint
|
||||||
|
* so the URL never exposes an integer ID. Returns empty if the token is not found. */
|
||||||
|
public Optional<ClientPendingEdit> findByEmailTrackingToken(final String token) {
|
||||||
|
return this.repository.findByEmailTrackingToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
public ClientPendingEdit saveAndFlush(final ClientPendingEdit edit) {
|
||||||
return this.repository.saveAndFlush(edit);
|
return this.repository.saveAndFlush(edit);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -48,7 +48,10 @@ public class KycVerificationResult {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience factory for a failed verification. */
|
/**
|
||||||
|
* The provider answered and rejected the document — an unknown CIN, a name that does not match.
|
||||||
|
* A data problem the officer can act on, not an outage.
|
||||||
|
*/
|
||||||
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
|
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
|
||||||
return KycVerificationResult.builder()
|
return KycVerificationResult.builder()
|
||||||
.status(Status.FAILURE)
|
.status(Status.FAILURE)
|
||||||
@@ -57,7 +60,25 @@ public class KycVerificationResult {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider could not be reached, or answered with something unusable. Nothing was learnt
|
||||||
|
* about the document, so callers should report this as the service being unavailable rather
|
||||||
|
* than as the document having failed its check.
|
||||||
|
*/
|
||||||
|
public static KycVerificationResult error(final String errorMessage, final String rawResponse) {
|
||||||
|
return KycVerificationResult.builder()
|
||||||
|
.status(Status.ERROR)
|
||||||
|
.errorMessage(errorMessage)
|
||||||
|
.rawResponse(rawResponse)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
return Status.SUCCESS == this.status;
|
return Status.SUCCESS == this.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinguishes an unreachable provider from one that answered with a rejection. */
|
||||||
|
public boolean isServiceError() {
|
||||||
|
return Status.ERROR == this.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
|
|||||||
final JsonObject response = parseJson(rawResponse);
|
final JsonObject response = parseJson(rawResponse);
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
return KycVerificationResult.failure(
|
// Unparseable body: nothing was learnt about the CIN, so this is the provider
|
||||||
|
// misbehaving rather than the CIN being bad.
|
||||||
|
return KycVerificationResult.error(
|
||||||
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
|
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
|
||||||
rawResponse);
|
rawResponse);
|
||||||
}
|
}
|
||||||
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
|
|||||||
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
||||||
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
|
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
|
||||||
e.getStatusCode(), e.getResponseBodyAsString());
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
return KycVerificationResult.failure(
|
return KycVerificationResult.error(
|
||||||
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
|
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
|
||||||
+ e.getResponseBodyAsString(), null);
|
+ e.getResponseBodyAsString(), null);
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
|
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
|
||||||
return KycVerificationResult.failure("CIN verification failed: " + e.getMessage(), null);
|
return KycVerificationResult.error("CIN verification failed: " + e.getMessage(), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -34,7 +34,7 @@ import org.mapstruct.Mapper;
|
|||||||
import org.mapstruct.Mapping;
|
import org.mapstruct.Mapping;
|
||||||
import org.mapstruct.MappingTarget;
|
import org.mapstruct.MappingTarget;
|
||||||
import org.mapstruct.Named;
|
import org.mapstruct.Named;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
|
||||||
@Mapper(config = MapstructMapperConfig.class)
|
@Mapper(config = MapstructMapperConfig.class)
|
||||||
public interface ClientMapper {
|
public interface ClientMapper {
|
||||||
@@ -107,6 +107,11 @@ public interface ClientMapper {
|
|||||||
@Mapping(target = "legalFormId", ignore = true)
|
@Mapping(target = "legalFormId", ignore = true)
|
||||||
@Mapping(target = "clientCollateralManagements", ignore = true)
|
@Mapping(target = "clientCollateralManagements", ignore = true)
|
||||||
@Mapping(target = "groups", ignore = true)
|
@Mapping(target = "groups", ignore = true)
|
||||||
|
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
|
||||||
|
// ClientReadPlatformServiceImpl#retrieveOne.
|
||||||
|
@Mapping(target = "dipLiveStatus", ignore = true)
|
||||||
|
@Mapping(target = "alternateDipLiveStatus", ignore = true)
|
||||||
|
@Mapping(target = "sanctionsAlertStatus", ignore = true)
|
||||||
ClientData map(Client source);
|
ClientData map(Client source);
|
||||||
|
|
||||||
@Named("clientTypeCode")
|
@Named("clientTypeCode")
|
||||||
|
|||||||
+3
-1
@@ -76,11 +76,13 @@ public final class ClientFamilyMemberCommandFromApiJsonDeserializer {
|
|||||||
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
"SUB_REGISTRAR_OFFICE", "MARRIAGE_REGISTRAR", "COURT_MARRIAGE_OFFICER",
|
||||||
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
"MUNICIPAL_AUTHORITY", "EMBASSY_CONSULATE", "DEFENCE_AUTHORITY");
|
||||||
|
|
||||||
|
public static final String CHILDREN = "children";
|
||||||
|
|
||||||
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
private static final Set<String> SUPPORTED_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
|
Arrays.asList(ID, CLIENT_ID, FIRST_NAME, MIDDLE_NAME, LAST_NAME, QUALIFICATION, MOBILE_NUMBER, AGE, IS_DEPENDENT,
|
||||||
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
|
RELATIONSHIP_ID, MARITAL_STATUS_ID, GENDER_ID, DATE_OF_BIRTH, PROFESSION_ID, LOCALE, DATE_FORMAT, FAMILY_MEMBERS,
|
||||||
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
NOMINATION_TYPE, PERCENTAGE_SHARE, REMARKS,
|
||||||
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY));
|
AADHAAR_NUMBER, PAN_NUMBER, NATIONALITY, IS_ALIVE, LINKED_CLIENT_ID, MARRIAGE_DATE, MARRIAGE_AUTHORITY, CHILDREN));
|
||||||
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
public static final String FAMILY_MEMBERS1 = "FamilyMembers";
|
||||||
public static final String RELATION_SHIP_ID = "relationShipId";
|
public static final String RELATION_SHIP_ID = "relationShipId";
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
|
|||||||
+21
-58
@@ -18,79 +18,42 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.service;
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepository;
|
|
||||||
import org.apache.fineract.portfolio.client.domain.LegalForm;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
|
* Generates CIF numbers in the format <code>CIF</code> followed by the client's id
|
||||||
* <ul>
|
* zero-padded to 10 digits — e.g. client 123 → <code>CIF0000000123</code>.
|
||||||
* <li><b>TT</b> — Customer type code:
|
|
||||||
* <code>11</code> Individual, <code>22</code> Corporate, <code>33</code> Minor, <code>44</code> Staff.
|
|
||||||
* Priority order when multiple flags overlap: <em>Staff > Minor > Corporate > Individual</em>.</li>
|
|
||||||
* <li><b>YYYY</b> — Year the client was submitted (4 digits).</li>
|
|
||||||
* <li><b>NNNNNN</b> — Zero-padded 6-digit sequence within the (type, year) bucket.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
*
|
||||||
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
|
* <p>
|
||||||
* The generator handles concurrent inserts by retrying on uniqueness collisions
|
* Uniqueness is structural rather than probabilistic: <code>m_client.id</code> is a single sequence
|
||||||
* (the caller's outer save is what actually triggers the unique constraint).
|
* shared by every office, so two clients booked concurrently at different branches can never derive
|
||||||
|
* the same CIF. That makes the number globally unique across the whole institution, not just within
|
||||||
|
* the branch that created it, and removes the read-then-write race a "count existing + 1" scheme has.
|
||||||
|
* The <code>cif_number_unique</code> index on m_client remains the backstop.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* The client must already be persisted when this is called, since the id is the source of the number.
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CifNumberGenerator {
|
public class CifNumberGenerator {
|
||||||
|
|
||||||
private static final String TYPE_INDIVIDUAL = "11";
|
private static final String CIF_PREFIX = "CIF";
|
||||||
private static final String TYPE_CORPORATE = "22";
|
|
||||||
private static final String TYPE_MINOR = "33";
|
|
||||||
private static final String TYPE_STAFF = "44";
|
|
||||||
|
|
||||||
private static final int SEQUENCE_WIDTH = 6;
|
/** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */
|
||||||
private static final int MAX_COLLISION_RETRIES = 5;
|
private static final int SEQUENCE_WIDTH = 10;
|
||||||
|
|
||||||
private final ClientRepository clientRepository;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the next CIF number for the given client. Caller is responsible for persisting.
|
* Generate the CIF number for the given client. Caller is responsible for persisting.
|
||||||
*/
|
*/
|
||||||
public String generate(final Client client) {
|
public String generate(final Client client) {
|
||||||
final String typeCode = resolveTypeCode(client);
|
final Long clientId = client.getId();
|
||||||
final int year = resolveYear(client);
|
if (clientId == null) {
|
||||||
final String prefix = typeCode + year;
|
throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted");
|
||||||
|
|
||||||
long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1;
|
|
||||||
for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) {
|
|
||||||
final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0');
|
|
||||||
if (clientRepository.countByCifNumberPrefix(candidate) == 0) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
nextSequence++;
|
|
||||||
}
|
}
|
||||||
// Last resort: append client id to guarantee uniqueness if every attempt collided
|
return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0');
|
||||||
return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveTypeCode(final Client client) {
|
|
||||||
if (client.isStaff()) {
|
|
||||||
return TYPE_STAFF;
|
|
||||||
}
|
|
||||||
if (Boolean.TRUE.equals(client.getIsMinor())) {
|
|
||||||
return TYPE_MINOR;
|
|
||||||
}
|
|
||||||
final Integer legalForm = client.getLegalForm();
|
|
||||||
if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) {
|
|
||||||
return TYPE_CORPORATE;
|
|
||||||
}
|
|
||||||
return TYPE_INDIVIDUAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int resolveYear(final Client client) {
|
|
||||||
final LocalDate submittedOn = client.getSubmittedOnDate();
|
|
||||||
return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.client.service;
|
||||||
|
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and sends the approval-confirmation email that goes to the client when a Clerk approves
|
||||||
|
* their pending profile-edit. Also records the tracking token, subject and body back onto the
|
||||||
|
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} so the "View Email"
|
||||||
|
* dialog can reproduce it later without regenerating the template.
|
||||||
|
*
|
||||||
|
* <p>Implementations must be silent on send failure — they log a warning but must NOT throw, so
|
||||||
|
* that a mail-server outage never rolls back an already-committed approval.
|
||||||
|
*/
|
||||||
|
public interface ClientEditEmailService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to send a confirmation email to the client. On success, calls
|
||||||
|
* {@link ClientPendingEdit#markEmailSent} on {@code pendingEdit} (caller must persist).
|
||||||
|
* On any failure (no email address, SMTP error) logs a warning and returns without mutating
|
||||||
|
* {@code pendingEdit} — the approval itself is never rolled back.
|
||||||
|
*
|
||||||
|
* @param pendingEdit the just-approved pending-edit row (already APPROVED in status but not
|
||||||
|
* yet persisted by the caller at this point)
|
||||||
|
* @param client the client whose details were edited; used for display name and email
|
||||||
|
* @param baseUrl the absolute base URL of this Fineract instance (e.g.
|
||||||
|
* {@code https://cbs.example.com}) used to build the tracking-pixel href
|
||||||
|
* @param tenantId the current tenant identifier, used to build the pixel URL
|
||||||
|
*/
|
||||||
|
void sendApprovalEmail(ClientPendingEdit pendingEdit, Client client, String baseUrl, String tenantId);
|
||||||
|
}
|
||||||
+301
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.configuration.service.ExternalServicesPropertiesReadPlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.configuration.data.SMTPCredentialsData;
|
||||||
|
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientPendingEdit;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an HTML confirmation email to the client when their pending profile edit is approved by a
|
||||||
|
* Checker. Records the tracking token, exact subject and exact body back onto the
|
||||||
|
* {@link ClientPendingEdit} row via {@link ClientPendingEdit#markEmailSent} (caller must persist
|
||||||
|
* the entity after this method returns).
|
||||||
|
*
|
||||||
|
* <p>Template selection:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Template A (Contact Change)</b> — used when {@code mobileNo}, {@code alternateMobileNo}
|
||||||
|
* or {@code emailAddress} has actually changed (compared between {@code payload_json} and
|
||||||
|
* {@code original_json}). Lists old→new values in a table.</li>
|
||||||
|
* <li><b>Template B (General Change)</b> — used for all other field changes, or when
|
||||||
|
* {@code original_json} is null (pre-snapshot edits). Lists changed field labels.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>This service is intentionally <em>non-transactional</em>: it must never cause the enclosing
|
||||||
|
* approval transaction to roll back. The caller catches any exception from this service and logs it.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClientEditEmailServiceImpl implements ClientEditEmailService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ClientEditEmailServiceImpl.class);
|
||||||
|
|
||||||
|
private static final String BANK_NAME = "CBSNexora";
|
||||||
|
private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm");
|
||||||
|
|
||||||
|
/** Contact fields that trigger Template A when they actually change. */
|
||||||
|
private static final String FIELD_MOBILE = "mobileNo";
|
||||||
|
private static final String FIELD_ALT_MOBILE = "alternateMobileNo";
|
||||||
|
private static final String FIELD_EMAIL = "emailAddress";
|
||||||
|
|
||||||
|
/** Human-readable labels for every field that can appear in the email body — mirrors the
|
||||||
|
* frontend's PENDING_EDIT_FIELD_LABELS map in client-edit-diff.util.ts. */
|
||||||
|
private static final Map<String, String> FIELD_LABELS;
|
||||||
|
static {
|
||||||
|
FIELD_LABELS = new LinkedHashMap<>();
|
||||||
|
FIELD_LABELS.put("firstname", "First Name");
|
||||||
|
FIELD_LABELS.put("middlename", "Middle Name");
|
||||||
|
FIELD_LABELS.put("lastname", "Last Name");
|
||||||
|
FIELD_LABELS.put("fullname", "Full Name");
|
||||||
|
FIELD_LABELS.put("mobileNo", "Mobile Number");
|
||||||
|
FIELD_LABELS.put("alternateMobileNo", "Alternate Mobile");
|
||||||
|
FIELD_LABELS.put("emailAddress", "Email Address");
|
||||||
|
FIELD_LABELS.put("dateOfBirth", "Date of Birth");
|
||||||
|
FIELD_LABELS.put("genderId", "Gender");
|
||||||
|
FIELD_LABELS.put("maritalStatusId", "Marital Status");
|
||||||
|
FIELD_LABELS.put("educationLevelId", "Education Level");
|
||||||
|
FIELD_LABELS.put("degreeQualificationId", "Degree / Qualification");
|
||||||
|
FIELD_LABELS.put("occupationCategoryId", "Occupation Category");
|
||||||
|
FIELD_LABELS.put("occupationId", "Occupation");
|
||||||
|
FIELD_LABELS.put("religionId", "Religion");
|
||||||
|
FIELD_LABELS.put("socialCategoryId", "Social Category");
|
||||||
|
FIELD_LABELS.put("pwdCategoryId", "PWD Category");
|
||||||
|
FIELD_LABELS.put("nationalityId", "Nationality");
|
||||||
|
FIELD_LABELS.put("officeId", "Branch / Office");
|
||||||
|
FIELD_LABELS.put("staffId", "Relationship Manager");
|
||||||
|
FIELD_LABELS.put("savingsProductId", "Default Savings Product");
|
||||||
|
FIELD_LABELS.put("legalFormId", "Legal Form");
|
||||||
|
FIELD_LABELS.put("salutationId", "Salutation");
|
||||||
|
}
|
||||||
|
|
||||||
|
private final EmailSenderService emailSenderService;
|
||||||
|
private final ExternalServicesPropertiesReadPlatformService externalServicesReadPlatformService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendApprovalEmail(final ClientPendingEdit pendingEdit, final Client client,
|
||||||
|
final String baseUrl, final String tenantId) {
|
||||||
|
|
||||||
|
final String toEmail = client.getEmailAddress();
|
||||||
|
if (toEmail == null || toEmail.isBlank()) {
|
||||||
|
LOG.warn("Client {} has no email address on file; skipping approval confirmation email for pending edit {}",
|
||||||
|
client.getId(), pendingEdit.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final String token = UUID.randomUUID().toString();
|
||||||
|
final String pixelUrl = buildPixelUrl(baseUrl, tenantId, token);
|
||||||
|
|
||||||
|
final JsonObject payload = parseJson(pendingEdit.getPayloadJson());
|
||||||
|
final JsonObject original = parseJson(pendingEdit.getOriginalJson());
|
||||||
|
|
||||||
|
final boolean isContactChange = detectContactChange(payload, original);
|
||||||
|
|
||||||
|
final String checkedDateFormatted = pendingEdit.getCheckedDate() != null
|
||||||
|
? pendingEdit.getCheckedDate().format(DISPLAY_FORMAT) : "—";
|
||||||
|
|
||||||
|
final String subject;
|
||||||
|
final String body;
|
||||||
|
if (isContactChange) {
|
||||||
|
subject = "Your contact details have been updated — " + BANK_NAME;
|
||||||
|
body = buildContactChangeBody(client.getDisplayName(), payload, original,
|
||||||
|
checkedDateFormatted, pixelUrl);
|
||||||
|
} else {
|
||||||
|
subject = "Your profile details have been updated — " + BANK_NAME;
|
||||||
|
body = buildGeneralChangeBody(client.getDisplayName(), payload, original,
|
||||||
|
checkedDateFormatted, pixelUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean sent = emailSenderService.sendHtml(toEmail, subject, body);
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
String fromEmail = "noreply@cbsnexora.in";
|
||||||
|
try {
|
||||||
|
final SMTPCredentialsData smtp = this.externalServicesReadPlatformService.getSMTPCredentials();
|
||||||
|
if (smtp != null && smtp.getFromEmail() != null && !smtp.getFromEmail().isBlank()) {
|
||||||
|
fromEmail = smtp.getFromEmail();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("Could not fetch SMTP credentials for fromEmail logging, using fallback", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingEdit.markEmailSent(token, subject, body, fromEmail);
|
||||||
|
LOG.info("Approval confirmation email sent to {} for pending edit {}", toEmail, pendingEdit.getId());
|
||||||
|
} else {
|
||||||
|
LOG.warn("Failed to send approval email to {}; email_sent will remain false.", toEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (final Exception e) {
|
||||||
|
LOG.warn("Failed to send approval confirmation email for pending edit {} — approval will NOT be rolled back. Error: {}",
|
||||||
|
pendingEdit.getId(), e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template detection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if any of the three contact fields actually changed between
|
||||||
|
* {@code original} and {@code payload}. If {@code original} is null (pre-snapshot edit)
|
||||||
|
* falls back to false (Template B).
|
||||||
|
*/
|
||||||
|
private boolean detectContactChange(final JsonObject payload, final JsonObject original) {
|
||||||
|
if (original == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||||
|
final String payloadVal = stringOf(payload, field);
|
||||||
|
final String originalVal = stringOf(original, field);
|
||||||
|
// Only counts as a change when both sides have a value and they differ, OR when
|
||||||
|
// payload has a non-blank value and original didn't have the key at all.
|
||||||
|
if (payloadVal != null && !payloadVal.equals(originalVal)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template A ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private String buildContactChangeBody(final String clientName, final JsonObject payload,
|
||||||
|
final JsonObject original, final String date, final String pixelUrl) {
|
||||||
|
|
||||||
|
final StringBuilder rows = new StringBuilder();
|
||||||
|
for (final String field : new String[] { FIELD_MOBILE, FIELD_ALT_MOBILE, FIELD_EMAIL }) {
|
||||||
|
final String newVal = stringOf(payload, field);
|
||||||
|
final String oldVal = original != null ? stringOf(original, field) : null;
|
||||||
|
if (newVal == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (oldVal != null && oldVal.equals(newVal)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String label = FIELD_LABELS.getOrDefault(field, field);
|
||||||
|
rows.append("<tr>")
|
||||||
|
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;'>").append(escHtml(label)).append("</td>")
|
||||||
|
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;color:#888;'>")
|
||||||
|
.append(oldVal != null ? escHtml(oldVal) : "—").append("</td>")
|
||||||
|
.append("<td style='padding:6px 12px;border:1px solid #e0e0e0;font-weight:600;color:#1a6b3c;'>")
|
||||||
|
.append(escHtml(newVal)).append("</td>")
|
||||||
|
.append("</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||||
|
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||||
|
+ "<p>Your contact details with <strong>" + BANK_NAME + "</strong> have been "
|
||||||
|
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||||
|
+ "<table style='border-collapse:collapse;width:100%;margin:16px 0;'>"
|
||||||
|
+ "<thead><tr>"
|
||||||
|
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Field</th>"
|
||||||
|
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>Previous Value</th>"
|
||||||
|
+ "<th style='padding:8px 12px;background:#f5f5f5;border:1px solid #e0e0e0;text-align:left;'>New Value</th>"
|
||||||
|
+ "</tr></thead><tbody>" + rows + "</tbody></table>"
|
||||||
|
+ "<p style='color:#c0392b;'><strong>If you did not request this change, please contact your branch immediately.</strong></p>"
|
||||||
|
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||||
|
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||||
|
+ "</body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template B ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private String buildGeneralChangeBody(final String clientName, final JsonObject payload,
|
||||||
|
final JsonObject original, final String date, final String pixelUrl) {
|
||||||
|
|
||||||
|
final StringBuilder items = new StringBuilder();
|
||||||
|
for (final Map.Entry<String, JsonElement> entry : payload.entrySet()) {
|
||||||
|
final String key = entry.getKey();
|
||||||
|
// Skip internal/non-display keys
|
||||||
|
if (key.startsWith("_") || key.equals("locale") || key.equals("dateFormat")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String payloadVal = stringOf(payload, key);
|
||||||
|
final String originalVal = original != null ? stringOf(original, key) : null;
|
||||||
|
if (payloadVal == null || (originalVal != null && originalVal.equals(payloadVal))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String label = FIELD_LABELS.getOrDefault(key, key);
|
||||||
|
items.append("<li>").append(escHtml(label)).append("</li>");
|
||||||
|
}
|
||||||
|
final String listHtml = items.length() > 0
|
||||||
|
? "<ul style='padding-left:20px;'>" + items + "</ul>"
|
||||||
|
: "<p><em>No specific fields identified.</em></p>";
|
||||||
|
|
||||||
|
return "<!DOCTYPE html><html><body style='font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;'>"
|
||||||
|
+ "<p>Dear " + escHtml(clientName) + ",</p>"
|
||||||
|
+ "<p>Your profile details with <strong>" + BANK_NAME + "</strong> have been "
|
||||||
|
+ "<strong style='color:#1a6b3c;'>successfully updated</strong> as of " + escHtml(date) + ".</p>"
|
||||||
|
+ "<p>The following fields were changed:</p>"
|
||||||
|
+ listHtml
|
||||||
|
+ "<p style='color:#c0392b;'><strong>If you did not authorise this change, please contact your branch immediately.</strong></p>"
|
||||||
|
+ "<p style='color:#888;font-size:12px;'>This is an automated message. Please do not reply to this email.</p>"
|
||||||
|
+ "<img src='" + pixelUrl + "' width='1' height='1' style='display:none;border:0;' alt='' />"
|
||||||
|
+ "</body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private String buildPixelUrl(final String baseUrl, final String tenantId, final String token) {
|
||||||
|
// e.g. https://cbs.example.com/api/v1/clients/edit/pixel/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
// The tenantId header is NOT part of the URL — the pixel endpoint uses JDBC directly
|
||||||
|
// without requiring a tenant header (it accepts the token and commits in the default
|
||||||
|
// datasource context). However we embed it as a query param so future multi-tenant
|
||||||
|
// support is easy to add without changing the stored URL.
|
||||||
|
return baseUrl + "/api/v1/clients/edit/pixel/" + token + "?tenantId=" + tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject parseJson(final String json) {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final JsonElement el = JsonParser.parseString(json);
|
||||||
|
return el.isJsonObject() ? el.getAsJsonObject() : null;
|
||||||
|
} catch (final Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringOf(final JsonObject obj, final String key) {
|
||||||
|
if (obj == null || !obj.has(key) || obj.get(key).isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final JsonElement el = obj.get(key);
|
||||||
|
return el.isJsonPrimitive() ? el.getAsString() : el.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escHtml(final String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace("\"", """).replace("'", "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -26,7 +26,15 @@ public interface ClientEditWritePlatformService {
|
|||||||
|
|
||||||
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
CommandProcessingResult submitEditForApproval(Long clientId, JsonCommand command);
|
||||||
|
|
||||||
CommandProcessingResult approveEdit(Long clientId);
|
/**
|
||||||
|
* Approves the pending edit and, on success, attempts to send a confirmation email to the
|
||||||
|
* client. The {@code baseUrl} and {@code tenantId} are forwarded to the email service so it
|
||||||
|
* can embed an absolute tracking-pixel URL in the email body.
|
||||||
|
*
|
||||||
|
* @param baseUrl absolute base URL of this Fineract instance (e.g. {@code https://cbs.example.com})
|
||||||
|
* @param tenantId current tenant identifier
|
||||||
|
*/
|
||||||
|
CommandProcessingResult approveEdit(Long clientId, String baseUrl, String tenantId);
|
||||||
|
|
||||||
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
CommandProcessingResult rejectEdit(Long clientId, String rejectReason);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -33,6 +33,8 @@ import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
|||||||
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
import org.apache.fineract.portfolio.client.exception.ClientEditPendingApprovalException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
import org.apache.fineract.portfolio.client.exception.ClientEditSelfApprovalNotAllowedException;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -40,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Service
|
@Service
|
||||||
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatformService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ClientEditWritePlatformServiceImpl.class);
|
||||||
|
|
||||||
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
/** Matches ORIGINAL_VALUES_PAYLOAD_KEY in client-edit-diff.util.ts. */
|
||||||
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
private static final String ORIGINAL_VALUES_PAYLOAD_KEY = "_originalValues";
|
||||||
|
|
||||||
@@ -48,6 +52,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
private final ClientPendingEditRepositoryWrapper pendingEditRepository;
|
||||||
private final ClientWritePlatformService clientWritePlatformService;
|
private final ClientWritePlatformService clientWritePlatformService;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
|
private final ClientEditEmailService clientEditEmailService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -92,7 +97,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
public CommandProcessingResult approveEdit(final Long clientId) {
|
public CommandProcessingResult approveEdit(final Long clientId, final String baseUrl, final String tenantId) {
|
||||||
final AppUser checker = this.context.authenticatedUser();
|
final AppUser checker = this.context.authenticatedUser();
|
||||||
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
final ClientPendingEdit pendingEdit = this.pendingEditRepository.findPendingByClientIdWithNotFoundDetection(clientId);
|
||||||
|
|
||||||
@@ -105,6 +110,7 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
// Replay the staged JSON through the SAME write-service method a direct PUT would have
|
// Replay the staged JSON through the SAME write-service method a direct PUT would have
|
||||||
// used, so every bit of Client's existing validation/change-detection logic runs exactly
|
// used, so every bit of Client's existing validation/change-detection logic runs exactly
|
||||||
// as it would for an immediate edit — just deferred until a different user approves it.
|
// as it would for an immediate edit — just deferred until a different user approves it.
|
||||||
|
final Client client = pendingEdit.getClient();
|
||||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
final JsonElement parsedJson = this.fromApiJsonHelper.parse(pendingEdit.getPayloadJson());
|
||||||
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
final JsonCommand replayedCommand = JsonCommand.fromJsonElement(clientId, parsedJson, this.fromApiJsonHelper);
|
||||||
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
final CommandProcessingResult result = this.clientWritePlatformService.updateClient(clientId, replayedCommand);
|
||||||
@@ -112,6 +118,23 @@ public class ClientEditWritePlatformServiceImpl implements ClientEditWritePlatfo
|
|||||||
pendingEdit.approve(checker);
|
pendingEdit.approve(checker);
|
||||||
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||||
|
|
||||||
|
// Send confirmation email — silently swallowed on any failure so the approval is never
|
||||||
|
// rolled back due to a mail-server outage. clientEditEmailService.sendApprovalEmail()
|
||||||
|
// calls pendingEdit.markEmailSent() on success; we then save again to persist the
|
||||||
|
// email tracking columns. If the send fails or the client has no email, this save is
|
||||||
|
// a no-op (email_sent remains false).
|
||||||
|
try {
|
||||||
|
if (org.apache.commons.lang3.StringUtils.isNotBlank(client.getEmailAddress())) {
|
||||||
|
clientEditEmailService.sendApprovalEmail(pendingEdit, client, baseUrl, tenantId);
|
||||||
|
} else {
|
||||||
|
LOG.info("Client {} has no email address on file; skipping approval email", client.getId());
|
||||||
|
}
|
||||||
|
} catch (final Exception e) {
|
||||||
|
LOG.warn("Unexpected error from ClientEditEmailService for pending edit {} — approval stands",
|
||||||
|
pendingEdit.getId(), e);
|
||||||
|
}
|
||||||
|
this.pendingEditRepository.saveAndFlush(pendingEdit);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-2
@@ -120,7 +120,11 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
|||||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||||
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
|
final String sql = "select " + rm.schema() + " where fmb.client_id=? and fmb.is_deleted = false and fmb.is_active = true";
|
||||||
|
|
||||||
return this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
List<ClientFamilyMembersData> familyMembers = this.jdbcTemplate.query(sql, rm, clientId); // NOSONAR
|
||||||
|
for (ClientFamilyMembersData fm : familyMembers) {
|
||||||
|
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||||
|
}
|
||||||
|
return familyMembers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -131,7 +135,28 @@ public class ClientFamilyMembersReadPlatformServiceImpl implements ClientFamilyM
|
|||||||
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
final ClientFamilyMembersMapper rm = new ClientFamilyMembersMapper();
|
||||||
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
final String sql = "select " + rm.schema() + " where fmb.id=? and fmb.is_deleted = false ";
|
||||||
|
|
||||||
return this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
ClientFamilyMembersData fm = this.jdbcTemplate.queryForObject(sql, rm, id); // NOSONAR
|
||||||
|
if (fm != null) {
|
||||||
|
fm.setChildren(getChildrenForFamilyMember(fm.getId()));
|
||||||
|
}
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData> getChildrenForFamilyMember(long familyMemberId) {
|
||||||
|
final String sql = "select c.id, c.full_name as fullName, c.date_of_birth as dateOfBirth, " +
|
||||||
|
"c.gender_cv_id as genderId, cv.code_value as gender " +
|
||||||
|
"from m_family_member_children c " +
|
||||||
|
"left join m_code_value cv on c.gender_cv_id = cv.id " +
|
||||||
|
"where c.family_member_id = ?";
|
||||||
|
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
|
||||||
|
return org.apache.fineract.portfolio.client.data.ClientFamilyMemberChildData.builder()
|
||||||
|
.id(rs.getLong("id"))
|
||||||
|
.fullName(rs.getString("fullName"))
|
||||||
|
.genderId(JdbcSupport.getLong(rs, "genderId"))
|
||||||
|
.gender(rs.getString("gender"))
|
||||||
|
.dateOfBirth(JdbcSupport.getLocalDate(rs, "dateOfBirth"))
|
||||||
|
.build();
|
||||||
|
}, familyMemberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+41
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.client.domain.ClientFamilyMembersRepository
|
|||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberFatherOrCaretakerRequiredException;
|
||||||
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
|
import org.apache.fineract.portfolio.client.exception.ClientFamilyMemberMotherRequiredException;
|
||||||
|
import org.apache.fineract.portfolio.client.domain.ClientFamilyMemberChildren;
|
||||||
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
|
import org.apache.fineract.portfolio.client.serialization.ClientFamilyMemberCommandFromApiJsonDeserializer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -134,6 +135,33 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateChildren(ClientFamilyMembers familyMember, JsonArray childrenArray, String dateFormat) {
|
||||||
|
List<ClientFamilyMemberChildren> children = new java.util.ArrayList<>();
|
||||||
|
if (childrenArray != null) {
|
||||||
|
String format = (dateFormat != null && !dateFormat.isEmpty()) ? dateFormat : "yyyy-MM-dd";
|
||||||
|
DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern(format).toFormatter();
|
||||||
|
for (JsonElement childElement : childrenArray) {
|
||||||
|
if (childElement.isJsonNull()) continue;
|
||||||
|
JsonObject childObj = childElement.getAsJsonObject();
|
||||||
|
String fullName = childObj.has("fullName") && !childObj.get("fullName").isJsonNull() ? childObj.get("fullName").getAsString() : null;
|
||||||
|
Long genderId = childObj.has("genderId") && !childObj.get("genderId").isJsonNull() ? childObj.get("genderId").getAsLong() : null;
|
||||||
|
CodeValue gender = genderId != null ? this.codeValueRepository.getReferenceById(genderId) : null;
|
||||||
|
LocalDate dob = null;
|
||||||
|
if (childObj.has("dateOfBirth") && !childObj.get("dateOfBirth").isJsonNull()) {
|
||||||
|
try {
|
||||||
|
dob = LocalDate.parse(childObj.get("dateOfBirth").getAsString(), formatter);
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
LOG.warn("Could not parse dateOfBirth for child", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fullName != null && !fullName.trim().isEmpty() && gender != null) {
|
||||||
|
children.add(ClientFamilyMemberChildren.fromJson(familyMember, fullName, gender, dob));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
familyMember.setChildren(children);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void validateFamilyRequirementsForActivation(final Client client) {
|
public void validateFamilyRequirementsForActivation(final Client client) {
|
||||||
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
if (client == null || client.getId() == null || client.getLegalForm() == null) {
|
||||||
@@ -258,6 +286,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
clientFamilyMembers.setLinkedClient(clientRepositoryWrapper.findOneWithNotFoundDetection(linkedClientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.parameterExists("children")) {
|
||||||
|
updateChildren(clientFamilyMembers, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||||
|
}
|
||||||
|
|
||||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
this.clientFamilyRepository.saveAndFlush(clientFamilyMembers);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMembers.getId()).build();
|
||||||
@@ -421,6 +453,11 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
// mothers (or one mother + an existing mother in the DB) fails fast on the offender.
|
||||||
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
validateRelationshipUniqueness(client != null ? client.getId() : null, relationship, null);
|
||||||
|
|
||||||
|
if (member.has("children") && !member.get("children").isJsonNull()) {
|
||||||
|
String dFormat = member.has("dateFormat") && !member.get("dateFormat").isJsonNull() ? member.get("dateFormat").getAsString() : null;
|
||||||
|
updateChildren(familyMember, member.get("children").getAsJsonArray(), dFormat);
|
||||||
|
}
|
||||||
|
|
||||||
this.clientFamilyRepository.saveAndFlush(familyMember);
|
this.clientFamilyRepository.saveAndFlush(familyMember);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -577,6 +614,10 @@ public class ClientFamilyMembersWritePlatformServiceImpl implements ClientFamily
|
|||||||
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
validateRelationshipUniqueness(ownerForValidation != null ? ownerForValidation.getId() : null,
|
||||||
clientFamilyMember.getRelationship(), familyMemberId);
|
clientFamilyMember.getRelationship(), familyMemberId);
|
||||||
|
|
||||||
|
if (command.parameterExists("children")) {
|
||||||
|
updateChildren(clientFamilyMember, command.arrayOfParameterNamed("children"), command.stringValueOfParameterNamed("dateFormat"));
|
||||||
|
}
|
||||||
|
|
||||||
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
this.clientFamilyRepository.saveAndFlush(clientFamilyMember);
|
||||||
|
|
||||||
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(clientFamilyMember.getId()).build();
|
||||||
|
|||||||
+20
@@ -85,15 +85,31 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
|||||||
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
|
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
|
||||||
.panCategory(kyc.getPanCategory())
|
.panCategory(kyc.getPanCategory())
|
||||||
.panStatus(kyc.getPanStatus())
|
.panStatus(kyc.getPanStatus())
|
||||||
|
.panDeclarationTaken(kyc.getPanDeclarationTaken())
|
||||||
|
.panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields())
|
||||||
|
.panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan())
|
||||||
|
.panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan())
|
||||||
|
.panDeclarationRemarks(kyc.getPanDeclarationRemarks())
|
||||||
|
.panDeclarationTakenOn(kyc.getPanDeclarationTakenOn())
|
||||||
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
|
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
|
||||||
.aadhaarVerified(kyc.isAadhaarVerified())
|
.aadhaarVerified(kyc.isAadhaarVerified())
|
||||||
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
|
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
|
||||||
|
.aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken())
|
||||||
|
.aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId())
|
||||||
|
.aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate())
|
||||||
|
.aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks())
|
||||||
|
.aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn())
|
||||||
.gstinNumber(kyc.getGstinNumber())
|
.gstinNumber(kyc.getGstinNumber())
|
||||||
.gstinVerified(kyc.isGstinVerified())
|
.gstinVerified(kyc.isGstinVerified())
|
||||||
.gstinVerifiedOn(kyc.getGstinVerifiedOn())
|
.gstinVerifiedOn(kyc.getGstinVerifiedOn())
|
||||||
.enterprisePan(kyc.getEnterprisePan())
|
.enterprisePan(kyc.getEnterprisePan())
|
||||||
.cinNumber(kyc.getCinNumber())
|
.cinNumber(kyc.getCinNumber())
|
||||||
|
.cinVerified(kyc.isCinVerified())
|
||||||
|
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
||||||
.incorporationDate(kyc.getIncorporationDate())
|
.incorporationDate(kyc.getIncorporationDate())
|
||||||
|
.dinNumber(kyc.getDinNumber())
|
||||||
|
.dinVerified(kyc.isDinVerified())
|
||||||
|
.dinVerifiedOn(kyc.getDinVerifiedOn())
|
||||||
.kycDocumentName(kyc.getKycDocumentName())
|
.kycDocumentName(kyc.getKycDocumentName())
|
||||||
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
||||||
.kycDocIssueDate(kyc.getKycDocIssueDate())
|
.kycDocIssueDate(kyc.getKycDocIssueDate())
|
||||||
@@ -106,6 +122,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
|||||||
.accountStatus(kyc.getAccountStatus())
|
.accountStatus(kyc.getAccountStatus())
|
||||||
.freezeReason(kyc.getFreezeReason())
|
.freezeReason(kyc.getFreezeReason())
|
||||||
.freezeAuthority(kyc.getFreezeAuthority())
|
.freezeAuthority(kyc.getFreezeAuthority())
|
||||||
|
.freezeDate(kyc.getFreezeDate())
|
||||||
|
.freezeAmount(kyc.getFreezeAmount())
|
||||||
|
.unfreezeDate(kyc.getUnfreezeDate())
|
||||||
|
.noDebitDate(kyc.getNoDebitDate())
|
||||||
.form60Type(kyc.getForm60Type())
|
.form60Type(kyc.getForm60Type())
|
||||||
.form60Date(kyc.getForm60Date())
|
.form60Date(kyc.getForm60Date())
|
||||||
.form15Applicable(kyc.getForm15Applicable())
|
.form15Applicable(kyc.getForm15Applicable())
|
||||||
|
|||||||
+64
-57
@@ -93,9 +93,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
|
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
|
||||||
|
|
||||||
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
||||||
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType");
|
enforceNonIndividualOnlyFields(command, isIndividualClient(clientForValidation));
|
||||||
final boolean isIndividualInitiate = kycTypeOnInitiate != null && kycTypeOnInitiate == 1;
|
|
||||||
enforceNonIndividualOnlyFields(command, isIndividualInitiate);
|
|
||||||
|
|
||||||
if (kycRepositoryWrapper.existsByClientId(clientId)) {
|
if (kycRepositoryWrapper.existsByClientId(clientId)) {
|
||||||
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
|
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
|
||||||
@@ -140,45 +138,30 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
|
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
|
||||||
|
|
||||||
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
||||||
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
|
enforceNonIndividualOnlyFields(command, isIndividualClient(client));
|
||||||
enforceNonIndividualOnlyFields(command, isIndividualExisting);
|
|
||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||||
|
|
||||||
// When re-editing a VERIFIED record, remarks are mandatory (audit trail)
|
final Map<String, Object> changes = kyc.update(command);
|
||||||
if (currentStatus.isVerified()) {
|
|
||||||
|
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
|
||||||
|
// request that replaces data the record already held. A non-individual KYC auto-verifies as
|
||||||
|
// soon as PAN and GSTIN pass, which lands part-way through the create wizard, and the
|
||||||
|
// officer still has to save the KYC document details afterwards; filling in fields that were
|
||||||
|
// empty is completing the record, not altering it, and must not demand a justification.
|
||||||
|
// Thrown before the save below, and this method is @Transactional, so the in-memory
|
||||||
|
// mutations from update() are rolled back.
|
||||||
|
if (currentStatus.isVerified() && !kyc.getFieldsOverwrittenByLastUpdate().isEmpty()) {
|
||||||
final String remarks = command.stringValueOfParameterNamed("remarks");
|
final String remarks = command.stringValueOfParameterNamed("remarks");
|
||||||
if (StringUtils.isBlank(remarks)) {
|
if (StringUtils.isBlank(remarks)) {
|
||||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
"error.msg.client.kyc.remarks.required.for.reverification",
|
"error.msg.client.kyc.remarks.required.for.reverification",
|
||||||
"Remarks are mandatory when updating a verified KYC record.", "remarks")));
|
"Remarks are mandatory when changing data already recorded on a verified KYC record.",
|
||||||
|
"remarks")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = kyc.update(command);
|
|
||||||
|
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
// Only changes to identifier fields invalidate prior verifications. A remarks edit or
|
|
||||||
// risk-category bump does not — keeping it as VERIFIED is the correct end state.
|
|
||||||
final boolean panChanged = changes.containsKey("panNumber");
|
|
||||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
|
||||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
|
||||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
|
||||||
|
|
||||||
if (panChanged && currentStatus.isVerified()) {
|
|
||||||
kyc.resetPanVerification();
|
|
||||||
changes.put("panVerified", false);
|
|
||||||
}
|
|
||||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
|
||||||
kyc.markInProgress();
|
|
||||||
changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue());
|
|
||||||
}
|
|
||||||
// Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED.
|
|
||||||
// Handles the common case of editing remarks / risk on a fully-verified KYC.
|
|
||||||
kyc.tryAutoVerify();
|
|
||||||
if (kyc.getKycStatus().isVerified()) {
|
|
||||||
changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue());
|
|
||||||
}
|
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +339,11 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
// firstname/middlename/lastname ← individual columns; NULL for entities
|
// firstname/middlename/lastname ← individual columns; NULL for entities
|
||||||
// fullname ← the entity name lives here
|
// fullname ← the entity name lives here
|
||||||
// dateOfBirth ← only set for individuals
|
// dateOfBirth ← only set for individuals
|
||||||
// ClientNonPerson.dateOfRegistration ← the incorporation date for entities
|
// ClientKyc.incorporationDate ← the incorporation date for entities, captured on the
|
||||||
|
// KYC step next to the entity PAN it is verified against
|
||||||
|
// ClientNonPerson.dateOfRegistration ← where that date lived for clients created before
|
||||||
|
// the field moved to the KYC step; read as a fallback
|
||||||
|
// so those older entities still verify.
|
||||||
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
|
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
|
||||||
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
|
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
|
||||||
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
|
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
|
||||||
@@ -364,10 +351,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final String dob;
|
final String dob;
|
||||||
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
|
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
|
||||||
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
|
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
|
||||||
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
|
java.time.LocalDate incorpDate = kyc.getIncorporationDate();
|
||||||
final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null;
|
if (incorpDate == null) {
|
||||||
dob = regDate != null
|
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
|
||||||
? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
incorpDate = (np != null) ? np.getDateOfRegistration() : null;
|
||||||
|
}
|
||||||
|
dob = incorpDate != null
|
||||||
|
? incorpDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
||||||
: null;
|
: null;
|
||||||
} else {
|
} else {
|
||||||
dob = client.dateOfBirth() != null
|
dob = client.dateOfBirth() != null
|
||||||
@@ -542,15 +532,36 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
"CIN number is not set on this KYC record. Update the KYC record first.");
|
"CIN number is not set on this KYC record. Update the KYC record first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final KycVerificationResult result = kycVerificationService.verifyCin(kyc.getCinNumber());
|
final String cin = kyc.getCinNumber().toUpperCase().trim();
|
||||||
|
if (!cin.matches(ClientKycApiConstants.CIN_REGEX)) {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
|
"error.msg.client.kyc.cin.invalid.format",
|
||||||
|
"Invalid CIN format. Expected 21 characters, e.g. L17110MH1973PLC019786.",
|
||||||
|
ClientKycApiConstants.cinNumberParam)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.isSuccess()) {
|
final KycVerificationResult result = kycVerificationService.verifyCin(cin);
|
||||||
|
|
||||||
|
if (!result.isSuccess() && result.isServiceError()) {
|
||||||
throw new PlatformServiceUnavailableException(
|
throw new PlatformServiceUnavailableException(
|
||||||
"error.msg.client.kyc.cin.verification.failed",
|
"error.msg.client.kyc.cin.verification.failed",
|
||||||
"CIN verification failed: " + result.getErrorMessage(),
|
"CIN verification failed: " + result.getErrorMessage(),
|
||||||
result.getErrorMessage());
|
result.getErrorMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A CIN whose structure is valid is accepted even when the registry lookup returns no match.
|
||||||
|
// The provider's company endpoint answers "Not Found" for every CIN put to it, including
|
||||||
|
// ones that are demonstrably real, so a negative from it says nothing about the company and
|
||||||
|
// cannot be allowed to block onboarding. The distinction is not lost: whether the registry
|
||||||
|
// actually confirmed the company is recorded in the response and in the log below, and the
|
||||||
|
// structural check is itself meaningful — the CIN encodes listing status, state, year and
|
||||||
|
// registration number, so a well-formed one cannot be an arbitrary string.
|
||||||
|
final boolean confirmedByRegistry = result.isSuccess();
|
||||||
|
if (!confirmedByRegistry) {
|
||||||
|
LOG.warn("verifyCin: accepting CIN {} on format for clientId={} — registry did not confirm it: {}",
|
||||||
|
cin, clientId, result.getErrorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
kyc.markCinVerified();
|
kyc.markCinVerified();
|
||||||
kyc.tryAutoVerify();
|
kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
@@ -558,6 +569,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("cinVerified", true);
|
changes.put("cinVerified", true);
|
||||||
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
|
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
|
||||||
|
changes.put("cinConfirmedByRegistry", confirmedByRegistry);
|
||||||
|
if (!confirmedByRegistry) {
|
||||||
|
changes.put("cinVerificationMessage", "CIN format validated. MCA records could not confirm this company.");
|
||||||
|
}
|
||||||
changes.put("kycStatus", kyc.getKycStatusValue());
|
changes.put("kycStatus", kyc.getKycStatusValue());
|
||||||
return buildResult(command, client, clientId, kycId, changes);
|
return buildResult(command, client, clientId, kycId, changes);
|
||||||
}
|
}
|
||||||
@@ -722,6 +737,16 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
|
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client itself is a natural person. Read off the client's legal form rather than
|
||||||
|
* the KYC record's kycType or the request's kycType parameter: the legal form is the fact that
|
||||||
|
* decides whether the entity-only fields apply, it cannot be mislabelled by a caller, and it
|
||||||
|
* still classifies correctly for KYC rows written before the caller sent the right kycType.
|
||||||
|
*/
|
||||||
|
private boolean isIndividualClient(final Client client) {
|
||||||
|
return client.getLegalForm() == null || !LegalForm.fromInt(client.getLegalForm()).isEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
|
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
|
||||||
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
|
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
|
||||||
@@ -997,11 +1022,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
|
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
|
||||||
|
|
||||||
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
|
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
|
||||||
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
|
|
||||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
|
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
|
||||||
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
|
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
|
||||||
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
|
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
|
||||||
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting);
|
enforceNonIndividualOnlyFields(jsonCommand, isIndividualClient(client));
|
||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||||
final Map<String, Object> changes = kyc.update(jsonCommand);
|
final Map<String, Object> changes = kyc.update(jsonCommand);
|
||||||
@@ -1009,23 +1033,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
return; // no-op — edit-client sent the same KYC values that are already on the row
|
return; // no-op — edit-client sent the same KYC values that are already on the row
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same audit semantics as the explicit updateKyc endpoint:
|
|
||||||
// • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications.
|
|
||||||
// • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED.
|
|
||||||
// • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed —
|
|
||||||
// fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS.
|
|
||||||
final boolean panChanged = changes.containsKey("panNumber");
|
|
||||||
final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked");
|
|
||||||
final boolean gstinChanged = changes.containsKey("gstinNumber");
|
|
||||||
final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged;
|
|
||||||
|
|
||||||
if (currentStatus.isVerified() && panChanged) {
|
|
||||||
kyc.resetPanVerification();
|
|
||||||
}
|
|
||||||
if (verificationAffected && !kyc.getKycStatus().isInProgress()) {
|
|
||||||
kyc.markInProgress();
|
|
||||||
}
|
|
||||||
kyc.tryAutoVerify();
|
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
|
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
|
||||||
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
|
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
|
||||||
|
|||||||
+9
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.client.service;
|
package org.apache.fineract.portfolio.client.service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import org.apache.fineract.infrastructure.core.service.Page;
|
import org.apache.fineract.infrastructure.core.service.Page;
|
||||||
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
import org.apache.fineract.portfolio.client.data.ClientPendingEditData;
|
||||||
|
|
||||||
@@ -33,4 +34,12 @@ public interface ClientPendingEditReadPlatformService {
|
|||||||
*/
|
*/
|
||||||
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
Page<ClientPendingEditData> retrieveAllPendingEdits(String status, String clientName, String fromDate, String toDate, Integer offset,
|
||||||
Integer limit);
|
Integer limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the email subject, body, sent timestamp and opened timestamp for the given pending
|
||||||
|
* edit — only populated once the approval email has been sent. Both {@code clientId} and
|
||||||
|
* {@code pendingEditId} must match to prevent cross-client data access. Returns {@code null}
|
||||||
|
* when the record does not exist.
|
||||||
|
*/
|
||||||
|
Map<String, Object> retrieveEmailPreview(Long clientId, Long pendingEditId);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -46,7 +46,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
pe.maker_id AS makerId, (maker.firstname || ' ' || maker.lastname) AS makerName,
|
||||||
pe.submitted_date AS submittedDate,
|
pe.submitted_date AS submittedDate,
|
||||||
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
|
pe.checker_id AS checkerId, (checker.firstname || ' ' || checker.lastname) AS checkerName,
|
||||||
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason
|
pe.checked_date AS checkedDate, pe.reject_reason AS rejectReason,
|
||||||
|
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||||
|
pe.email_opened_at AS emailOpenedAt
|
||||||
FROM m_client_pending_edit pe
|
FROM m_client_pending_edit pe
|
||||||
JOIN m_client c ON c.id = pe.client_id
|
JOIN m_client c ON c.id = pe.client_id
|
||||||
JOIN m_appuser maker ON maker.id = pe.maker_id
|
JOIN m_appuser maker ON maker.id = pe.maker_id
|
||||||
@@ -56,6 +58,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
private static final RowMapper<ClientPendingEditData> MAPPER = (ResultSet rs, int rowNum) -> {
|
||||||
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
final OffsetDateTime submittedDate = rs.getObject("submittedDate", OffsetDateTime.class);
|
||||||
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
final OffsetDateTime checkedDate = rs.getObject("checkedDate", OffsetDateTime.class);
|
||||||
|
final OffsetDateTime emailSentAt = rs.getObject("emailSentAt", OffsetDateTime.class);
|
||||||
|
final OffsetDateTime emailOpenedAt = rs.getObject("emailOpenedAt", OffsetDateTime.class);
|
||||||
|
final boolean emailSent = rs.getBoolean("emailSent");
|
||||||
return ClientPendingEditData.builder() //
|
return ClientPendingEditData.builder() //
|
||||||
.id(rs.getLong("id")) //
|
.id(rs.getLong("id")) //
|
||||||
.clientId(rs.getLong("clientId")) //
|
.clientId(rs.getLong("clientId")) //
|
||||||
@@ -70,6 +75,9 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
.checkerName(rs.getString("checkerName")) //
|
.checkerName(rs.getString("checkerName")) //
|
||||||
.checkedDate(checkedDate) //
|
.checkedDate(checkedDate) //
|
||||||
.rejectReason(rs.getString("rejectReason")) //
|
.rejectReason(rs.getString("rejectReason")) //
|
||||||
|
.emailSent(emailSent) //
|
||||||
|
.emailSentAt(emailSentAt) //
|
||||||
|
.emailOpenedAt(emailOpenedAt) //
|
||||||
.build();
|
.build();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,4 +127,32 @@ public class ClientPendingEditReadPlatformServiceImpl implements ClientPendingEd
|
|||||||
|
|
||||||
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
return new Page<>(items, totalFilteredRecords == null ? 0 : totalFilteredRecords.intValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Map<String, Object> retrieveEmailPreview(final Long clientId, final Long pendingEditId) {
|
||||||
|
final String sql = """
|
||||||
|
SELECT pe.email_subject AS emailSubject, pe.email_body AS emailBody,
|
||||||
|
pe.email_sent AS emailSent, pe.email_sent_at AS emailSentAt,
|
||||||
|
pe.email_opened_at AS emailOpenedAt, pe.email_from AS emailFrom,
|
||||||
|
c.email_address AS clientEmail
|
||||||
|
FROM m_client_pending_edit pe
|
||||||
|
JOIN m_client c ON c.id = pe.client_id
|
||||||
|
WHERE pe.id = ? AND pe.client_id = ?
|
||||||
|
""";
|
||||||
|
try {
|
||||||
|
return this.jdbcTemplate.queryForObject(sql, (rs, rowNum) -> {
|
||||||
|
final java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||||
|
result.put("emailSubject", rs.getString("emailSubject"));
|
||||||
|
result.put("emailBody", rs.getString("emailBody"));
|
||||||
|
result.put("emailSent", rs.getBoolean("emailSent"));
|
||||||
|
result.put("emailSentAt", rs.getObject("emailSentAt", OffsetDateTime.class));
|
||||||
|
result.put("emailOpenedAt", rs.getObject("emailOpenedAt", OffsetDateTime.class));
|
||||||
|
result.put("emailFrom", rs.getString("emailFrom"));
|
||||||
|
result.put("emailTo", rs.getString("clientEmail"));
|
||||||
|
return result;
|
||||||
|
}, pendingEditId, clientId);
|
||||||
|
} catch (final EmptyResultDataAccessException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
@@ -79,10 +79,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
|
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
|
||||||
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
|
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
|
||||||
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
|
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
|
||||||
|
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
|
||||||
|
|
||||||
private final ColumnValidator columnValidator;
|
private final ColumnValidator columnValidator;
|
||||||
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
||||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
|
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
|
||||||
private final ClientMapper clientMapper;
|
private final ClientMapper clientMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -227,6 +230,24 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
|
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
|
||||||
final ClientData clientData = clientMapper.map(client);
|
final ClientData clientData = clientMapper.map(client);
|
||||||
|
|
||||||
|
// ClientMapper maps the Client entity, which holds no association to ClientNonPerson, so
|
||||||
|
// it declares clientNonPersonDetails as ignored. Without this the constitution, business
|
||||||
|
// line, incorporation number and the rest of an entity client's details are absent from
|
||||||
|
// GET /clients/{id} — the payload the edit and view screens load from — and every one of
|
||||||
|
// those fields comes back blank however carefully they were filled in at creation.
|
||||||
|
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
|
||||||
|
|
||||||
|
// dipVerified is the flag captured when the client was onboarded and never changes
|
||||||
|
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
|
||||||
|
// reported for fraud or reassigned since then shows as flagged on the client's page,
|
||||||
|
// instead of the page continuing to display the old "Verified".
|
||||||
|
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
|
||||||
|
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
|
||||||
|
|
||||||
|
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
|
||||||
|
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
|
||||||
|
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
|
||||||
|
|
||||||
// Get client collaterals
|
// Get client collaterals
|
||||||
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
||||||
.getCollateralsPerClient(clientId);
|
.getCollateralsPerClient(clientId);
|
||||||
@@ -254,6 +275,57 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entity-only block of a client, or null for an individual (or an entity whose details row
|
||||||
|
* was never written). Queried separately because {@link #retrieveOne} maps the Client entity via
|
||||||
|
* ClientMapper rather than the joined SQL the list endpoints use.
|
||||||
|
*/
|
||||||
|
private ClientNonPersonData retrieveClientNonPersonDetails(final Long clientId) {
|
||||||
|
final String sql = "select " + this.clientNonPersonMapper.schema();
|
||||||
|
final List<ClientNonPersonData> details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR
|
||||||
|
clientId);
|
||||||
|
return details.isEmpty() ? null : details.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ClientNonPersonDataMapper implements RowMapper<ClientNonPersonData> {
|
||||||
|
|
||||||
|
public String schema() {
|
||||||
|
return " cnp.constitution_cv_id as constitutionId, cvConstitution.code_value as constitutionValue,"
|
||||||
|
+ " cnp.incorp_no as incorpNo, cnp.incorp_validity_till as incorpValidityTill,"
|
||||||
|
+ " cnp.main_business_line_cv_id as mainBusinessLineId,"
|
||||||
|
+ " cvMainBusinessLine.code_value as mainBusinessLineValue, cnp.remarks as remarks,"
|
||||||
|
+ " cnp.authorized_signatory as authorizedSignatory,"
|
||||||
|
+ " cnp.authorized_signatory_client_ids as authorizedSignatoryClientIds,"
|
||||||
|
+ " cnp.additional_info as additionalInfo,"
|
||||||
|
+ " cnp.date_of_registration as dateOfRegistration" + " from m_client_non_person cnp"
|
||||||
|
+ " left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id"
|
||||||
|
+ " left join m_code_value cvMainBusinessLine on cvMainBusinessLine.id = cnp.main_business_line_cv_id"
|
||||||
|
+ " where cnp.client_id = ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClientNonPersonData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long constitutionId = JdbcSupport.getLong(rs, "constitutionId");
|
||||||
|
final String constitutionValue = rs.getString("constitutionValue");
|
||||||
|
final CodeValueData constitution = CodeValueData.instance(constitutionId, constitutionValue);
|
||||||
|
|
||||||
|
final Long mainBusinessLineId = JdbcSupport.getLong(rs, "mainBusinessLineId");
|
||||||
|
final String mainBusinessLineValue = rs.getString("mainBusinessLineValue");
|
||||||
|
final CodeValueData mainBusinessLine = CodeValueData.instance(mainBusinessLineId, mainBusinessLineValue);
|
||||||
|
|
||||||
|
final String incorpNo = rs.getString("incorpNo");
|
||||||
|
final LocalDate incorpValidityTill = JdbcSupport.getLocalDate(rs, "incorpValidityTill");
|
||||||
|
final String remarks = rs.getString("remarks");
|
||||||
|
final String authorizedSignatory = rs.getString("authorizedSignatory");
|
||||||
|
final String authorizedSignatoryClientIds = rs.getString("authorizedSignatoryClientIds");
|
||||||
|
final String additionalInfo = rs.getString("additionalInfo");
|
||||||
|
final LocalDate dateOfRegistration = JdbcSupport.getLocalDate(rs, "dateOfRegistration");
|
||||||
|
|
||||||
|
return new ClientNonPersonData(constitution, incorpNo, incorpValidityTill, mainBusinessLine, remarks,
|
||||||
|
authorizedSignatory, additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
|
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
|
||||||
|
|
||||||
|
|||||||
+133
-9
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -96,6 +97,7 @@ import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundExc
|
|||||||
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
import org.apache.fineract.portfolio.savings.service.SavingsApplicationProcessWritePlatformService;
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.orm.jpa.JpaSystemException;
|
import org.springframework.orm.jpa.JpaSystemException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -133,9 +135,12 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
private final ClientRepository clientJpaRepository;
|
private final ClientRepository clientJpaRepository;
|
||||||
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
|
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
|
||||||
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
|
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
|
||||||
|
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
private final CifNumberGenerator cifNumberGenerator;
|
private final CifNumberGenerator cifNumberGenerator;
|
||||||
private final ClientContactOtpService clientContactOtpService;
|
private final ClientContactOtpService clientContactOtpService;
|
||||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -154,6 +159,13 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
this.clientNonPersonRepository.delete(clientNonPerson);
|
this.clientNonPersonRepository.delete(clientNonPerson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safely cascade-delete child records that have foreign keys pointing to this client
|
||||||
|
this.jdbcTemplate.update("delete from m_client_identifier where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_family_members where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_client_address where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_address where client_id = ?", clientId);
|
||||||
|
this.jdbcTemplate.update("delete from m_client_kyc where client_id = ?", clientId);
|
||||||
|
|
||||||
this.clientRepository.delete(client);
|
this.clientRepository.delete(client);
|
||||||
this.clientRepository.flush();
|
this.clientRepository.flush();
|
||||||
return new CommandProcessingResultBuilder() //
|
return new CommandProcessingResultBuilder() //
|
||||||
@@ -609,6 +621,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
newClient.setScreeningLogId(screeningLogId);
|
newClient.setScreeningLogId(screeningLogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UNSC sanctions gate. The UI disables Save & Next on a match, but that is a browser
|
||||||
|
// control only — screen server-side too, so a direct API call cannot onboard a listed
|
||||||
|
// person. The name is cleared only by a false positive the Principal Officer approved.
|
||||||
|
validateNotSanctioned(newClient.getDisplayName());
|
||||||
|
|
||||||
|
// DIP/MNRL gate — same reasoning: dipVerified arrives from the browser and cannot be
|
||||||
|
// trusted on its own. Record the status actually on file alongside it.
|
||||||
|
validateMobileNumberUsable(newClient.getMobileNo(), "mobileNo");
|
||||||
|
validateMobileNumberUsable(newClient.getAlternateMobileNo(), "alternateMobileNo");
|
||||||
|
newClient.setDipStatus(this.mobileLookupService.getLatestStatus(newClient.getMobileNo()));
|
||||||
|
newClient.setAlternateDipStatus(this.mobileLookupService.getLatestStatus(newClient.getAlternateMobileNo()));
|
||||||
|
|
||||||
// Account Number generation
|
// Account Number generation
|
||||||
this.clientRepository.saveAndFlush(newClient);
|
this.clientRepository.saveAndFlush(newClient);
|
||||||
if (StringUtils.isBlank(accountNo)) {
|
if (StringUtils.isBlank(accountNo)) {
|
||||||
@@ -617,9 +641,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
this.clientRepository.saveAndFlush(newClient);
|
this.clientRepository.saveAndFlush(newClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CIF Number generation — TTYYYYNNNNNN format. TT picked from client type
|
// CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123.
|
||||||
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate,
|
// Must run after the saveAndFlush above, since the id is what the number is derived from;
|
||||||
// NNNNNN is the next 6-digit sequence within the (type, year) bucket.
|
// deriving it from the shared id sequence is what keeps CIFs unique across all branches.
|
||||||
if (StringUtils.isBlank(cifNumber)) {
|
if (StringUtils.isBlank(cifNumber)) {
|
||||||
newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
|
newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
|
||||||
this.clientRepository.saveAndFlush(newClient);
|
this.clientRepository.saveAndFlush(newClient);
|
||||||
@@ -754,6 +778,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final String authorizedSignatory = this.fromApiJsonHelper
|
final String authorizedSignatory = this.fromApiJsonHelper
|
||||||
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
|
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
|
||||||
|
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
|
||||||
|
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
|
||||||
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
|
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
|
||||||
clientNonPersonElement);
|
clientNonPersonElement);
|
||||||
final LocalDate dateOfRegistration = this.fromApiJsonHelper
|
final LocalDate dateOfRegistration = this.fromApiJsonHelper
|
||||||
@@ -761,7 +787,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
|
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
|
||||||
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
|
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
|
||||||
dateOfRegistration);
|
dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
|
|
||||||
this.clientNonPersonRepository.save(newClientNonPerson);
|
this.clientNonPersonRepository.save(newClientNonPerson);
|
||||||
}
|
}
|
||||||
@@ -788,13 +814,27 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
* client onboarded without a mobile number is having one set for the first time) — there is no
|
* client onboarded without a mobile number is having one set for the first time) — there is no
|
||||||
* existing verified channel to prove possession of in that case.
|
* existing verified channel to prove possession of in that case.
|
||||||
*/
|
*/
|
||||||
private void verifyDualOtpForContactChange(final JsonCommand command, final String changedParamName, final String oldValue,
|
private void verifyDualOtpForContactChange(final Long clientId, final JsonCommand command, final String changedParamName, final String oldValue,
|
||||||
final String newValue) {
|
final String newValue) {
|
||||||
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
|
if (ClientApiConstants.mobileNoParamName.equals(changedParamName)) {
|
||||||
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
|
final Long mobileOldOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileOldOtpIdParamName);
|
||||||
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
|
final Long mobileNewOtpId = command.longValueOfParameterNamed(ClientApiConstants.mobileNewOtpIdParamName);
|
||||||
|
final Long oldMobileExceptionDocId = command.longValueOfParameterNamed("oldMobileVerificationExceptionDocumentId");
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(oldValue)) {
|
if (StringUtils.isNotBlank(oldValue)) {
|
||||||
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
if (oldMobileExceptionDocId != null && oldMobileExceptionDocId > 0) {
|
||||||
|
final Integer docCount = this.jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_document WHERE id = ? AND parent_entity_type = 'clients' AND parent_entity_id = ?",
|
||||||
|
Integer.class, oldMobileExceptionDocId, clientId);
|
||||||
|
if (docCount == null || docCount == 0) {
|
||||||
|
throw new PlatformDataIntegrityException(
|
||||||
|
"error.msg.client.mobile.old.verification.exception.invalid.document",
|
||||||
|
"FIR exception document ID " + oldMobileExceptionDocId + " does not exist or does not belong to client " + clientId,
|
||||||
|
"oldMobileVerificationExceptionDocumentId", oldMobileExceptionDocId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.clientContactOtpService.assertVerifiedMobile(mobileOldOtpId, oldValue, ClientApiConstants.mobileOldOtpIdParamName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
|
this.clientContactOtpService.assertVerifiedMobile(mobileNewOtpId, newValue, ClientApiConstants.mobileNewOtpIdParamName);
|
||||||
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
} else if (ClientApiConstants.emailAddressParamName.equals(changedParamName)) {
|
||||||
@@ -862,24 +902,30 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.mobileNoParamName, clientForUpdate.getMobileNo())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.mobileNoParamName);
|
||||||
final String oldValue = clientForUpdate.getMobileNo();
|
final String oldValue = clientForUpdate.getMobileNo();
|
||||||
verifyDualOtpForContactChange(command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.mobileNoParamName, oldValue, newValue);
|
||||||
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
changes.put(ClientApiConstants.mobileNoParamName, newValue);
|
||||||
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
oldMobileNoForNotification = oldValue;
|
oldMobileNoForNotification = oldValue;
|
||||||
newMobileNoForNotification = newValue;
|
newMobileNoForNotification = newValue;
|
||||||
mobileNoChanged = true;
|
mobileNoChanged = true;
|
||||||
|
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
|
||||||
|
// alert here. Left to the officer as a second manual step it would be forgotten,
|
||||||
|
// and the queue would fill with cases that were in fact already dealt with.
|
||||||
|
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
||||||
|
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
|
||||||
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
|
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
|
||||||
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
|
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.emailAddressParamName);
|
||||||
final String oldValue = clientForUpdate.getEmailAddress();
|
final String oldValue = clientForUpdate.getEmailAddress();
|
||||||
verifyDualOtpForContactChange(command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
verifyDualOtpForContactChange(clientId, command, ClientApiConstants.emailAddressParamName, oldValue, newValue);
|
||||||
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
changes.put(ClientApiConstants.emailAddressParamName, newValue);
|
||||||
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setEmailAddress(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
oldEmailAddressForNotification = oldValue;
|
oldEmailAddressForNotification = oldValue;
|
||||||
@@ -1450,7 +1496,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
|
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
|
||||||
validateParentGroupRulesBeforeClientActivation(client);
|
validateParentGroupRulesBeforeClientActivation(client);
|
||||||
this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client);
|
// Mother/Father-or-Caretaker is enforced where family members are captured (the Family
|
||||||
|
// Details step), not here: a client can reach approval through several different forms,
|
||||||
|
// and gating activation on it left approvers unable to approve what was handed to them.
|
||||||
final Locale locale = command.extractLocale();
|
final Locale locale = command.extractLocale();
|
||||||
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||||
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
|
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
|
||||||
@@ -1682,6 +1730,82 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
/*
|
/*
|
||||||
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
|
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Refuses onboarding of a name that hits the UN Security Council consolidated list.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Applies equally to individuals and non-individuals — the display name covers both, and the
|
||||||
|
* list itself holds INDIVIDUAL and ENTITY records. The only way past this is a false positive
|
||||||
|
* on the same name that the Principal Officer has approved, which is deliberately a different
|
||||||
|
* person's decision from the officer who reviewed the hit.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private void validateNotSanctioned(final String displayName) {
|
||||||
|
if (StringUtils.isBlank(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sanctionsScreeningService.hasApprovedFalsePositive(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Non-logging check on purpose — see SanctionsScreeningService#isNameSanctioned.
|
||||||
|
if (this.sanctionsScreeningService.isNameSanctioned(displayName)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.client.sanctions.match",
|
||||||
|
"'" + displayName + "' matched the UN Security Council Consolidated List. Onboarding is blocked until the "
|
||||||
|
+ "Principal Officer clears the alert.",
|
||||||
|
displayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a mobile number that the DoT Digital Intelligence Platform flagged, unless an
|
||||||
|
* officer explicitly recorded a decision to proceed with a written reason.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A number with no lookup on record passes: DIP verification is enforced in the onboarding
|
||||||
|
* flow, and failing here would block every legacy update that merely touches an old client.
|
||||||
|
* What this stops is the case that matters — a number the DoT has actively flagged being
|
||||||
|
* saved because the browser said it was fine.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Closes any open DIP/MNRL alert on a number that has just been replaced.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* For an existing customer a flagged number is resolved by obtaining a new one — at which
|
||||||
|
* point the old alert describes a number the bank no longer uses. Closing it here keeps the
|
||||||
|
* decision queue a list of work actually outstanding rather than a growing pile of cases that
|
||||||
|
* were already handled. Failure is logged, never propagated: the client update itself must
|
||||||
|
* not fail because a bookkeeping row could not be stamped.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
|
||||||
|
if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final String reason = StringUtils.isBlank(newNumber)
|
||||||
|
? "Number removed from the client record on resolution of the DIP/MNRL alert."
|
||||||
|
: "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
|
||||||
|
this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
|
||||||
|
if (StringUtils.isBlank(mobileNo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mobileLookupService.isMobileNumberUsable(mobileNo.trim())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.client.mobile.dip.flagged",
|
||||||
|
"Mobile number " + mobileNo + " is flagged on the DoT Digital Intelligence Platform. Record a decision "
|
||||||
|
+ "to proceed, with reasons, before saving this client.",
|
||||||
|
parameter);
|
||||||
|
}
|
||||||
|
|
||||||
private void validateParentGroupRulesBeforeClientActivation(Client client) {
|
private void validateParentGroupRulesBeforeClientActivation(Client client) {
|
||||||
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
|
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
|
||||||
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
|
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
|
||||||
|
|||||||
+3
@@ -23,4 +23,7 @@ import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
|||||||
|
|
||||||
public interface KycRiskRulesReadPlatformService {
|
public interface KycRiskRulesReadPlatformService {
|
||||||
List<KycRiskRuleData> retrieveAllRules();
|
List<KycRiskRuleData> retrieveAllRules();
|
||||||
|
|
||||||
|
/** One rule with its live-transaction counterpart resolved, as returned after a create. */
|
||||||
|
KycRiskRuleData retrieveRule(Long ruleId);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user