add KYC document renewal alerts, Aadhaar/PAN exceptions, and AML recurring alerts UI; remove old journal AML marking
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
This commit is contained in:
+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;
|
||||
}
|
||||
+8
-1
@@ -22,7 +22,14 @@ import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @param riskCategory
|
||||
* severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating
|
||||
* {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog
|
||||
* ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity.
|
||||
*/
|
||||
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
|
||||
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
|
||||
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition) 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 {
|
||||
}
|
||||
+25
-4
@@ -19,12 +19,15 @@
|
||||
package org.apache.fineract.portfolio.aml.service;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
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.domain.AmlAlert;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService {
|
||||
|
||||
private final AmlAlertRepository repository;
|
||||
private final ClientRiskRuleRepository clientRiskRuleRepository;
|
||||
|
||||
@Override
|
||||
public List<AmlAlertData> retrieve(String statusFilter) {
|
||||
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
|
||||
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
|
||||
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData)
|
||||
.collect(Collectors.toList());
|
||||
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
|
||||
// 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(),
|
||||
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
|
||||
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
|
||||
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
|
||||
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name());
|
||||
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;
|
||||
}
|
||||
}
|
||||
+9
@@ -44,6 +44,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
private final AmlTransactionRuleRepository ruleRepository;
|
||||
private final AmlAlertRepository alertRepository;
|
||||
private final ClientKycRepository clientKycRepository;
|
||||
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
|
||||
|
||||
@Override
|
||||
public List<AmlAlert> evaluate(AmlTransactionContext context) {
|
||||
@@ -59,6 +60,14 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
@@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
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.KycRiskRulesWritePlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Path("/v1/kyc/risk-rules")
|
||||
@@ -41,6 +44,7 @@ public class KycRiskRulesApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final KycRiskRulesReadPlatformService readPlatformService;
|
||||
private final KycRiskRulesWritePlatformService writePlatformService;
|
||||
private final DefaultToApiJsonSerializer<KycRiskRuleData> toApiJsonSerializer;
|
||||
|
||||
@GET
|
||||
@@ -52,4 +56,17 @@ public class KycRiskRulesApiResource {
|
||||
final List<KycRiskRuleData> rules = this.readPlatformService.retrieveAllRules();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -53,6 +53,22 @@ public final class ClientKycApiConstants {
|
||||
|
||||
// ── Extended KYC fields ──────────────────────────────────────────────────
|
||||
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 cinNumberParam = "cinNumber";
|
||||
public static final String dinNumberParam = "dinNumber";
|
||||
@@ -72,6 +88,10 @@ public final class ClientKycApiConstants {
|
||||
public static final String accountStatusParam = "accountStatus";
|
||||
public static final String freezeReasonParam = "freezeReason";
|
||||
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 form60DateParam = "form60Date";
|
||||
public static final String form15ApplicableParam = "form15Applicable";
|
||||
@@ -90,9 +110,14 @@ public final class ClientKycApiConstants {
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
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(
|
||||
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
|
||||
@@ -101,9 +126,14 @@ public final class ClientKycApiConstants {
|
||||
kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam,
|
||||
amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam,
|
||||
dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam,
|
||||
freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam,
|
||||
form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam,
|
||||
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. */
|
||||
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
|
||||
|
||||
+25
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -52,11 +53,29 @@ public final class ClientKycData {
|
||||
private final String panCategory;
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
private final String aadhaarNumberMasked;
|
||||
private final Boolean aadhaarVerified;
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
private final String gstinNumber;
|
||||
private final Boolean gstinVerified;
|
||||
@@ -65,6 +84,8 @@ public final class ClientKycData {
|
||||
// ── Enterprise / Non-Individual ──────────────────────────────────────────
|
||||
private final String enterprisePan;
|
||||
private final String cinNumber;
|
||||
private final Boolean cinVerified;
|
||||
private final LocalDate cinVerifiedOn;
|
||||
private final LocalDate incorporationDate;
|
||||
|
||||
// ── KYC Document ─────────────────────────────────────────────────────────
|
||||
@@ -84,6 +105,10 @@ public final class ClientKycData {
|
||||
private final String accountStatus;
|
||||
private final String freezeReason;
|
||||
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 ───────────────────────────────────────────────
|
||||
private final String form60Type;
|
||||
|
||||
+52
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.client.data;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -51,6 +52,45 @@ public class ClientKycRequest {
|
||||
@Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED")
|
||||
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")
|
||||
private String enterprisePan;
|
||||
|
||||
@@ -96,6 +136,18 @@ public class ClientKycRequest {
|
||||
@Schema(description = "Authority that ordered the freeze (null if not frozen)")
|
||||
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")
|
||||
private String form60Type;
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+21
-58
@@ -18,79 +18,42 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
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.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
|
||||
/**
|
||||
* Generates CIF numbers in the format <code>TTYYYYNNNNNN</code>:
|
||||
* <ul>
|
||||
* <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>
|
||||
* Generates CIF numbers in the format <code>CIF</code> followed by the client's id
|
||||
* zero-padded to 10 digits — e.g. client 123 → <code>CIF0000000123</code>.
|
||||
*
|
||||
* Example: an Individual client created in 2026 → <code>1120260000001</code>.
|
||||
* The generator handles concurrent inserts by retrying on uniqueness collisions
|
||||
* (the caller's outer save is what actually triggers the unique constraint).
|
||||
* <p>
|
||||
* Uniqueness is structural rather than probabilistic: <code>m_client.id</code> is a single sequence
|
||||
* 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
|
||||
@RequiredArgsConstructor
|
||||
public class CifNumberGenerator {
|
||||
|
||||
private static final String TYPE_INDIVIDUAL = "11";
|
||||
private static final String TYPE_CORPORATE = "22";
|
||||
private static final String TYPE_MINOR = "33";
|
||||
private static final String TYPE_STAFF = "44";
|
||||
private static final String CIF_PREFIX = "CIF";
|
||||
|
||||
private static final int SEQUENCE_WIDTH = 6;
|
||||
private static final int MAX_COLLISION_RETRIES = 5;
|
||||
|
||||
private final ClientRepository clientRepository;
|
||||
/** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */
|
||||
private static final int SEQUENCE_WIDTH = 10;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
final String typeCode = resolveTypeCode(client);
|
||||
final int year = resolveYear(client);
|
||||
final String prefix = typeCode + year;
|
||||
|
||||
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++;
|
||||
final Long clientId = client.getId();
|
||||
if (clientId == null) {
|
||||
throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted");
|
||||
}
|
||||
// Last resort: append client id to guarantee uniqueness if every attempt collided
|
||||
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();
|
||||
return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0');
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -85,14 +85,27 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus())
|
||||
.panCategory(kyc.getPanCategory())
|
||||
.panStatus(kyc.getPanStatus())
|
||||
.panDeclarationTaken(kyc.getPanDeclarationTaken())
|
||||
.panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields())
|
||||
.panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan())
|
||||
.panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan())
|
||||
.panDeclarationRemarks(kyc.getPanDeclarationRemarks())
|
||||
.panDeclarationTakenOn(kyc.getPanDeclarationTakenOn())
|
||||
.aadhaarNumberMasked(kyc.getAadhaarNumberMasked())
|
||||
.aadhaarVerified(kyc.isAadhaarVerified())
|
||||
.aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn())
|
||||
.aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken())
|
||||
.aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId())
|
||||
.aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate())
|
||||
.aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks())
|
||||
.aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn())
|
||||
.gstinNumber(kyc.getGstinNumber())
|
||||
.gstinVerified(kyc.isGstinVerified())
|
||||
.gstinVerifiedOn(kyc.getGstinVerifiedOn())
|
||||
.enterprisePan(kyc.getEnterprisePan())
|
||||
.cinNumber(kyc.getCinNumber())
|
||||
.cinVerified(kyc.isCinVerified())
|
||||
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
||||
.incorporationDate(kyc.getIncorporationDate())
|
||||
.kycDocumentName(kyc.getKycDocumentName())
|
||||
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
||||
@@ -106,6 +119,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
||||
.accountStatus(kyc.getAccountStatus())
|
||||
.freezeReason(kyc.getFreezeReason())
|
||||
.freezeAuthority(kyc.getFreezeAuthority())
|
||||
.freezeDate(kyc.getFreezeDate())
|
||||
.freezeAmount(kyc.getFreezeAmount())
|
||||
.unfreezeDate(kyc.getUnfreezeDate())
|
||||
.noDebitDate(kyc.getNoDebitDate())
|
||||
.form60Type(kyc.getForm60Type())
|
||||
.form60Date(kyc.getForm60Date())
|
||||
.form15Applicable(kyc.getForm15Applicable())
|
||||
|
||||
-38
@@ -158,27 +158,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
final Map<String, Object> changes = kyc.update(command);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1009,23 +988,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
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);
|
||||
LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}",
|
||||
client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue());
|
||||
|
||||
+6
-4
@@ -619,9 +619,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
this.clientRepository.saveAndFlush(newClient);
|
||||
}
|
||||
|
||||
// CIF Number generation — TTYYYYNNNNNN format. TT picked from client type
|
||||
// (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate,
|
||||
// NNNNNN is the next 6-digit sequence within the (type, year) bucket.
|
||||
// CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123.
|
||||
// Must run after the saveAndFlush above, since the id is what the number is derived from;
|
||||
// deriving it from the shared id sequence is what keeps CIFs unique across all branches.
|
||||
if (StringUtils.isBlank(cifNumber)) {
|
||||
newClient.updateCifNumber(cifNumberGenerator.generate(newClient));
|
||||
this.clientRepository.saveAndFlush(newClient);
|
||||
@@ -1466,7 +1466,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
||||
|
||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true);
|
||||
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 DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
|
||||
final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate");
|
||||
|
||||
+3
@@ -23,4 +23,7 @@ import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
|
||||
public interface KycRiskRulesReadPlatformService {
|
||||
List<KycRiskRuleData> retrieveAllRules();
|
||||
|
||||
/** One rule with its live-transaction counterpart resolved, as returned after a create. */
|
||||
KycRiskRuleData retrieveRule(Long ruleId);
|
||||
}
|
||||
|
||||
+9
@@ -48,6 +48,15 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
||||
return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public KycRiskRuleData retrieveRule(final Long ruleId) {
|
||||
final ClientRiskRule rule = this.repository.findById(ruleId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
|
||||
final AmlTransactionRule txnRule = this.amlTransactionRuleRepository.findAll().stream()
|
||||
.filter(txn -> ruleId.equals(txn.getSourceClientRiskRuleId())).findFirst().orElse(null);
|
||||
return mapToData(rule, txnRule);
|
||||
}
|
||||
|
||||
private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) {
|
||||
final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null;
|
||||
final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null;
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||
|
||||
public interface KycRiskRulesWritePlatformService {
|
||||
|
||||
/**
|
||||
* Adds a rule to the AML catalog. Creates one {@code m_client_risk_rule} row and, when the
|
||||
* request describes a transaction band or observation window, a companion
|
||||
* {@code m_aml_transaction_rule} row linked back to it.
|
||||
*
|
||||
* @return the persisted rule exactly as the catalog GET would return it
|
||||
*/
|
||||
KycRiskRuleData createRule(KycRiskRuleRequest request);
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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 java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper;
|
||||
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.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleData;
|
||||
import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
|
||||
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePlatformService {
|
||||
|
||||
private static final String RESOURCE = "kycriskrule";
|
||||
|
||||
private final ClientRiskRuleRepository repository;
|
||||
private final AmlTransactionRuleRepository amlTransactionRuleRepository;
|
||||
private final CodeValueRepositoryWrapper codeValueRepository;
|
||||
private final KycRiskRulesReadPlatformService readPlatformService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycRiskRuleData createRule(final KycRiskRuleRequest request) {
|
||||
validate(request);
|
||||
|
||||
// Appending is the safe default: order_position drives evaluation precedence, so silently
|
||||
// inserting into the middle of the catalog would re-prioritise existing rules.
|
||||
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
|
||||
|
||||
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
|
||||
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
|
||||
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
|
||||
request.getMinAnnualTurnover(), request.getMaxAnnualTurnover(),
|
||||
StringUtils.defaultIfBlank(request.getAmlCheckStatus(), "CLEAR"),
|
||||
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
|
||||
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
|
||||
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
|
||||
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"));
|
||||
|
||||
if (request.getOccupationCategoryCvId() != null) {
|
||||
final CodeValue occupationCategory = this.codeValueRepository
|
||||
.findOneWithNotFoundDetection(request.getOccupationCategoryCvId());
|
||||
rule.setOccupationCategory(occupationCategory);
|
||||
}
|
||||
if (request.getOccupationCvId() != null) {
|
||||
final CodeValue occupation = this.codeValueRepository.findOneWithNotFoundDetection(request.getOccupationCvId());
|
||||
rule.setOccupation(occupation);
|
||||
}
|
||||
|
||||
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
|
||||
|
||||
if (request.hasTransactionRule()) {
|
||||
this.amlTransactionRuleRepository.save(buildTransactionRule(saved, request));
|
||||
}
|
||||
|
||||
return this.readPlatformService.retrieveRule(saved.getId());
|
||||
}
|
||||
|
||||
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
|
||||
// The band's lower bound is what the rule fires at unless an explicit threshold is given,
|
||||
// so a rule described purely as a band is still evaluable against live transactions.
|
||||
final BigDecimal threshold = request.getTxnThresholdAmount() != null ? request.getTxnThresholdAmount()
|
||||
: request.getTxnMinLimitAmount();
|
||||
final AmlRuleType ruleType = StringUtils.isBlank(request.getTxnRuleType()) ? AmlRuleType.THRESHOLD
|
||||
: AmlRuleType.valueOf(request.getTxnRuleType().trim().toUpperCase());
|
||||
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
|
||||
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
|
||||
|
||||
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
|
||||
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||
}
|
||||
|
||||
private Integer nextOrderPosition() {
|
||||
final Integer max = this.repository.findMaxOrderPosition();
|
||||
return max == null ? 1 : max + 1;
|
||||
}
|
||||
|
||||
private void validate(final KycRiskRuleRequest request) {
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
|
||||
|
||||
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
|
||||
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
|
||||
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
|
||||
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
|
||||
.integerGreaterThanZero();
|
||||
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
|
||||
.integerZeroOrGreater();
|
||||
validator.reset().parameter("txnMinLimitAmount").value(request.getTxnMinLimitAmount()).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount()).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
validator.reset().parameter("txnThresholdAmount").value(request.getTxnThresholdAmount()).ignoreIfNull()
|
||||
.zeroOrPositiveAmount();
|
||||
|
||||
// A band whose ceiling sits under its floor would silently never match a transaction.
|
||||
if (request.getTxnMinLimitAmount() != null && request.getTxnMaxLimitAmount() != null
|
||||
&& request.getTxnMaxLimitAmount().compareTo(request.getTxnMinLimitAmount()) < 0) {
|
||||
validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount())
|
||||
.failWithCode("must.be.greater.than.min.limit");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(request.getRuleName()) && this.repository.existsByRuleNameIgnoreCase(request.getRuleName().trim())) {
|
||||
validator.reset().parameter("ruleName").value(request.getRuleName()).failWithCode("duplicate");
|
||||
}
|
||||
|
||||
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
|
||||
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
/** Guards the enum conversions in {@link #buildTransactionRule}, which would otherwise throw a
|
||||
* bare IllegalArgumentException and surface to the officer as a 500. */
|
||||
private <E extends Enum<E>> void validateEnum(final DataValidatorBuilder validator, final String parameter,
|
||||
final String value, final Class<E> type) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
final String candidate = value.trim().toUpperCase();
|
||||
for (final E constant : type.getEnumConstants()) {
|
||||
if (constant.name().equals(candidate)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
validator.reset().parameter(parameter).value(value).failWithCode("is.not.supported");
|
||||
}
|
||||
}
|
||||
+9
@@ -93,6 +93,15 @@ public class SanctionsXmlLoaderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the previous list rather than adding to it. A consolidated-list XML is a full
|
||||
// snapshot, so appending would both duplicate every entry that is still listed and leave
|
||||
// de-listed individuals active forever — screening has to reflect the current list only.
|
||||
// Done after parsing has fully succeeded, so a malformed file cannot leave the table empty.
|
||||
final long removed = sanctionsEntryRepository.countByActiveTrue();
|
||||
sanctionsEntryRepository.deleteAllByActiveTrue();
|
||||
sanctionsEntryRepository.flush();
|
||||
LOG.info("Removed {} previously loaded sanctions entries before loading the new snapshot", removed);
|
||||
|
||||
// Save in batches
|
||||
final int batchSize = 100;
|
||||
for (int i = 0; i < allEntries.size(); i += batchSize) {
|
||||
|
||||
+1
-1
@@ -647,7 +647,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
|
||||
}
|
||||
|
||||
private boolean isWithHoldTaxApplicableForInterestPosting(final SavingsAccountData savingsAccountData) {
|
||||
return this.withHoldTax(savingsAccountData) && this.depositAccountType(savingsAccountData).isSavingsDeposit();
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean withHoldTax(final SavingsAccountData savingsAccountData) {
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
@@ -508,4 +508,11 @@
|
||||
<include file="parts/0453_add_email_tracking_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0454_add_email_from_to_client_pending_edit.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0455_add_family_member_children_table.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0456_add_freeze_date_and_amount_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0457_add_unfreeze_date_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0458_add_no_debit_date_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0459_seed_aml_alerts_awaiting_review.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0460_create_aml_recurring_review.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0461_add_pan_declaration_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+30
@@ -35,6 +35,11 @@
|
||||
never exposes integer IDs. Unique constraint prevents accidental re-use. -->
|
||||
|
||||
<changeSet author="CBS" id="0453-1-add-email-tracking-token">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_tracking_token"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_tracking_token" type="VARCHAR(36)">
|
||||
<constraints nullable="true" unique="true"
|
||||
@@ -44,6 +49,11 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0453-2-add-email-sent">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_sent"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
@@ -52,6 +62,11 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0453-3-add-email-sent-at">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_sent_at"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_sent_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
@@ -60,6 +75,11 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0453-4-add-email-opened-at">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_opened_at"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_opened_at" type="TIMESTAMP WITH TIME ZONE">
|
||||
<constraints nullable="true"/>
|
||||
@@ -68,6 +88,11 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0453-5-add-email-subject">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_subject"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_subject" type="VARCHAR(300)">
|
||||
<constraints nullable="true"/>
|
||||
@@ -76,6 +101,11 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="CBS" id="0453-6-add-email-body">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_body"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_body" type="TEXT">
|
||||
<constraints nullable="true"/>
|
||||
|
||||
+5
@@ -3,6 +3,11 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet author="antigravity" id="add_email_from_to_client_pending_edit">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_client_pending_edit" columnName="email_from"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_pending_edit">
|
||||
<column name="email_from" type="VARCHAR(255)"/>
|
||||
</addColumn>
|
||||
|
||||
+5
@@ -3,6 +3,11 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
<changeSet id="1" author="fineract">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<tableExists tableName="m_family_member_children"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_family_member_children">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet id="0456-kyc-freeze-date" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="freeze_date"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="freeze_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0456-kyc-freeze-amount" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="freeze_amount"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="freeze_amount" type="DECIMAL(19,6)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet id="0457-kyc-unfreeze-date" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="unfreeze_date"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="unfreeze_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet id="0458-kyc-no-debit-date" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="no_debit_date"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="no_debit_date" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!--
|
||||
Two alerts left in RAISED so the Whitelist / Suspicious actions on the AML Rules Master
|
||||
"Flagged Transactions" tab can be exercised. The alerts seeded in 0449 have all been
|
||||
dispositioned, which correctly hides the buttons and leaves nothing to test against.
|
||||
|
||||
Transaction ids 2001/2002 are stubs — they do not point at rows in m_savings_account_transaction.
|
||||
That is deliberate and matches 0449: the review path (whitelist, or escalate opening an STR
|
||||
case) reads only the alert row, so a stub id exercises the full workflow. Only a screen that
|
||||
drills through to the underlying transaction would notice.
|
||||
-->
|
||||
<changeSet author="Antigravity" id="0459-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_alert WHERE transaction_id IN (2001, 2002)</sqlCheck>
|
||||
<sqlCheck expectedResult="2">
|
||||
SELECT COUNT(*) FROM m_client WHERE external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA')
|
||||
</sqlCheck>
|
||||
<sqlCheck expectedResult="2">
|
||||
SELECT COUNT(*) FROM m_aml_transaction_rule
|
||||
WHERE rule_code IN ('LARGE_CASH_TXN_25L', 'STRUCTURING_BELOW_10L')
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_aml_alert (
|
||||
transaction_type, transaction_id, client_id, rule_id, amount, currency_code,
|
||||
raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'SAVINGS', 2001,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'),
|
||||
4200000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
),
|
||||
(
|
||||
'SAVINGS', 2002,
|
||||
(SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'),
|
||||
(SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'),
|
||||
980000.000000, 'INR',
|
||||
NOW(), 'RAISED',
|
||||
1, NOW(), 1, NOW(), 0
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<!--
|
||||
One row per completed review of a customer who keeps tripping the AML engine.
|
||||
|
||||
Distinct from m_aml_alert.review_notes, which disposes of a single transaction: this records
|
||||
a judgement on the customer's *pattern* across many alerts. alert_count_at_review is what
|
||||
makes the queue clearable — a customer drops off once reviewed and only returns when their
|
||||
alert count grows past the number that was reviewed, so a reviewer is never asked the same
|
||||
question twice about the same evidence.
|
||||
-->
|
||||
<changeSet author="Antigravity" id="0460-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_aml_recurring_review"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_aml_recurring_review">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="client_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Alerts the customer had when this review was recorded. -->
|
||||
<column name="alert_count_at_review" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- MONITOR | ESCALATED | CLEARED -->
|
||||
<column name="outcome" type="VARCHAR(30)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="notes" type="VARCHAR(1000)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reviewed_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reviewed_on" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="Antigravity" id="0460-2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><foreignKeyConstraintExists foreignKeyName="FK_m_aml_recurring_review_client"/></not>
|
||||
</preConditions>
|
||||
<addForeignKeyConstraint baseTableName="m_aml_recurring_review" baseColumnNames="client_id"
|
||||
referencedTableName="m_client" referencedColumnNames="id"
|
||||
constraintName="FK_m_aml_recurring_review_client"/>
|
||||
</changeSet>
|
||||
|
||||
<!-- The queue reads the latest review per customer on every load. -->
|
||||
<changeSet author="Antigravity" id="0460-3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_m_aml_recurring_review_client"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_aml_recurring_review" indexName="idx_m_aml_recurring_review_client">
|
||||
<column name="client_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<!--
|
||||
PAN declaration — the record of a customer declaration accepted in place of a passing PAN
|
||||
check. When PAN verification fails (name/DOB does not match the Income Tax record, or the
|
||||
check could not be completed at all), the branch takes a signed declaration from the
|
||||
customer stating what differs. The declaration is what stops the KYC exception being
|
||||
raised, so the fields that justify it have to be stored, not merely shown once.
|
||||
|
||||
pan_declaration_name_on_pan / pan_declaration_dob_on_pan hold the values read off the
|
||||
physical PAN card by the officer; comparing them against the client record is what produces
|
||||
pan_declaration_mismatch_fields (NAME, DOB, NAME,DOB or NONE).
|
||||
-->
|
||||
<changeSet id="0461-kyc-pan-declaration" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="pan_declaration_taken"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="pan_declaration_taken" type="BOOLEAN" defaultValueBoolean="false"/>
|
||||
<column name="pan_declaration_mismatch_fields" type="VARCHAR(100)"/>
|
||||
<column name="pan_declaration_name_on_pan" type="VARCHAR(200)"/>
|
||||
<column name="pan_declaration_dob_on_pan" type="DATE"/>
|
||||
<column name="pan_declaration_remarks" type="VARCHAR(500)"/>
|
||||
<column name="pan_declaration_taken_on" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<!--
|
||||
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; there is no number to send to the OTP flow until the Aadhaar itself is issued,
|
||||
so the check cannot pass however long the branch waits. Recording the receipt is what stops
|
||||
the KYC exception being raised, so it has to be stored rather than merely sighted.
|
||||
|
||||
aadhaar_enrolment_date is the date printed on the slip. It is kept because the receipt is
|
||||
inherently temporary: an officer reviewing the record needs to see how old the enrolment is,
|
||||
and any future rule about how long a receipt remains acceptable has to be able to read it.
|
||||
-->
|
||||
<changeSet id="0462-kyc-aadhaar-enrolment-receipt" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_kyc" columnName="aadhaar_receipt_taken"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_kyc">
|
||||
<column name="aadhaar_receipt_taken" type="BOOLEAN" defaultValueBoolean="false"/>
|
||||
<column name="aadhaar_enrolment_id" type="VARCHAR(40)"/>
|
||||
<column name="aadhaar_enrolment_date" type="DATE"/>
|
||||
<column name="aadhaar_receipt_remarks" type="VARCHAR(500)"/>
|
||||
<column name="aadhaar_receipt_taken_on" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+1
@@ -54,6 +54,7 @@
|
||||
<class>org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.Group</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.Client</class>
|
||||
<class>org.apache.fineract.portfolio.client.domain.ClientKyc</class>
|
||||
<class>org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration</class>
|
||||
<class>org.apache.fineract.portfolio.group.domain.GroupRole</class>
|
||||
<class>org.apache.fineract.portfolio.paymenttype.domain.PaymentType</class>
|
||||
|
||||
Reference in New Issue
Block a user