From 9eac40bb5c1126d2c1624e785a8ee65b859116fa Mon Sep 17 00:00:00 2001
From: keshav
Date: Mon, 17 Aug 2026 17:54:47 +0530
Subject: [PATCH] feat: add database export API, welcome kit return tracking,
and sanctions/mobile lookup updates
Co-Authored-By: Claude Opus 5 (1M context)
---
.../portfolio/client/data/ClientData.java | 28 ++
.../domain/SanctionsScreeningLog.java | 41 +++
.../SanctionsScreeningLogRepository.java | 8 +
.../api/DatabaseExportApiResource.java | 114 +++++++
.../domain/DatabaseExportRequest.java | 152 +++++++++
.../DatabaseExportRequestRepository.java | 42 +++
.../service/DatabaseExportService.java | 42 +++
.../service/DatabaseExportServiceImpl.java | 318 ++++++++++++++++++
.../service/ClientContactOtpServiceImpl.java | 18 +-
.../portfolio/client/mapper/ClientMapper.java | 7 +-
.../ClientReadPlatformServiceImpl.java | 13 +
...WritePlatformServiceJpaRepositoryImpl.java | 32 ++
.../api/MobileLookupApiResource.java | 49 ++-
.../data/MobileLookupLogData.java | 13 +
.../service/MobileLookupService.java | 35 ++
.../service/MobileLookupServiceImpl.java | 115 ++++++-
.../api/SanctionsScreeningApiResource.java | 40 ++-
.../data/SanctionsScreeningLogData.java | 36 +-
.../service/SanctionsScreeningService.java | 20 ++
.../SanctionsScreeningServiceImpl.java | 138 +++++++-
.../welcomekit/data/ClientWelcomeKitData.java | 1 +
.../data/WelcomeKitDispatchData.java | 3 +
.../welcomekit/domain/WelcomeKitDispatch.java | 49 ++-
.../domain/WelcomeKitDispatchRepository.java | 2 +
.../WelcomeKitReadPlatformServiceImpl.java | 38 ++-
.../WelcomeKitWritePlatformServiceImpl.java | 55 ++-
.../db/changelog/tenant/changelog-tenant.xml | 4 +
.../parts/0466_add_sanctions_str_fields.xml | 40 +++
.../0467_add_welcome_kit_return_tracking.xml | 16 +
...add_address_id_to_welcome_kit_dispatch.xml | 20 ++
.../0469_create_database_export_request.xml | 72 ++++
31 files changed, 1505 insertions(+), 56 deletions(-)
create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/api/DatabaseExportApiResource.java
create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/domain/DatabaseExportRequest.java
create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/domain/DatabaseExportRequestRepository.java
create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/service/DatabaseExportService.java
create mode 100644 fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/service/DatabaseExportServiceImpl.java
create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_add_sanctions_str_fields.xml
create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_add_welcome_kit_return_tracking.xml
create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml
create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml
diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java
index 452120d95..319ecdc23 100644
--- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java
+++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientData.java
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable, Serializable {
private Boolean alternateDipVerified;
private Long alternateDipLookupLogId;
private String alternateDipTimestamp;
+ /**
+ * Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
+ * captured at onboarding. A customer cleared at onboarding whose number is later reported or
+ * reassigned would otherwise keep showing "Verified" forever.
+ */
+ private String dipLiveStatus;
+ private String alternateDipLiveStatus;
+ /**
+ * Disposition of the client's most recent flagged sanctions alert, or null when they carry
+ * none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
+ * would keep reading "Clear" after the client was added to a later list snapshot.
+ */
+ private String sanctionsAlertStatus;
private Boolean nameVerified;
private Long screeningLogId;
private Boolean isStaff;
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
+ // Carried across too: these helpers rebuild ClientData field by field, so a field missing
+ // here is silently dropped from the payload the client view loads.
+ data.dipLiveStatus = clientData.dipLiveStatus;
+ data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
+ data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
+ // Carried across too: these helpers rebuild ClientData field by field, so a field missing
+ // here is silently dropped from the payload the client view loads.
+ data.dipLiveStatus = clientData.dipLiveStatus;
+ data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
+ data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
+ // Carried across too: these helpers rebuild ClientData field by field, so a field missing
+ // here is silently dropped from the payload the client view loads.
+ data.dipLiveStatus = clientData.dipLiveStatus;
+ data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
+ data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java
index 3bf35f0b2..4ebed2046 100644
--- a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java
+++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java
@@ -96,6 +96,24 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
@Column(name = "frozen_on")
private OffsetDateTime frozenOn;
+ // ── STR tracking ──────────────────────────────────────────────────────────
+ // A confirmed match is reportable to FIU-IND. Tracked here rather than as an AML alert: an
+ // AML alert is built around a monetary transaction, whereas this is triggered by who the
+ // customer is, so the two are surfaced as separate streams on the FIU screen.
+
+ /** NULL until approval; then PENDING_FILING → FILED. */
+ @Column(name = "str_status", length = 30)
+ private String strStatus;
+
+ @Column(name = "str_due_date")
+ private java.time.LocalDate strDueDate;
+
+ @Column(name = "str_raised_on")
+ private OffsetDateTime strRaisedOn;
+
+ public static final String STR_PENDING_FILING = "PENDING_FILING";
+ public static final String STR_FILED = "FILED";
+
public static final String RESULT_FLAGGED = "FLAGGED";
public static final String RESULT_CLEAR = "CLEAR";
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
@@ -146,6 +164,11 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
if (strReference != null) {
this.fiuStrReference = strReference;
+ // Recording the FIU-IND acknowledgement is what closes the filing obligation, so the
+ // case stops appearing as outstanding on the FIU screen.
+ if (!strReference.isBlank()) {
+ this.strStatus = STR_FILED;
+ }
}
if (nodalOfficerInformedOn != null) {
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
@@ -157,6 +180,21 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
}
+ /**
+ * Opens the STR obligation on approval of a confirmed match. PMLA allows seven working days
+ * from forming the suspicion; seven calendar days is the stricter reading.
+ */
+ public void raiseStr() {
+ if (this.strStatus != null) {
+ return;
+ }
+ this.strStatus = STR_PENDING_FILING;
+ this.strRaisedOn = DateUtils.getOffsetDateTimeOfTenant();
+ this.strDueDate = DateUtils.getBusinessLocalDate().plusDays(7);
+ }
+
+ public boolean isStrPending() { return STR_PENDING_FILING.equals(this.strStatus); }
+
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
public boolean isApprovedFalsePositive() {
@@ -187,4 +225,7 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
public String getFrozenAccountIds() { return frozenAccountIds; }
public OffsetDateTime getFrozenOn() { return frozenOn; }
+ public String getStrStatus() { return strStatus; }
+ public java.time.LocalDate getStrDueDate() { return strDueDate; }
+ public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
}
diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java
index 17bb4845a..d8d3b7559 100644
--- a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java
+++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java
@@ -59,4 +59,12 @@ public interface SanctionsScreeningLogRepository extends JpaRepository findFlaggedByClientId(@Param("clientId") Long clientId);
+
+ /**
+ * Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
+ * Pass null for every STR regardless of whether it has been filed.
+ */
+ @Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
+ + "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
+ List findStrCases(@Param("status") String status);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/api/DatabaseExportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/api/DatabaseExportApiResource.java
new file mode 100644
index 000000000..aa5045d83
--- /dev/null
+++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/databaseexport/api/DatabaseExportApiResource.java
@@ -0,0 +1,114 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.fineract.infrastructure.databaseexport.api;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
+import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
+import org.springframework.stereotype.Component;
+
+@Path("/v1/databaseexport")
+@Component
+@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
+@RequiredArgsConstructor
+public class DatabaseExportApiResource {
+
+ private final DatabaseExportService service;
+ private final FromJsonHelper fromJsonHelper;
+
+ @GET
+ @Produces({ MediaType.APPLICATION_JSON })
+ @Operation(summary = "List export requests, optionally filtered by status")
+ public List
*/
+ /**
+ * Closes any open DIP/MNRL alert on a number that has just been replaced.
+ *
+ *
+ * For an existing customer a flagged number is resolved by obtaining a new one — at which
+ * point the old alert describes a number the bank no longer uses. Closing it here keeps the
+ * decision queue a list of work actually outstanding rather than a growing pile of cases that
+ * were already handled. Failure is logged, never propagated: the client update itself must
+ * not fail because a bookkeeping row could not be stamped.
+ *
+ */
+ private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
+ if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
+ return;
+ }
+ try {
+ final String reason = StringUtils.isBlank(newNumber)
+ ? "Number removed from the client record on resolution of the DIP/MNRL alert."
+ : "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
+ this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
+ } catch (Exception e) {
+ log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
+ e.getMessage());
+ }
+ }
+
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
if (StringUtils.isBlank(mobileNo)) {
return;
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java
index a5d4cfe13..4c87302a6 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java
@@ -37,6 +37,7 @@ import jakarta.ws.rs.core.MediaType;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -129,11 +130,25 @@ public class MobileLookupApiResource {
logs = mobileLookupService.getLookupLogs();
}
- final List logDataList = logs.stream()
- .map(MobileLookupLogData::fromEntity)
- .collect(Collectors.toList());
+ return listSerializer.serialize(withClientNames(logs));
+ }
- return listSerializer.serialize(logDataList);
+ /**
+ * Attaches the client's display name and account number to each row. The log stores only the
+ * client id, and a queue of bare ids is not something a reviewing officer can act on.
+ */
+ private List withClientNames(final List logs) {
+ final Set clientIds = logs.stream()
+ .map(MobileLookupLog::getClientId)
+ .filter(java.util.Objects::nonNull)
+ .collect(Collectors.toSet());
+ final Map names = mobileLookupService.resolveClientNames(clientIds);
+
+ return logs.stream().map(log -> {
+ final String[] client = log.getClientId() == null ? null : names.get(log.getClientId());
+ return MobileLookupLogData.fromEntity(log, client == null ? null : client[0],
+ client == null ? null : client[1]);
+ }).collect(Collectors.toList());
}
@GET
@@ -146,10 +161,7 @@ public class MobileLookupApiResource {
public String getPendingDecisions() {
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
- final List pending = mobileLookupService.getPendingDecisions().stream()
- .map(MobileLookupLogData::fromEntity)
- .collect(Collectors.toList());
- return listSerializer.serialize(pending);
+ return listSerializer.serialize(withClientNames(mobileLookupService.getPendingDecisions()));
}
@POST
@@ -170,4 +182,25 @@ public class MobileLookupApiResource {
.recordDecision(logId, decision, reason, this.context.authenticatedUser().getId()));
return logSerializer.serialize(result);
}
+
+ @POST
+ @Path("/rescreen")
+ @Consumes({ MediaType.APPLICATION_JSON })
+ @Produces({ MediaType.APPLICATION_JSON })
+ @Operation(summary = "Re-screen existing clients' mobile numbers against DIP/MNRL",
+ description = "Screens every active client's mobile and alternate mobile numbers, opening a "
+ + "decision row for each hit. Detects numbers reported for fraud or reassigned after "
+ + "the customer was onboarded.")
+ public String rescreenExistingClients() {
+ this.context.authenticatedUser().validateHasPermissionTo("OVERRIDE_MOBILE_LOOKUP");
+
+ final int flagged = mobileLookupService.rescreenExistingClients();
+ final Map response = new LinkedHashMap<>();
+ response.put("message", "DIP/MNRL re-screening complete");
+ response.put("numbersFlagged", flagged);
+ // Reported so "0 flagged" can be told apart from "0 clients were even looked at" — the
+ // two mean very different things and the caller could not previously distinguish them.
+ response.put("clientsScanned", mobileLookupService.countScreenableClients());
+ return mapSerializer.serialize(response);
+ }
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java
index fad7c70d6..1fa67b25f 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java
@@ -34,6 +34,12 @@ public class MobileLookupLogData {
private final String responseMessage;
private final String lookupSource;
private final Long clientId;
+ /**
+ * Resolved separately from the log row, which stores only the id. A queue showing a bare
+ * "145" gives the reviewing officer nothing to act on — they need to know who it is.
+ */
+ private final String clientName;
+ private final String clientAccountNo;
private final String lookedUpOn;
private final String overrideDecision;
private final String overrideReason;
@@ -43,6 +49,11 @@ public class MobileLookupLogData {
private final boolean usable;
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
+ return fromEntity(log, null, null);
+ }
+
+ public static MobileLookupLogData fromEntity(final MobileLookupLog log, final String clientName,
+ final String clientAccountNo) {
return new MobileLookupLogData(
log.getId(),
log.getMobileNumber(),
@@ -52,6 +63,8 @@ public class MobileLookupLogData {
log.getResponseMessage(),
log.getLookupSource(),
log.getClientId(),
+ clientName,
+ clientAccountNo,
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null,
log.getOverrideDecision(),
log.getOverrideReason(),
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java
index f8b0593e0..c0e392909 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java
@@ -59,4 +59,39 @@ public interface MobileLookupService {
* turned away once should not be screened as if it had no history.
*/
MobileLookupLog findPriorRejection(String mobileNumber);
+
+ /**
+ * Re-screens every active client's mobile numbers against DIP/MNRL, opening a decision row
+ * for each hit.
+ *
+ *
+ * Onboarding-time screening only ever sees a number as it is typed. A number reported for
+ * fraud, or disconnected and reassigned, after the customer was onboarded is otherwise never
+ * detected — and that is precisely the case where the bank's OTPs start reaching a stranger.
+ *
+ *
+ * @return count of numbers that came back flagged
+ */
+ int rescreenExistingClients();
+
+ /**
+ * Display name and account number for the given client ids, keyed by id. Resolved in one
+ * query so a queue of lookups does not issue a round trip per row.
+ */
+ java.util.Map resolveClientNames(java.util.Collection clientIds);
+
+ /** How many clients the re-screen sweep would look at — lets "0 flagged" be interpreted. */
+ int countScreenableClients();
+
+ /**
+ * Marks every open alert on a number as resolved, recording why.
+ *
+ *
+ * Called when the number is replaced on a client: the alert then refers to a number the bank
+ * no longer uses, and leaving it "Awaiting Decision" would misrepresent it as outstanding work.
+ *
+ *
+ * @return how many alerts were closed
+ */
+ int closeOpenAlerts(String mobileNumber, Long clientId, String reason);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
index 92878b569..54670f91e 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
@@ -21,8 +21,12 @@ package org.apache.fineract.portfolio.mobilelookup.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
+import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData;
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult;
@@ -32,6 +36,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
+import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
@@ -55,12 +60,14 @@ public class MobileLookupServiceImpl implements MobileLookupService {
private final String baseUrl;
private final RestTemplate restTemplate;
private final MobileLookupLogRepository logRepository;
+ private final JdbcTemplate jdbcTemplate;
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
- MobileLookupLogRepository logRepository) {
+ MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
this.baseUrl = baseUrl;
this.restTemplate = new RestTemplate();
this.logRepository = logRepository;
+ this.jdbcTemplate = jdbcTemplate;
}
@Override
@@ -187,6 +194,112 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return rows.isEmpty() ? null : rows.get(0);
}
+ @Override
+ @Transactional
+ public int rescreenExistingClients() {
+ // Active clients only (status 300). A pending client is still inside the onboarding flow,
+ // where the number is screened as it is entered; this sweep exists for the customers that
+ // flow has already finished with. Closed/rejected/withdrawn have no live relationship.
+ final List> rows = jdbcTemplate.queryForList(
+ "SELECT id, mobile_no, alternate_mobile_no FROM m_client WHERE status_enum = 300 "
+ + "AND (mobile_no IS NOT NULL OR alternate_mobile_no IS NOT NULL)");
+
+ LOG.info("DIP/MNRL re-screening starting for {} client(s) with a number on file", rows.size());
+
+ int flagged = 0;
+ for (final Map row : rows) {
+ final Long clientId = ((Number) row.get("id")).longValue();
+ flagged += rescreenNumber((String) row.get("mobile_no"), clientId);
+ flagged += rescreenNumber((String) row.get("alternate_mobile_no"), clientId);
+ }
+ LOG.info("DIP/MNRL re-screening complete: {} number(s) flagged across {} clients", flagged, rows.size());
+ return flagged;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public int countScreenableClients() {
+ final Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM m_client WHERE status_enum = 300 "
+ + "AND (mobile_no IS NOT NULL OR alternate_mobile_no IS NOT NULL)",
+ Integer.class);
+ return count == null ? 0 : count;
+ }
+
+ /** Screens one number, tolerating per-number failure so one bad row cannot abort the sweep. */
+ private int rescreenNumber(final String mobileNumber, final Long clientId) {
+ if (mobileNumber == null || mobileNumber.isBlank()) {
+ return 0;
+ }
+ final String trimmed = mobileNumber.trim();
+
+ // Re-running the sweep must not stack another identical alert onto the queue. An open
+ // alert for this number and client is the same case still awaiting the same decision;
+ // duplicating it buries the real work under repeats of itself. A number already decided
+ // is likewise left alone until its value or decision changes.
+ final MobileLookupLog existing = findLatest(trimmed);
+ if (existing != null && existing.requiresDecision() && java.util.Objects.equals(existing.getClientId(), clientId)) {
+ LOG.debug("Skipping re-screen of {} for client {} — an open alert already exists", trimmed, clientId);
+ return 0;
+ }
+
+ try {
+ final MobileLookupResult result = checkMobileNumber(trimmed, "PERIODIC_RESCREEN", clientId);
+ final boolean hit = result.getData() != null
+ && (result.getData().isSuspected() || result.getData().isDisconnected());
+ return hit ? 1 : 0;
+ } catch (Exception e) {
+ LOG.error("Re-screening failed for {} (client {}): {}", mobileNumber, clientId, e.getMessage());
+ return 0;
+ }
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Map resolveClientNames(final Collection clientIds) {
+ final Map byId = new HashMap<>();
+ if (clientIds == null || clientIds.isEmpty()) {
+ return byId;
+ }
+ final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
+ final List> rows = jdbcTemplate.queryForList(
+ "SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
+ clientIds.toArray());
+
+ for (final Map row : rows) {
+ final Long id = ((Number) row.get("id")).longValue();
+ byId.put(id, new String[] { (String) row.get("display_name"), (String) row.get("account_no") });
+ }
+ return byId;
+ }
+
+ @Override
+ @Transactional
+ public int closeOpenAlerts(final String mobileNumber, final Long clientId, final String reason) {
+ if (mobileNumber == null || mobileNumber.isBlank()) {
+ return 0;
+ }
+ final List open = logRepository.findByMobileNumber(mobileNumber.trim()).stream()
+ .filter(MobileLookupLog::requiresDecision)
+ .filter(log -> log.getOverrideDecision() == null)
+ .collect(Collectors.toList());
+
+ for (final MobileLookupLog log : open) {
+ // REJECTED is the correct terminal state: the bank turned this number away. It also
+ // keeps isMobileNumberUsable() false, so the number cannot quietly come back into use.
+ log.recordDecision(MobileLookupLog.DECISION_REJECTED, reason, null);
+ if (log.getClientId() == null && clientId != null) {
+ log.setClientId(clientId);
+ }
+ }
+ if (!open.isEmpty()) {
+ logRepository.saveAll(open);
+ logRepository.flush();
+ LOG.info("Closed {} open DIP alert(s) for replaced number {} on client {}", open.size(), mobileNumber, clientId);
+ }
+ return open.size();
+ }
+
@Override
@Transactional(readOnly = true)
public MobileLookupLog findPriorRejection(final String mobileNumber) {
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java
index 9643e8644..ee580c3a5 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java
@@ -36,6 +36,7 @@ import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -186,11 +187,23 @@ public class SanctionsScreeningApiResource {
logs = sanctionsScreeningService.getScreeningLogs();
}
- final List logDataList = logs.stream()
- .map(SanctionsScreeningLogData::fromEntity)
- .collect(Collectors.toList());
+ return listSerializer.serialize(withClientNames(logs));
+ }
- return listSerializer.serialize(logDataList);
+ /**
+ * Attaches the client's display name and account number to each row. The log stores only the
+ * client id, and a queue of bare ids is not something a reviewing officer can act on.
+ */
+ private List withClientNames(final List logs) {
+ final Set clientIds = logs.stream().map(SanctionsScreeningLog::getClientId).filter(java.util.Objects::nonNull)
+ .collect(Collectors.toSet());
+ final Map names = sanctionsScreeningService.resolveClientNames(clientIds);
+
+ return logs.stream().map(log -> {
+ final String[] client = log.getClientId() == null ? null : names.get(log.getClientId());
+ return SanctionsScreeningLogData.fromEntity(log, client == null ? null : client[0],
+ client == null ? null : client[1]);
+ }).collect(Collectors.toList());
}
// ── Alert disposition ─────────────────────────────────────────────────────
@@ -209,10 +222,7 @@ public class SanctionsScreeningApiResource {
@QueryParam("status") @Parameter(description = "Filter by disposition") final String status) {
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
- final List alerts = sanctionsScreeningService.getAlerts(status).stream()
- .map(SanctionsScreeningLogData::fromEntity)
- .collect(Collectors.toList());
- return listSerializer.serialize(alerts);
+ return listSerializer.serialize(withClientNames(sanctionsScreeningService.getAlerts(status)));
}
@POST
@@ -267,6 +277,20 @@ public class SanctionsScreeningApiResource {
return logSerializer.serialize(result);
}
+ @GET
+ @Path("/str-cases")
+ @Consumes({ MediaType.APPLICATION_JSON })
+ @Produces({ MediaType.APPLICATION_JSON })
+ @Operation(summary = "Sanctions STR cases for the FIU-IND screen",
+ description = "Confirmed matches carrying a reporting obligation. Filter with "
+ + "?status=PENDING_FILING | FILED. Kept separate from transaction-driven AML alerts.")
+ public String getStrCases(
+ @QueryParam("status") @Parameter(description = "PENDING_FILING or FILED") final String status) {
+ this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
+
+ return listSerializer.serialize(withClientNames(sanctionsScreeningService.getStrCases(status)));
+ }
+
@POST
@Path("/rescreen")
@Consumes({ MediaType.APPLICATION_JSON })
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java
index ada39de25..dd692ae4e 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java
@@ -34,6 +34,12 @@ public final class SanctionsScreeningLogData {
private final String matchedNames;
private final String screeningSource;
private final Long clientId;
+ /**
+ * Resolved separately from the log row, which stores only the id. A queue showing a bare
+ * "124" gives the reviewing officer nothing to act on — they need to know who it is.
+ */
+ private final String clientName;
+ private final String clientAccountNo;
private final String ipAddress;
private final OffsetDateTime screenedOn;
private final Long screenedBy;
@@ -47,15 +53,19 @@ public final class SanctionsScreeningLogData {
private final OffsetDateTime nodalOfficerInformedOn;
private final String frozenAccountIds;
private final OffsetDateTime frozenOn;
+ private final String strStatus;
+ private final String strDueDate;
+ private final String strRaisedOn;
public SanctionsScreeningLogData(final Long id, final String screenedName, final String screeningResult,
final Integer matchCount, final String matchedEntryIds, final String matchedNames,
- final String screeningSource, final Long clientId, final String ipAddress,
+ final String screeningSource, final Long clientId, final String clientName, final String clientAccountNo,
+ final String ipAddress,
final OffsetDateTime screenedOn, final Long screenedBy, final String disposition,
final String dispositionReason, final Long reviewedBy, final OffsetDateTime reviewedOn,
final Long approvedBy, final OffsetDateTime approvedOn, final String fiuStrReference,
final OffsetDateTime nodalOfficerInformedOn, final String frozenAccountIds,
- final OffsetDateTime frozenOn) {
+ final OffsetDateTime frozenOn, final String strStatus, final String strDueDate, final String strRaisedOn) {
this.id = id;
this.screenedName = screenedName;
this.screeningResult = screeningResult;
@@ -64,6 +74,8 @@ public final class SanctionsScreeningLogData {
this.matchedNames = matchedNames;
this.screeningSource = screeningSource;
this.clientId = clientId;
+ this.clientName = clientName;
+ this.clientAccountNo = clientAccountNo;
this.ipAddress = ipAddress;
this.screenedOn = screenedOn;
this.screenedBy = screenedBy;
@@ -77,9 +89,17 @@ public final class SanctionsScreeningLogData {
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
this.frozenAccountIds = frozenAccountIds;
this.frozenOn = frozenOn;
+ this.strStatus = strStatus;
+ this.strDueDate = strDueDate;
+ this.strRaisedOn = strRaisedOn;
}
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
+ return fromEntity(log, null, null);
+ }
+
+ public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log, final String clientName,
+ final String clientAccountNo) {
return new SanctionsScreeningLogData(
log.getId(),
log.getScreenedName(),
@@ -89,6 +109,8 @@ public final class SanctionsScreeningLogData {
log.getMatchedNames(),
log.getScreeningSource(),
log.getClientId(),
+ clientName,
+ clientAccountNo,
log.getIpAddress(),
log.getScreenedOn(),
log.getCreatedBy().orElse(null),
@@ -101,7 +123,10 @@ public final class SanctionsScreeningLogData {
log.getFiuStrReference(),
log.getNodalOfficerInformedOn(),
log.getFrozenAccountIds(),
- log.getFrozenOn()
+ log.getFrozenOn(),
+ log.getStrStatus(),
+ log.getStrDueDate() != null ? log.getStrDueDate().toString() : null,
+ log.getStrRaisedOn() != null ? log.getStrRaisedOn().toString() : null
);
}
@@ -113,6 +138,8 @@ public final class SanctionsScreeningLogData {
public String getMatchedNames() { return matchedNames; }
public String getScreeningSource() { return screeningSource; }
public Long getClientId() { return clientId; }
+ public String getClientName() { return clientName; }
+ public String getClientAccountNo() { return clientAccountNo; }
public String getIpAddress() { return ipAddress; }
public OffsetDateTime getScreenedOn() { return screenedOn; }
public Long getScreenedBy() { return screenedBy; }
@@ -126,4 +153,7 @@ public final class SanctionsScreeningLogData {
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
public String getFrozenAccountIds() { return frozenAccountIds; }
public OffsetDateTime getFrozenOn() { return frozenOn; }
+ public String getStrStatus() { return strStatus; }
+ public String getStrDueDate() { return strDueDate; }
+ public String getStrRaisedOn() { return strRaisedOn; }
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java
index c8f6c55b7..f9fe6f584 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java
@@ -112,4 +112,24 @@ public interface SanctionsScreeningService {
* @return number of clients that produced a new flagged alert
*/
int rescreenExistingClients();
+
+ /**
+ * Display name and account number for the given client ids, keyed by id. Resolved in one
+ * query so a queue of alerts does not issue a round trip per row.
+ */
+ java.util.Map resolveClientNames(java.util.Collection clientIds);
+
+ /**
+ * Disposition of the client's most recent flagged alert (PENDING_REVIEW / FALSE_POSITIVE /
+ * CONFIRMED_MATCH), or null when they carry none. Drives the badge on the client's own page.
+ */
+ String getLatestAlertStatus(Long clientId);
+
+ /**
+ * Confirmed matches carrying an STR obligation — the sanctions stream shown on the FIU-IND
+ * screen, deliberately separate from transaction-driven AML alerts.
+ *
+ * @param status PENDING_FILING or FILED; null for all
+ */
+ List getStrCases(String status);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java
index 7afbf9c0d..405465bdc 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java
@@ -22,9 +22,12 @@ import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
+import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
@@ -170,8 +173,13 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
final Long approverId = currentUserId();
// Maker-checker: whoever assessed the hit cannot also sign it off.
if (approverId != null && approverId.equals(log.getReviewedBy())) {
+ // Naming the reviewer turns a dead end into an instruction: the officer can see at a
+ // glance that they are the maker and that somebody else has to sign it off.
+ final String reviewer = resolveUsername(log.getReviewedBy());
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.self.approval",
- "A sanctions alert must be approved by an officer other than the one who reviewed it.");
+ "This alert was assessed by '" + reviewer + "'. A different officer with the "
+ + "APPROVE_SANCTIONS permission must approve it.",
+ reviewer);
}
log.approve(approverId);
@@ -183,6 +191,12 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
log.recordFreeze(frozen);
LOG.warn("Sanctions alert {}: froze accounts [{}] for client {}", logId, frozen, log.getClientId());
}
+ // A confirmed match is reportable under PMLA, so approval opens the STR obligation and the
+ // case becomes visible on the FIU-IND screen. Kept on this row rather than pushed into the
+ // AML alert pipeline — that pipeline is transaction-driven and this is identity-driven.
+ if (log.isConfirmedMatch()) {
+ log.raiseStr();
+ }
// A cleared false positive releases any freeze the alert previously imposed.
if (log.isApprovedFalsePositive() && log.getFrozenAccountIds() != null && !log.getFrozenAccountIds().isBlank()) {
sanctionsFreezeService.unfreezeClientAccounts(log.getClientId());
@@ -228,15 +242,27 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
@Override
@Transactional
public int rescreenExistingClients() {
- // display_name is maintained by Fineract for both persons and entities, so one query
- // covers individual and non-individual clients alike.
- final List> clients = jdbcTemplate
- .queryForList("SELECT id, display_name FROM m_client WHERE status_enum = 300 AND display_name IS NOT NULL");
+ // display_name is denormalised from the name parts and can drift out of sync with them —
+ // a direct DB edit, or any write path that misses it, leaves it stale. Screening the
+ // stale value would silently clear a client whose actual name is on the list, so the
+ // name parts are rebuilt here and display_name used only as the fallback.
+ final List> clients = jdbcTemplate.queryForList(
+ "SELECT id, firstname, middlename, lastname, fullname, display_name FROM m_client "
+ + "WHERE status_enum = 300");
int flagged = 0;
for (final Map row : clients) {
final Long clientId = ((Number) row.get("id")).longValue();
- final String name = (String) row.get("display_name");
+ final String name = resolveScreenableName(row);
+ if (name == null || name.isBlank()) {
+ continue;
+ }
+ // Re-running the sweep must not stack another identical alert onto the queue. An open
+ // alert for this client is the same case still awaiting the same decision; repeating
+ // it buries the real work under copies of itself.
+ if (hasOpenAlert(clientId)) {
+ continue;
+ }
try {
final SanctionsScreeningResult result = screenName(name, "PERIODIC_RESCREEN", clientId, null);
if (result.isMatchFound()) {
@@ -250,6 +276,106 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
return flagged;
}
+ @Override
+ @Transactional(readOnly = true)
+ public Map resolveClientNames(final Collection clientIds) {
+ final Map byId = new HashMap<>();
+ if (clientIds == null || clientIds.isEmpty()) {
+ return byId;
+ }
+ final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
+ final List> rows = jdbcTemplate.queryForList(
+ "SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
+ clientIds.toArray());
+
+ for (final Map row : rows) {
+ final Long id = ((Number) row.get("id")).longValue();
+ byId.put(id, new String[] { (String) row.get("display_name"), (String) row.get("account_no") });
+ }
+ return byId;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public String getLatestAlertStatus(final Long clientId) {
+ if (clientId == null) {
+ return null;
+ }
+ // findFlaggedByClientId already orders newest first.
+ return screeningLogRepository.findFlaggedByClientId(clientId).stream().map(SanctionsScreeningLog::getDisposition)
+ .filter(java.util.Objects::nonNull).findFirst().orElse(null);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public List getStrCases(final String status) {
+ final String normalised = status == null || status.isBlank() ? null : status.trim().toUpperCase();
+ return screeningLogRepository.findStrCases(normalised);
+ }
+
+ /** Username for a user id, falling back to the id when it cannot be resolved. */
+ private String resolveUsername(final Long userId) {
+ if (userId == null) {
+ return "unknown";
+ }
+ try {
+ final List> rows = jdbcTemplate
+ .queryForList("SELECT username FROM m_appuser WHERE id = ?", userId);
+ return rows.isEmpty() ? ("user " + userId) : (String) rows.get(0).get("username");
+ } catch (Exception e) {
+ return "user " + userId;
+ }
+ }
+
+ /** True when the client already has a flagged alert nobody has dispositioned yet. */
+ private boolean hasOpenAlert(final Long clientId) {
+ return screeningLogRepository.findFlaggedByClientId(clientId).stream().anyMatch(log -> {
+ // Unassessed — still the same case awaiting the same decision.
+ if (SanctionsScreeningLog.DISPOSITION_PENDING.equals(log.getDisposition())) {
+ return true;
+ }
+ // Assessed but not yet signed off, or a standing confirmed match. Checking only for
+ // PENDING_REVIEW left a hole: the moment an officer recorded an assessment the guard
+ // stopped seeing the alert, so every later sweep raised a fresh duplicate of a case
+ // that was already being worked — or already confirmed and frozen.
+ if (log.getApprovedBy() == null) {
+ return true;
+ }
+ return log.isConfirmedMatch();
+ });
+ }
+
+ /**
+ * The name to screen for a client row, built from the authoritative name columns.
+ *
+ *
+ * An entity carries {@code fullname}; a person carries first/middle/last. Both are the source
+ * of truth, whereas {@code display_name} is a derived copy that can lag behind them. Falling
+ * back to it only when no name part exists keeps legacy rows screenable without trusting a
+ * value that may be stale.
+ *
+ */
+ private String resolveScreenableName(final Map row) {
+ final String fullname = trimToNull((String) row.get("fullname"));
+ if (fullname != null) {
+ return fullname;
+ }
+ final String joined = Stream.of("firstname", "middlename", "lastname").map(col -> trimToNull((String) row.get(col)))
+ .filter(part -> part != null).collect(Collectors.joining(" "));
+ if (!joined.isBlank()) {
+ return joined;
+ }
+ return trimToNull((String) row.get("display_name"));
+ }
+
+ private String trimToNull(final String value) {
+ if (value == null) {
+ return null;
+ }
+ final String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
private SanctionsScreeningLog findAlertOrThrow(final Long logId) {
final SanctionsScreeningLog log = screeningLogRepository.findById(logId)
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.found",
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/ClientWelcomeKitData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/ClientWelcomeKitData.java
index 74b68009c..171334ac8 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/ClientWelcomeKitData.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/ClientWelcomeKitData.java
@@ -28,6 +28,7 @@ import lombok.Data;
public class ClientWelcomeKitData implements Serializable {
private final String clientName;
+ private final boolean clientActive;
private final boolean canDispatchNew;
private final List dispatches;
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/WelcomeKitDispatchData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/WelcomeKitDispatchData.java
index 37f381e45..d652d61bc 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/WelcomeKitDispatchData.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/data/WelcomeKitDispatchData.java
@@ -31,10 +31,13 @@ public class WelcomeKitDispatchData implements Serializable {
private final Long clientId;
private final String clientName;
private final String deliveryAddress;
+ private final Long addressId;
private final String deliveryMode;
private final String deliveryModeOtherText;
private final String trackingId;
private final String status;
private final LocalDate dispatchedOnDate;
private final LocalDate deliveredOnDate;
+ private final LocalDate returnedOnDate;
+ private final String remarks;
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatch.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatch.java
index 1f2f881c7..eb325e986 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatch.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatch.java
@@ -39,6 +39,13 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@EntityListeners(AuditingEntityListener.class)
public class WelcomeKitDispatch extends AbstractPersistableCustom {
+ /** Kit handed over to the delivery partner; awaiting the outcome. */
+ public static final String STATUS_DISPATCHED = "DISPATCHED";
+ /** Delivery partner confirmed delivery, so the address stands verified. */
+ public static final String STATUS_DELIVERED = "DELIVERED";
+ /** Kit came back undelivered, so the address could not be verified. */
+ public static final String STATUS_RETURNED = "RETURNED";
+
@ManyToOne
@JoinColumn(name = "client_id", nullable = false)
private Client client;
@@ -46,6 +53,13 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom {
@Column(name = "delivery_address", length = 500, nullable = false)
private String deliveryAddress;
+ /**
+ * The client address this kit was posted to. Kept as a plain id rather than an association because the
+ * address it points at may later be edited or removed, while the dispatch record must stay as it was.
+ */
+ @Column(name = "address_id")
+ private Long addressId;
+
@Column(name = "delivery_mode", length = 50, nullable = false)
private String deliveryMode;
@@ -64,6 +78,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom {
@Column(name = "delivered_on_date")
private LocalDate deliveredOnDate;
+ @Column(name = "returned_on_date")
+ private LocalDate returnedOnDate;
+
+ @Column(name = "remarks", length = 500)
+ private String remarks;
+
@CreatedBy
@Column(name = "created_by", updatable = false)
private Long createdBy;
@@ -84,11 +104,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom {
// Default constructor for JPA
}
- private WelcomeKitDispatch(final Client client, final String deliveryAddress, final String deliveryMode,
+ private WelcomeKitDispatch(final Client client, final String deliveryAddress, final Long addressId, final String deliveryMode,
final String deliveryModeOtherText, final String trackingId, final String status,
final LocalDate dispatchedOnDate) {
this.client = client;
this.deliveryAddress = deliveryAddress;
+ this.addressId = addressId;
this.deliveryMode = deliveryMode;
this.deliveryModeOtherText = deliveryModeOtherText;
this.trackingId = trackingId;
@@ -96,9 +117,10 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom {
this.dispatchedOnDate = dispatchedOnDate;
}
- public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final String deliveryMode,
- final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
- return new WelcomeKitDispatch(client, deliveryAddress, deliveryMode, deliveryModeOtherText, trackingId, "DISPATCHED", dispatchedOnDate);
+ public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final Long addressId,
+ final String deliveryMode, final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
+ return new WelcomeKitDispatch(client, deliveryAddress, addressId, deliveryMode, deliveryModeOtherText, trackingId,
+ STATUS_DISPATCHED, dispatchedOnDate);
}
public Client getClient() {
@@ -109,10 +131,23 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom {
return this.status;
}
- public void updateStatus(final String newStatus, final LocalDate deliveredOnDate) {
+ public String getTrackingId() {
+ return this.trackingId;
+ }
+
+ /**
+ * Records the outcome the operator read off the delivery partner's website or app. DELIVERED stamps the delivery
+ * date and clears any previous return date, RETURNED does the reverse, so the two dates can never both be set.
+ */
+ public void updateStatus(final String newStatus, final LocalDate outcomeDate, final String remarks) {
this.status = newStatus;
- if ("DELIVERED".equals(newStatus)) {
- this.deliveredOnDate = deliveredOnDate;
+ if (STATUS_DELIVERED.equals(newStatus)) {
+ this.deliveredOnDate = outcomeDate;
+ this.returnedOnDate = null;
+ } else if (STATUS_RETURNED.equals(newStatus)) {
+ this.returnedOnDate = outcomeDate;
+ this.deliveredOnDate = null;
}
+ this.remarks = remarks;
}
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatchRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatchRepository.java
index d16b4a2d7..b6c42fbe0 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatchRepository.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/domain/WelcomeKitDispatchRepository.java
@@ -25,4 +25,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface WelcomeKitDispatchRepository extends JpaRepository, JpaSpecificationExecutor {
List findByClientIdOrderByCreatedDateDesc(Long clientId);
+
+ boolean existsByTrackingIdIgnoreCase(String trackingId);
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitReadPlatformServiceImpl.java
index 65cca504d..5e46cfd8d 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitReadPlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitReadPlatformServiceImpl.java
@@ -22,10 +22,13 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.List;
+import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
+import org.apache.fineract.portfolio.client.domain.ClientStatus;
import org.apache.fineract.portfolio.welcomekit.data.ClientWelcomeKitData;
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
+import org.apache.fineract.portfolio.welcomekit.domain.WelcomeKitDispatch;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
@@ -43,20 +46,21 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
List dispatches = this.jdbcTemplate.query(sql, rm, clientId);
- String clientName = this.jdbcTemplate.queryForObject("SELECT display_name FROM m_client WHERE id = ?", String.class, clientId);
-
- boolean canDispatchNew = true;
- if (!dispatches.isEmpty()) {
- WelcomeKitDispatchData latest = dispatches.get(0);
- if ("RETURNED".equals(latest.getStatus())) {
- canDispatchNew = true;
- } else {
- canDispatchNew = false;
- }
+ final Map client = this.jdbcTemplate.queryForMap("SELECT display_name, status_enum FROM m_client WHERE id = ?",
+ clientId);
+ final String clientName = (String) client.get("display_name");
+ final boolean clientActive = ClientStatus.fromInt((Integer) client.get("status_enum")).isActive();
+
+ // A kit only goes out for an active client, and only one kit is in flight at a time: a fresh one can follow
+ // a returned kit (the address is being retried) but never a delivered or still-in-transit one.
+ boolean canDispatchNew = clientActive;
+ if (clientActive && !dispatches.isEmpty()) {
+ canDispatchNew = WelcomeKitDispatch.STATUS_RETURNED.equals(dispatches.get(0).getStatus());
}
-
+
return ClientWelcomeKitData.builder()
.clientName(clientName)
+ .clientActive(clientActive)
.canDispatchNew(canDispatchNew)
.dispatches(dispatches)
.build();
@@ -66,9 +70,10 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
public String schema() {
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
- + " w.delivery_address as deliveryAddress, w.delivery_mode as deliveryMode, "
+ + " w.delivery_address as deliveryAddress, w.address_id as addressId, w.delivery_mode as deliveryMode, "
+ " w.delivery_mode_other_text as deliveryModeOtherText, w.tracking_id as trackingId, "
- + " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate "
+ + " w.status as status, w.dispatched_on_date as dispatchedOnDate, w.delivered_on_date as deliveredOnDate, "
+ + " w.returned_on_date as returnedOnDate, w.remarks as remarks "
+ " FROM m_welcome_kit_dispatch w "
+ " JOIN m_client c ON c.id = w.client_id ";
}
@@ -79,24 +84,31 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
final Long clientId = rs.getLong("clientId");
final String clientName = rs.getString("clientName");
final String deliveryAddress = rs.getString("deliveryAddress");
+ // Nullable: kits dispatched before the address link existed carry no address id.
+ final Long addressId = JdbcSupport.getLong(rs, "addressId");
final String deliveryMode = rs.getString("deliveryMode");
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
final String trackingId = rs.getString("trackingId");
final String status = rs.getString("status");
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
+ final LocalDate returnedOnDate = JdbcSupport.getLocalDate(rs, "returnedOnDate");
+ final String remarks = rs.getString("remarks");
return WelcomeKitDispatchData.builder()
.id(id)
.clientId(clientId)
.clientName(clientName)
.deliveryAddress(deliveryAddress)
+ .addressId(addressId)
.deliveryMode(deliveryMode)
.deliveryModeOtherText(deliveryModeOtherText)
.trackingId(trackingId)
.status(status)
.dispatchedOnDate(dispatchedOnDate)
.deliveredOnDate(deliveredOnDate)
+ .returnedOnDate(returnedOnDate)
+ .remarks(remarks)
.build();
}
}
diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitWritePlatformServiceImpl.java
index bc63bb6cd..de4b63ac0 100644
--- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitWritePlatformServiceImpl.java
+++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/welcomekit/service/WelcomeKitWritePlatformServiceImpl.java
@@ -19,12 +19,12 @@
package org.apache.fineract.portfolio.welcomekit.service;
import java.time.LocalDate;
-import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
@@ -48,9 +48,16 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
+ if (!client.isActive()) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.client.must.be.active",
+ "A welcome kit can only be dispatched for an active client.");
+ }
+
final String deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
+ final String trackingId = command.stringValueOfParameterNamed("trackingId");
+ final Long addressId = command.longValueOfParameterNamed("addressId");
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
@@ -61,15 +68,29 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
"Delivery mode cannot be blank.");
}
+ if (trackingId == null || trackingId.trim().isEmpty()) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.trackingId.cannot.be.blank",
+ "Tracking ID cannot be blank.");
+ }
if (dispatchedOnDate == null) {
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
"Dispatched on date cannot be blank.");
}
+ if (dispatchedOnDate.isAfter(DateUtils.getBusinessLocalDate())) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.in.the.future",
+ "Dispatched on date cannot be in the future.");
+ }
- final String trackingId = "WK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
+ // The tracking ID is transcribed by hand from the delivery partner's receipt, so a typo could otherwise
+ // silently attach one client's consignment number to another client's kit.
+ final String sanitisedTrackingId = trackingId.trim();
+ if (this.welcomeKitDispatchRepository.existsByTrackingIdIgnoreCase(sanitisedTrackingId)) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.trackingId.duplicate",
+ "Tracking ID " + sanitisedTrackingId + " is already recorded against another welcome kit dispatch.");
+ }
- final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, deliveryMode, deliveryModeOtherText,
- trackingId, dispatchedOnDate);
+ final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, addressId, deliveryMode,
+ deliveryModeOtherText, sanitisedTrackingId, dispatchedOnDate);
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
@@ -94,12 +115,30 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
}
- LocalDate deliveredOnDate = null;
- if ("DELIVERED".equals(status)) {
- deliveredOnDate = LocalDate.now();
+ final String newStatus = status.trim().toUpperCase();
+ if (!WelcomeKitDispatch.STATUS_DELIVERED.equals(newStatus) && !WelcomeKitDispatch.STATUS_RETURNED.equals(newStatus)) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.status.invalid",
+ "Status must be either " + WelcomeKitDispatch.STATUS_DELIVERED + " or " + WelcomeKitDispatch.STATUS_RETURNED + ".");
}
- dispatch.updateStatus(status, deliveredOnDate);
+ // The outcome is recorded once. A kit that already came back is replaced by a fresh dispatch rather than
+ // being flipped to delivered after the fact.
+ if (!WelcomeKitDispatch.STATUS_DISPATCHED.equals(dispatch.getStatus())) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.outcome.already.recorded",
+ "This welcome kit is already marked as " + dispatch.getStatus() + ".");
+ }
+
+ // The outcome date is optional; the operator may only know the day they checked the courier's site.
+ LocalDate outcomeDate = command.localDateValueOfParameterNamed("outcomeDate");
+ if (outcomeDate == null) {
+ outcomeDate = DateUtils.getBusinessLocalDate();
+ }
+ if (outcomeDate.isAfter(DateUtils.getBusinessLocalDate())) {
+ throw new PlatformDataIntegrityException("error.msg.welcomekit.outcomeDate.in.the.future",
+ "Outcome date cannot be in the future.");
+ }
+
+ dispatch.updateStatus(newStatus, outcomeDate, command.stringValueOfParameterNamed("remarks"));
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
index e60f5d5fc..d2407861e 100644
--- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
@@ -533,4 +533,8 @@
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_add_sanctions_str_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_add_sanctions_str_fields.xml
new file mode 100644
index 000000000..31d2594ba
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0466_add_sanctions_str_fields.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_add_welcome_kit_return_tracking.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_add_welcome_kit_return_tracking.xml
new file mode 100644
index 000000000..b73f0704f
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0467_add_welcome_kit_return_tracking.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml
new file mode 100644
index 000000000..d440dcb0d
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0468_add_address_id_to_welcome_kit_dispatch.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml
new file mode 100644
index 000000000..b373481da
--- /dev/null
+++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0469_create_database_export_request.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
+ SELECT 'authorisation', 'CREATE_DATABASEEXPORT', 'DATABASEEXPORT', 'CREATE', false
+ WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_DATABASEEXPORT');
+
+ INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
+ SELECT 'authorisation', 'READ_DATABASEEXPORT', 'DATABASEEXPORT', 'READ', false
+ WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_DATABASEEXPORT');
+
+ INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
+ SELECT 'authorisation', 'APPROVE_DATABASEEXPORT', 'DATABASEEXPORT', 'APPROVE', false
+ WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'APPROVE_DATABASEEXPORT');
+
+ INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
+ SELECT 'authorisation', 'DOWNLOAD_DATABASEEXPORT', 'DATABASEEXPORT', 'DOWNLOAD', false
+ WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'DOWNLOAD_DATABASEEXPORT');
+
+
+
+