Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into dev
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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract 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

# Conflicts:
#	fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java
This commit is contained in:
2026-08-19 10:50:25 +05:30
31 changed files with 1505 additions and 56 deletions
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
private Boolean alternateDipVerified;
private Long alternateDipLookupLogId;
private String alternateDipTimestamp;
/**
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
* reassigned would otherwise keep showing "Verified" forever.
*/
private String dipLiveStatus;
private String alternateDipLiveStatus;
/**
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
* would keep reading "Clear" after the client was added to a later list snapshot.
*/
private String sanctionsAlertStatus;
private Boolean nameVerified;
private Long screeningLogId;
private Boolean isStaff;
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
data.alternateDipVerified = clientData.alternateDipVerified;
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
// here is silently dropped from the payload the client view loads.
data.dipLiveStatus = clientData.dipLiveStatus;
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
data.nameVerified = clientData.nameVerified;
data.screeningLogId = clientData.screeningLogId;
return data;
@@ -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; }
}
@@ -59,4 +59,12 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
+ "ORDER BY l.screenedOn DESC")
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
/**
* Confirmed matches carrying an STR obligation — the sanctions stream on the FIU-IND screen.
* Pass null for every STR regardless of whether it has been filed.
*/
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.strStatus IS NOT NULL "
+ "AND (:status IS NULL OR l.strStatus = :status) ORDER BY l.strDueDate ASC, l.strRaisedOn DESC")
List<SanctionsScreeningLog> findStrCases(@Param("status") String status);
}
@@ -0,0 +1,114 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.api;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
import org.springframework.stereotype.Component;
@Path("/v1/databaseexport")
@Component
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
@RequiredArgsConstructor
public class DatabaseExportApiResource {
private final DatabaseExportService service;
private final FromJsonHelper fromJsonHelper;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List export requests, optionally filtered by status")
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
return this.service.retrieveAll(status);
}
@GET
@Path("{requestId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Retrieve a single export request")
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
return this.service.retrieveOne(requestId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Raise a request for a full database export")
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
final Long id = this.service.requestExport(reason);
return Map.of("resourceId", id);
}
@POST
@Path("{requestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve an export request (admin only, never the requester)")
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
this.service.approve(requestId);
return Map.of("resourceId", requestId, "status", "APPROVED");
}
@POST
@Path("{requestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject an export request with a reason")
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
? json.get("rejectionReason").getAsString()
: null;
this.service.reject(requestId, reason);
return Map.of("resourceId", requestId, "status", "REJECTED");
}
@GET
@Path("{requestId}/download")
@Produces({ "application/zip" })
@Operation(summary = "Download the approved export as a ZIP of CSV files")
public Response download(@PathParam("requestId") final Long requestId) {
final byte[] archive = this.service.generateExport(requestId);
return Response.ok(archive)
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
.header("Content-Length", archive.length)
.build();
}
}
@@ -0,0 +1,152 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
/**
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
*/
@Entity
@Table(name = "m_database_export_request")
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
public static final String STATUS_APPROVED = "APPROVED";
public static final String STATUS_REJECTED = "REJECTED";
/** Approved and the dump has actually been downloaded at least once. */
public static final String STATUS_GENERATED = "GENERATED";
@Column(name = "status", length = 30, nullable = false)
private String status;
@Column(name = "reason", nullable = false)
private String reason;
@Column(name = "requested_by", nullable = false)
private Long requestedBy;
@Column(name = "requested_by_username", length = 100)
private String requestedByUsername;
@Column(name = "requested_on", nullable = false)
private LocalDateTime requestedOn;
@Column(name = "approved_by")
private Long approvedBy;
@Column(name = "approved_by_username", length = 100)
private String approvedByUsername;
@Column(name = "approved_on")
private LocalDateTime approvedOn;
@Column(name = "rejection_reason")
private String rejectionReason;
@Column(name = "generated_on")
private LocalDateTime generatedOn;
@Column(name = "generated_by")
private Long generatedBy;
@Column(name = "file_name", length = 255)
private String fileName;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "row_count")
private Long rowCount;
protected DatabaseExportRequest() {
// Default constructor for JPA
}
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
final LocalDateTime requestedOn) {
this.status = STATUS_PENDING_APPROVAL;
this.reason = reason;
this.requestedBy = requestedBy;
this.requestedByUsername = requestedByUsername;
this.requestedOn = requestedOn;
}
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
final LocalDateTime requestedOn) {
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
}
public String getStatus() {
return this.status;
}
public Long getRequestedBy() {
return this.requestedBy;
}
public boolean isPendingApproval() {
return STATUS_PENDING_APPROVAL.equals(this.status);
}
/**
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
* second copy has to be asked for and approved again — so every copy of the database that exists outside
* the system can be traced back to an approval that was granted for it specifically.
*/
public boolean isDownloadable() {
return STATUS_APPROVED.equals(this.status);
}
public boolean isAlreadyDownloaded() {
return STATUS_GENERATED.equals(this.status);
}
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
this.status = STATUS_APPROVED;
this.approvedBy = approvedBy;
this.approvedByUsername = approvedByUsername;
this.approvedOn = approvedOn;
}
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
final String rejectionReason) {
this.status = STATUS_REJECTED;
this.approvedBy = approvedBy;
this.approvedByUsername = approvedByUsername;
this.approvedOn = approvedOn;
this.rejectionReason = rejectionReason;
}
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
final Long fileSizeBytes, final Long rowCount) {
this.status = STATUS_GENERATED;
this.generatedBy = generatedBy;
this.generatedOn = generatedOn;
this.fileName = fileName;
this.fileSizeBytes = fileSizeBytes;
this.rowCount = rowCount;
}
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.domain;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
long countByRequestedByAndStatus(Long requestedBy, String status);
/**
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
*/
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
}
@@ -0,0 +1,42 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.service;
import java.util.List;
import java.util.Map;
/** Requesting, approving and generating a full export of the tenant database. */
public interface DatabaseExportService {
Long requestExport(String reason);
List<Map<String, Object>> retrieveAll(String status);
Map<String, Object> retrieveOne(Long requestId);
void approve(Long requestId);
void reject(Long requestId, String rejectionReason);
/**
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
* admin has already approved.
*/
byte[] generateExport(Long requestId);
}
@@ -0,0 +1,318 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.databaseexport.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class DatabaseExportServiceImpl implements DatabaseExportService {
private static final String PERMISSION = "DATABASEEXPORT";
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
private static final int MAX_ROWS_PER_TABLE = 200_000;
private final PlatformSecurityContext context;
private final DatabaseExportRequestRepository repository;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
public Long requestExport(final String reason) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("CREATE_" + PERMISSION);
if (reason == null || reason.trim().length() < 10) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
"A reason of at least 10 characters is required for a database export request.");
}
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
// approver to rubber-stamp in bulk.
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
"You already have a database export request awaiting approval.");
}
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
LocalDateTime.now());
this.repository.saveAndFlush(request);
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
return request.getId();
}
@Override
public List<Map<String, Object>> retrieveAll(final String status) {
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
// client `requestedbyusername` and leave every camelCase field on the screen empty.
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
+ "FROM m_database_export_request ");
if (status != null && !status.trim().isEmpty()) {
sql.append("WHERE status = ? ORDER BY id DESC");
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
}
sql.append("ORDER BY id DESC");
return this.jdbcTemplate.queryForList(sql.toString());
}
@Override
public Map<String, Object> retrieveOne(final Long requestId) {
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
+ "FROM m_database_export_request WHERE id = ?",
requestId);
if (rows.isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
}
return rows.get(0);
}
@Transactional
@Override
public void approve(final Long requestId) {
final AppUser approver = this.context.authenticatedUser();
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
final DatabaseExportRequest request = findOneOrThrow(requestId);
validatePending(request);
validateNotSelfApproval(request, approver);
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
this.repository.saveAndFlush(request);
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
}
@Transactional
@Override
public void reject(final Long requestId, final String rejectionReason) {
final AppUser approver = this.context.authenticatedUser();
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
final DatabaseExportRequest request = findOneOrThrow(requestId);
validatePending(request);
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
"A reason is required when rejecting a database export request.");
}
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
this.repository.saveAndFlush(request);
}
@Transactional
@Override
public byte[] generateExport(final Long requestId) {
final AppUser user = this.context.authenticatedUser();
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
// calls must not both find it unspent.
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
"Export request not found."));
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
// being turned away with a message about approval that no longer describes their situation.
if (request.isAlreadyDownloaded()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
"This export has already been downloaded. Raise a new request to take another copy.");
}
if (!request.isDownloadable()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
"This export request has not been approved. Current status: " + request.getStatus() + ".");
}
validateIsRequester(request, user);
final List<String> tables = listTenantTables();
long totalRows = 0;
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
for (final String table : tables) {
try {
totalRows += writeTableAsCsv(zip, table);
} catch (final Exception e) {
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
log.error("Database export: skipping table {} — {}", table, e.getMessage());
}
}
writeManifest(zip, request, tables, totalRows, user);
} catch (final IOException e) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
"Failed to generate the database export: " + e.getMessage());
}
final byte[] archive = buffer.toByteArray();
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
+ ".zip";
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
this.repository.saveAndFlush(request);
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
user.getId(), tables.size(), totalRows, archive.length);
return archive;
}
private List<String> listTenantTables() {
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
}
/** Writes one table as a CSV entry and returns how many rows it contained. */
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
// The table name comes from information_schema, never from user input, so it cannot carry injection.
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
if (rows.isEmpty()) {
// Still emit the header so an empty table is visibly empty rather than missing.
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
zip.write('\n');
zip.closeEntry();
return 0;
}
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
zip.write('\n');
for (final Map<String, Object> row : rows) {
final StringBuilder line = new StringBuilder();
for (int i = 0; i < columns.size(); i++) {
if (i > 0) {
line.append(',');
}
line.append(csvEscape(row.get(columns.get(i))));
}
line.append('\n');
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
}
zip.closeEntry();
return rows.size();
}
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
final long totalRows, final AppUser downloadedBy) throws IOException {
final Map<String, Object> details = retrieveOne(request.getId());
final StringBuilder manifest = new StringBuilder();
manifest.append("CBSNexora — Full Database Export\n");
manifest.append("=================================\n\n");
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
manifest.append("Handle under your institution's data protection policy.\n\n");
manifest.append("Request ID : ").append(request.getId()).append('\n');
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
manifest.append("Reason : ").append(details.get("reason")).append('\n');
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
manifest.append("Tables exported : ").append(tables.size()).append('\n');
manifest.append("Rows exported : ").append(totalRows).append('\n');
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
private String csvEscape(final Object value) {
if (value == null) {
return "";
}
final String text = String.valueOf(value);
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
return '"' + text.replace("\"", "\"\"") + '"';
}
return text;
}
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
"error.msg.databaseexport.not.found", "Export request not found."));
}
private void validatePending(final DatabaseExportRequest request) {
if (!request.isPendingApproval()) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
"This request has already been actioned. Current status: " + request.getStatus() + ".");
}
}
/**
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
* approver download would collapse the two-person control back into one.
*/
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
if (!user.getId().equals(request.getRequestedBy())) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
"Only the user who raised this export request can download the archive.");
}
}
/** The entire control is that two different people are involved, so this is the check that enforces it. */
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
if (approver.getId().equals(request.getRequestedBy())) {
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
"A database export cannot be approved by the same user who requested it.");
}
}
}
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
private final SecureRandom secureRandom = new SecureRandom();
@Autowired
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
final EmailTemplateService emailTemplateService) {
final EmailTemplateService emailTemplateService,
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
this.restTemplate = new RestTemplate();
this.otpLogRepository = otpLogRepository;
this.emailSenderService = emailSenderService;
this.emailTemplateService = emailTemplateService;
this.mobileLookupService = mobileLookupService;
}
// ── Generate ─────────────────────────────────────────────────────────────
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
}
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
// credential, so this is refused at the point of dispatch rather than merely warned about
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
return OtpGenerateResponse.builder().success(false)
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
+ "Contact the customer through an alternate channel and obtain a new number.")
.build();
}
final OtpLog log = new OtpLog();
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
log.setProvider(PROVIDER_LINCPAY);
@@ -34,7 +34,7 @@ import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Mapper(config = MapstructMapperConfig.class)
public interface ClientMapper {
@@ -107,6 +107,11 @@ public interface ClientMapper {
@Mapping(target = "legalFormId", ignore = true)
@Mapping(target = "clientCollateralManagements", ignore = true)
@Mapping(target = "groups", ignore = true)
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
// ClientReadPlatformServiceImpl#retrieveOne.
@Mapping(target = "dipLiveStatus", ignore = true)
@Mapping(target = "alternateDipLiveStatus", ignore = true)
@Mapping(target = "sanctionsAlertStatus", ignore = true)
ClientData map(Client source);
@Named("clientTypeCode")
@@ -84,6 +84,8 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
private final ColumnValidator columnValidator;
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
private final ClientRepositoryWrapper clientRepositoryWrapper;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
private final ClientMapper clientMapper;
@Override
@@ -235,6 +237,17 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
// those fields comes back blank however carefully they were filled in at creation.
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
// dipVerified is the flag captured when the client was onboarded and never changes
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
// reported for fraud or reassigned since then shows as flagged on the client's page,
// instead of the page continuing to display the old "Verified".
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
// Get client collaterals
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
.getCollateralsPerClient(clientId);
@@ -908,12 +908,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
oldMobileNoForNotification = oldValue;
newMobileNoForNotification = newValue;
mobileNoChanged = true;
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
// alert here. Left to the officer as a second manual step it would be forgotten,
// and the queue would fill with cases that were in fact already dealt with.
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
}
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
@@ -1761,6 +1767,32 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
* saved because the browser said it was fine.
* </p>
*/
/**
* Closes any open DIP/MNRL alert on a number that has just been replaced.
*
* <p>
* For an existing customer a flagged number is resolved by obtaining a new one — at which
* point the old alert describes a number the bank no longer uses. Closing it here keeps the
* decision queue a list of work actually outstanding rather than a growing pile of cases that
* were already handled. Failure is logged, never propagated: the client update itself must
* not fail because a bookkeeping row could not be stamped.
* </p>
*/
private void closeDipAlertForReplacedNumber(final String oldNumber, final String newNumber, final Long clientId) {
if (StringUtils.isBlank(oldNumber) || StringUtils.equals(oldNumber, newNumber)) {
return;
}
try {
final String reason = StringUtils.isBlank(newNumber)
? "Number removed from the client record on resolution of the DIP/MNRL alert."
: "Number replaced with " + newNumber + " on resolution of the DIP/MNRL alert.";
this.mobileLookupService.closeOpenAlerts(oldNumber.trim(), clientId, reason);
} catch (Exception e) {
log.error("Could not close DIP alert for replaced number {} on client {}: {}", oldNumber, clientId,
e.getMessage());
}
}
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
if (StringUtils.isBlank(mobileNo)) {
return;
@@ -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<MobileLookupLogData> 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<MobileLookupLogData> withClientNames(final List<MobileLookupLog> logs) {
final Set<Long> clientIds = logs.stream()
.map(MobileLookupLog::getClientId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
final Map<Long, String[]> 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<MobileLookupLogData> 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<String, Object> 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);
}
}
@@ -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(),
@@ -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.
*
* <p>
* 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.
* </p>
*
* @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<Long, String[]> resolveClientNames(java.util.Collection<Long> 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.
*
* <p>
* 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.
* </p>
*
* @return how many alerts were closed
*/
int closeOpenAlerts(String mobileNumber, Long clientId, String reason);
}
@@ -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;
@@ -62,14 +67,16 @@ public class MobileLookupServiceImpl implements MobileLookupService {
private final boolean enabled;
private final RestTemplate restTemplate;
private final MobileLookupLogRepository logRepository;
private final JdbcTemplate jdbcTemplate;
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
MobileLookupLogRepository logRepository) {
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
this.baseUrl = baseUrl;
this.enabled = enabled;
this.restTemplate = new RestTemplate();
this.logRepository = logRepository;
this.jdbcTemplate = jdbcTemplate;
}
@Override
@@ -229,6 +236,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<Map<String, Object>> 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<String, Object> 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<Long, String[]> resolveClientNames(final Collection<Long> clientIds) {
final Map<Long, String[]> byId = new HashMap<>();
if (clientIds == null || clientIds.isEmpty()) {
return byId;
}
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
clientIds.toArray());
for (final Map<String, Object> 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<MobileLookupLog> 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) {
@@ -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<SanctionsScreeningLogData> 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<SanctionsScreeningLogData> withClientNames(final List<SanctionsScreeningLog> logs) {
final Set<Long> clientIds = logs.stream().map(SanctionsScreeningLog::getClientId).filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
final Map<Long, String[]> 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<SanctionsScreeningLogData> 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 })
@@ -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; }
}
@@ -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<Long, String[]> resolveClientNames(java.util.Collection<Long> 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<SanctionsScreeningLog> getStrCases(String status);
}
@@ -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<Map<String, Object>> 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<Map<String, Object>> clients = jdbcTemplate.queryForList(
"SELECT id, firstname, middlename, lastname, fullname, display_name FROM m_client "
+ "WHERE status_enum = 300");
int flagged = 0;
for (final Map<String, Object> 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<Long, String[]> resolveClientNames(final Collection<Long> clientIds) {
final Map<Long, String[]> byId = new HashMap<>();
if (clientIds == null || clientIds.isEmpty()) {
return byId;
}
final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(","));
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, display_name, account_no FROM m_client WHERE id IN (" + placeholders + ")",
clientIds.toArray());
for (final Map<String, Object> 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<SanctionsScreeningLog> 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<Map<String, Object>> 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.
*
* <p>
* 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.
* </p>
*/
private String resolveScreenableName(final Map<String, Object> 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",
@@ -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<WelcomeKitDispatchData> dispatches;
}
@@ -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;
}
@@ -39,6 +39,13 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@EntityListeners(AuditingEntityListener.class)
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
/** 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<Long> {
@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<Long> {
@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<Long> {
// 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<Long> {
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<Long> {
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;
}
}
@@ -25,4 +25,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
boolean existsByTrackingIdIgnoreCase(String trackingId);
}
@@ -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<WelcomeKitDispatchData> 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<String, Object> 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();
}
}
@@ -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);
@@ -533,4 +533,8 @@
<include file="parts/0464_add_sanctions_alert_disposition.xml" relativeToChangelogFile="true"/>
<include file="parts/0465_add_dip_override_and_client_status.xml" relativeToChangelogFile="true"/>
<include file="parts/0465_create_loan_origination_pipeline.xml" relativeToChangelogFile="true"/>
<include file="parts/0466_add_sanctions_str_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0467_add_welcome_kit_return_tracking.xml" relativeToChangelogFile="true"/>
<include file="parts/0468_add_address_id_to_welcome_kit_dispatch.xml" relativeToChangelogFile="true"/>
<include file="parts/0469_create_database_export_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Sanctions STR tracking.
A confirmed UNSC match is reportable to FIU-IND, so on approval it has to become a visible STR
item rather than a field buried in the sanctions log. It is kept on the screening log itself
and NOT written into m_aml_alert: an AML alert is built around a monetary transaction, whereas
a sanctions hit is triggered by who the customer is. Forcing it into that pipeline would mean
inventing a transaction that never happened and mixing two different kinds of suspicion in one
queue. The FIU screen surfaces them as a separate sanctions-STR stream.
-->
<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.1.xsd">
<changeSet id="0466_add_sanctions_str_fields" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_sanctions_screening_log" columnName="str_status"/></not>
</preConditions>
<addColumn tableName="m_sanctions_screening_log">
<!-- NULL until approval; then PENDING_FILING -> FILED. -->
<column name="str_status" type="VARCHAR(30)"/>
<!-- Seven days from confirmation, per PMLA's seven-working-day STR deadline. -->
<column name="str_due_date" type="DATE"/>
<column name="str_raised_on" type="DATETIME"/>
</addColumn>
</changeSet>
<changeSet id="0466_sanctions_str_status_index" author="Antigravity">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_sanctions_log_str_status"/></not>
</preConditions>
<createIndex tableName="m_sanctions_screening_log" indexName="idx_sanctions_log_str_status">
<column name="str_status"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -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.1.xsd">
<!-- The welcome kit doubles as a physical address check: the operator looks the tracking ID up on the
delivery partner's own website or app and records the outcome here by hand. A returned kit now needs
its own date alongside the existing delivered date, plus free-text remarks for what the partner said. -->
<changeSet id="0467_add_welcome_kit_return_tracking" author="CBS" runOnChange="false">
<addColumn tableName="m_welcome_kit_dispatch">
<column name="returned_on_date" type="DATE"/>
<column name="remarks" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,20 @@
<?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.1.xsd">
<!-- Records which of the client's addresses the kit was posted to, so the Address tab can badge that one
address as verified or not verified once the outcome is known. Deliberately a plain column rather than
a foreign key: the dispatch is a historical record and must survive the address being edited or removed. -->
<changeSet id="0468_add_address_id_to_welcome_kit_dispatch" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_welcome_kit_dispatch" columnName="address_id"/>
</not>
</preConditions>
<addColumn tableName="m_welcome_kit_dispatch">
<column name="address_id" type="BIGINT"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,72 @@
<?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.1.xsd">
<!-- A full database export contains every client's PII, KYC and account data, so it is never taken on one
person's authority. The requester states a reason, an admin approves or rejects, and only an approved
request can produce a dump. Every step is stamped so the trail survives the file itself. -->
<changeSet id="0469_create_database_export_request" author="CBS" runOnChange="false">
<createTable tableName="m_database_export_request">
<column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- PENDING_APPROVAL -> APPROVED -> GENERATED, or REJECTED. -->
<column name="status" type="VARCHAR(30)" defaultValue="PENDING_APPROVAL">
<constraints nullable="false"/>
</column>
<column name="reason" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="requested_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="requested_by_username" type="VARCHAR(100)"/>
<column name="requested_on" type="TIMESTAMP WITHOUT TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="approved_by" type="BIGINT"/>
<column name="approved_by_username" type="VARCHAR(100)"/>
<column name="approved_on" type="TIMESTAMP WITHOUT TIME ZONE"/>
<column name="rejection_reason" type="TEXT"/>
<!-- Stamped when the approved request is actually downloaded, so an approval that was never used
is distinguishable from data that genuinely left the building. -->
<column name="generated_on" type="TIMESTAMP WITHOUT TIME ZONE"/>
<column name="generated_by" type="BIGINT"/>
<column name="file_name" type="VARCHAR(255)"/>
<column name="file_size_bytes" type="BIGINT"/>
<column name="row_count" type="BIGINT"/>
<column name="created_by" type="BIGINT"/>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE"/>
</createTable>
<createIndex tableName="m_database_export_request" indexName="idx_db_export_request_status">
<column name="status"/>
</createIndex>
</changeSet>
<!-- Requesting an export and approving one are deliberately separate permissions: the whole control
depends on no single user holding both. -->
<changeSet id="0469_database_export_permissions" author="CBS" runOnChange="false">
<sql>
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');
</sql>
</changeSet>
</databaseChangeLog>