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
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:
@@ -105,6 +105,19 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
private Boolean alternateDipVerified;
|
private Boolean alternateDipVerified;
|
||||||
private Long alternateDipLookupLogId;
|
private Long alternateDipLookupLogId;
|
||||||
private String alternateDipTimestamp;
|
private String alternateDipTimestamp;
|
||||||
|
/**
|
||||||
|
* Live DIP/MNRL status of the number, resolved from the latest lookup rather than the flag
|
||||||
|
* captured at onboarding. A customer cleared at onboarding whose number is later reported or
|
||||||
|
* reassigned would otherwise keep showing "Verified" forever.
|
||||||
|
*/
|
||||||
|
private String dipLiveStatus;
|
||||||
|
private String alternateDipLiveStatus;
|
||||||
|
/**
|
||||||
|
* Disposition of the client's most recent flagged sanctions alert, or null when they carry
|
||||||
|
* none. Like the DIP status this is resolved live: nameVerified is captured at onboarding and
|
||||||
|
* would keep reading "Clear" after the client was added to a later list snapshot.
|
||||||
|
*/
|
||||||
|
private String sanctionsAlertStatus;
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
private Long screeningLogId;
|
private Long screeningLogId;
|
||||||
private Boolean isStaff;
|
private Boolean isStaff;
|
||||||
@@ -436,6 +449,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -498,6 +516,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
@@ -550,6 +573,11 @@ public final class ClientData implements Comparable<ClientData>, Serializable {
|
|||||||
data.alternateDipVerified = clientData.alternateDipVerified;
|
data.alternateDipVerified = clientData.alternateDipVerified;
|
||||||
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
data.alternateDipLookupLogId = clientData.alternateDipLookupLogId;
|
||||||
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
data.alternateDipTimestamp = clientData.alternateDipTimestamp;
|
||||||
|
// Carried across too: these helpers rebuild ClientData field by field, so a field missing
|
||||||
|
// here is silently dropped from the payload the client view loads.
|
||||||
|
data.dipLiveStatus = clientData.dipLiveStatus;
|
||||||
|
data.alternateDipLiveStatus = clientData.alternateDipLiveStatus;
|
||||||
|
data.sanctionsAlertStatus = clientData.sanctionsAlertStatus;
|
||||||
data.nameVerified = clientData.nameVerified;
|
data.nameVerified = clientData.nameVerified;
|
||||||
data.screeningLogId = clientData.screeningLogId;
|
data.screeningLogId = clientData.screeningLogId;
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
+41
@@ -96,6 +96,24 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
@Column(name = "frozen_on")
|
@Column(name = "frozen_on")
|
||||||
private OffsetDateTime frozenOn;
|
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_FLAGGED = "FLAGGED";
|
||||||
public static final String RESULT_CLEAR = "CLEAR";
|
public static final String RESULT_CLEAR = "CLEAR";
|
||||||
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
|
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) {
|
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
|
||||||
if (strReference != null) {
|
if (strReference != null) {
|
||||||
this.fiuStrReference = strReference;
|
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) {
|
if (nodalOfficerInformedOn != null) {
|
||||||
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||||
@@ -157,6 +180,21 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
|
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 isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
|
||||||
|
|
||||||
public boolean isApprovedFalsePositive() {
|
public boolean isApprovedFalsePositive() {
|
||||||
@@ -187,4 +225,7 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||||
public String getFrozenAccountIds() { return frozenAccountIds; }
|
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||||
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||||
|
public String getStrStatus() { return strStatus; }
|
||||||
|
public java.time.LocalDate getStrDueDate() { return strDueDate; }
|
||||||
|
public OffsetDateTime getStrRaisedOn() { return strRaisedOn; }
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -59,4 +59,12 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
|
|||||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
|
||||||
+ "ORDER BY l.screenedOn DESC")
|
+ "ORDER BY l.screenedOn DESC")
|
||||||
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.databaseexport.api;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
|
import org.apache.fineract.infrastructure.databaseexport.service.DatabaseExportService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Path("/v1/databaseexport")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "Database Export", description = "Approval-gated full export of the tenant database.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DatabaseExportApiResource {
|
||||||
|
|
||||||
|
private final DatabaseExportService service;
|
||||||
|
private final FromJsonHelper fromJsonHelper;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List export requests, optionally filtered by status")
|
||||||
|
public List<Map<String, Object>> retrieveAll(@QueryParam("status") @Parameter(description = "status") final String status) {
|
||||||
|
return this.service.retrieveAll(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{requestId}")
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Retrieve a single export request")
|
||||||
|
public Map<String, Object> retrieveOne(@PathParam("requestId") final Long requestId) {
|
||||||
|
return this.service.retrieveOne(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Raise a request for a full database export")
|
||||||
|
public Map<String, Object> requestExport(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
final String reason = this.fromJsonHelper.extractStringNamed("reason", element);
|
||||||
|
final Long id = this.service.requestExport(reason);
|
||||||
|
return Map.of("resourceId", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{requestId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Approve an export request (admin only, never the requester)")
|
||||||
|
public Map<String, Object> approve(@PathParam("requestId") final Long requestId) {
|
||||||
|
this.service.approve(requestId);
|
||||||
|
return Map.of("resourceId", requestId, "status", "APPROVED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{requestId}/reject")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Reject an export request with a reason")
|
||||||
|
public Map<String, Object> reject(@PathParam("requestId") final Long requestId,
|
||||||
|
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||||
|
final JsonElement element = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||||
|
final JsonObject json = element == null ? new JsonObject() : element.getAsJsonObject();
|
||||||
|
final String reason = json.has("rejectionReason") && !json.get("rejectionReason").isJsonNull()
|
||||||
|
? json.get("rejectionReason").getAsString()
|
||||||
|
: null;
|
||||||
|
this.service.reject(requestId, reason);
|
||||||
|
return Map.of("resourceId", requestId, "status", "REJECTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("{requestId}/download")
|
||||||
|
@Produces({ "application/zip" })
|
||||||
|
@Operation(summary = "Download the approved export as a ZIP of CSV files")
|
||||||
|
public Response download(@PathParam("requestId") final Long requestId) {
|
||||||
|
final byte[] archive = this.service.generateExport(requestId);
|
||||||
|
return Response.ok(archive)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"cbsnexora-database-export.zip\"")
|
||||||
|
.header("Content-Length", archive.length)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.databaseexport.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request to take a full export of the tenant database. The export carries every client's personal, KYC and
|
||||||
|
* account data, so it is deliberately a two-person action: whoever asks for it cannot also approve it.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "m_database_export_request")
|
||||||
|
public class DatabaseExportRequest extends AbstractPersistableCustom<Long> {
|
||||||
|
|
||||||
|
public static final String STATUS_PENDING_APPROVAL = "PENDING_APPROVAL";
|
||||||
|
public static final String STATUS_APPROVED = "APPROVED";
|
||||||
|
public static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
/** Approved and the dump has actually been downloaded at least once. */
|
||||||
|
public static final String STATUS_GENERATED = "GENERATED";
|
||||||
|
|
||||||
|
@Column(name = "status", length = 30, nullable = false)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "reason", nullable = false)
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
@Column(name = "requested_by", nullable = false)
|
||||||
|
private Long requestedBy;
|
||||||
|
|
||||||
|
@Column(name = "requested_by_username", length = 100)
|
||||||
|
private String requestedByUsername;
|
||||||
|
|
||||||
|
@Column(name = "requested_on", nullable = false)
|
||||||
|
private LocalDateTime requestedOn;
|
||||||
|
|
||||||
|
@Column(name = "approved_by")
|
||||||
|
private Long approvedBy;
|
||||||
|
|
||||||
|
@Column(name = "approved_by_username", length = 100)
|
||||||
|
private String approvedByUsername;
|
||||||
|
|
||||||
|
@Column(name = "approved_on")
|
||||||
|
private LocalDateTime approvedOn;
|
||||||
|
|
||||||
|
@Column(name = "rejection_reason")
|
||||||
|
private String rejectionReason;
|
||||||
|
|
||||||
|
@Column(name = "generated_on")
|
||||||
|
private LocalDateTime generatedOn;
|
||||||
|
|
||||||
|
@Column(name = "generated_by")
|
||||||
|
private Long generatedBy;
|
||||||
|
|
||||||
|
@Column(name = "file_name", length = 255)
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
@Column(name = "file_size_bytes")
|
||||||
|
private Long fileSizeBytes;
|
||||||
|
|
||||||
|
@Column(name = "row_count")
|
||||||
|
private Long rowCount;
|
||||||
|
|
||||||
|
protected DatabaseExportRequest() {
|
||||||
|
// Default constructor for JPA
|
||||||
|
}
|
||||||
|
|
||||||
|
private DatabaseExportRequest(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||||
|
final LocalDateTime requestedOn) {
|
||||||
|
this.status = STATUS_PENDING_APPROVAL;
|
||||||
|
this.reason = reason;
|
||||||
|
this.requestedBy = requestedBy;
|
||||||
|
this.requestedByUsername = requestedByUsername;
|
||||||
|
this.requestedOn = requestedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DatabaseExportRequest createNew(final String reason, final Long requestedBy, final String requestedByUsername,
|
||||||
|
final LocalDateTime requestedOn) {
|
||||||
|
return new DatabaseExportRequest(reason, requestedBy, requestedByUsername, requestedOn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getRequestedBy() {
|
||||||
|
return this.requestedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPendingApproval() {
|
||||||
|
return STATUS_PENDING_APPROVAL.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An approval buys exactly one download. Once the archive has been handed over the request is spent, and a
|
||||||
|
* second copy has to be asked for and approved again — so every copy of the database that exists outside
|
||||||
|
* the system can be traced back to an approval that was granted for it specifically.
|
||||||
|
*/
|
||||||
|
public boolean isDownloadable() {
|
||||||
|
return STATUS_APPROVED.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAlreadyDownloaded() {
|
||||||
|
return STATUS_GENERATED.equals(this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approve(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn) {
|
||||||
|
this.status = STATUS_APPROVED;
|
||||||
|
this.approvedBy = approvedBy;
|
||||||
|
this.approvedByUsername = approvedByUsername;
|
||||||
|
this.approvedOn = approvedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reject(final Long approvedBy, final String approvedByUsername, final LocalDateTime approvedOn,
|
||||||
|
final String rejectionReason) {
|
||||||
|
this.status = STATUS_REJECTED;
|
||||||
|
this.approvedBy = approvedBy;
|
||||||
|
this.approvedByUsername = approvedByUsername;
|
||||||
|
this.approvedOn = approvedOn;
|
||||||
|
this.rejectionReason = rejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records that the dump actually left the system, which is the moment that matters for an audit. */
|
||||||
|
public void markGenerated(final Long generatedBy, final LocalDateTime generatedOn, final String fileName,
|
||||||
|
final Long fileSizeBytes, final Long rowCount) {
|
||||||
|
this.status = STATUS_GENERATED;
|
||||||
|
this.generatedBy = generatedBy;
|
||||||
|
this.generatedOn = generatedOn;
|
||||||
|
this.fileName = fileName;
|
||||||
|
this.fileSizeBytes = fileSizeBytes;
|
||||||
|
this.rowCount = rowCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.databaseexport.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface DatabaseExportRequestRepository extends JpaRepository<DatabaseExportRequest, Long> {
|
||||||
|
|
||||||
|
List<DatabaseExportRequest> findByStatusOrderByIdDesc(String status);
|
||||||
|
|
||||||
|
long countByRequestedByAndStatus(Long requestedBy, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An approval is worth exactly one download, so the row is locked while that single use is spent. Without
|
||||||
|
* the lock two concurrent calls could both read APPROVED and both walk away with a copy of the database.
|
||||||
|
*/
|
||||||
|
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select request from DatabaseExportRequest request where request.id = :requestId")
|
||||||
|
Optional<DatabaseExportRequest> findByIdWithLock(@Param("requestId") Long requestId);
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.databaseexport.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Requesting, approving and generating a full export of the tenant database. */
|
||||||
|
public interface DatabaseExportService {
|
||||||
|
|
||||||
|
Long requestExport(String reason);
|
||||||
|
|
||||||
|
List<Map<String, Object>> retrieveAll(String status);
|
||||||
|
|
||||||
|
Map<String, Object> retrieveOne(Long requestId);
|
||||||
|
|
||||||
|
void approve(Long requestId);
|
||||||
|
|
||||||
|
void reject(Long requestId, String rejectionReason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams the approved export as a ZIP of CSV files, one per table. Only callable against a request that an
|
||||||
|
* admin has already approved.
|
||||||
|
*/
|
||||||
|
byte[] generateExport(Long requestId);
|
||||||
|
}
|
||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.infrastructure.databaseexport.service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
|
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequest;
|
||||||
|
import org.apache.fineract.infrastructure.databaseexport.domain.DatabaseExportRequestRepository;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DatabaseExportServiceImpl implements DatabaseExportService {
|
||||||
|
|
||||||
|
private static final String PERMISSION = "DATABASEEXPORT";
|
||||||
|
/** Guards against a runaway export: a single table beyond this is truncated rather than exhausting heap. */
|
||||||
|
private static final int MAX_ROWS_PER_TABLE = 200_000;
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final DatabaseExportRequestRepository repository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public Long requestExport(final String reason) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("CREATE_" + PERMISSION);
|
||||||
|
|
||||||
|
if (reason == null || reason.trim().length() < 10) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.reason.too.short",
|
||||||
|
"A reason of at least 10 characters is required for a database export request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// One open request per user at a time, so a queue of forgotten requests cannot build up for an
|
||||||
|
// approver to rubber-stamp in bulk.
|
||||||
|
if (this.repository.countByRequestedByAndStatus(user.getId(), DatabaseExportRequest.STATUS_PENDING_APPROVAL) > 0) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.request.already.pending",
|
||||||
|
"You already have a database export request awaiting approval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final DatabaseExportRequest request = DatabaseExportRequest.createNew(reason.trim(), user.getId(), user.getUsername(),
|
||||||
|
LocalDateTime.now());
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
|
||||||
|
log.warn("Database export requested by user {} ({}), request id {}", user.getUsername(), user.getId(), request.getId());
|
||||||
|
return request.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> retrieveAll(final String status) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||||
|
|
||||||
|
// Aliases are quoted because PostgreSQL folds unquoted identifiers to lower case, which would hand the
|
||||||
|
// client `requestedbyusername` and leave every camelCase field on the screen empty.
|
||||||
|
final StringBuilder sql = new StringBuilder("SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||||
|
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||||
|
+ "approved_by AS \"approvedBy\", approved_by_username AS \"approvedByUsername\", "
|
||||||
|
+ "approved_on AS \"approvedOn\", rejection_reason AS \"rejectionReason\", "
|
||||||
|
+ "generated_on AS \"generatedOn\", generated_by AS \"generatedBy\", file_name AS \"fileName\", "
|
||||||
|
+ "file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||||
|
+ "FROM m_database_export_request ");
|
||||||
|
|
||||||
|
if (status != null && !status.trim().isEmpty()) {
|
||||||
|
sql.append("WHERE status = ? ORDER BY id DESC");
|
||||||
|
return this.jdbcTemplate.queryForList(sql.toString(), status.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
sql.append("ORDER BY id DESC");
|
||||||
|
return this.jdbcTemplate.queryForList(sql.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> retrieveOne(final Long requestId) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission(PERMISSION);
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||||
|
"SELECT id, status, reason, requested_by AS \"requestedBy\", "
|
||||||
|
+ "requested_by_username AS \"requestedByUsername\", requested_on AS \"requestedOn\", "
|
||||||
|
+ "approved_by_username AS \"approvedByUsername\", approved_on AS \"approvedOn\", "
|
||||||
|
+ "rejection_reason AS \"rejectionReason\", generated_on AS \"generatedOn\", "
|
||||||
|
+ "file_name AS \"fileName\", file_size_bytes AS \"fileSizeBytes\", row_count AS \"rowCount\" "
|
||||||
|
+ "FROM m_database_export_request WHERE id = ?",
|
||||||
|
requestId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.found", "Export request not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void approve(final Long requestId) {
|
||||||
|
final AppUser approver = this.context.authenticatedUser();
|
||||||
|
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||||
|
|
||||||
|
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||||
|
validatePending(request);
|
||||||
|
validateNotSelfApproval(request, approver);
|
||||||
|
|
||||||
|
request.approve(approver.getId(), approver.getUsername(), LocalDateTime.now());
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
|
||||||
|
log.warn("Database export request {} approved by {} ({})", requestId, approver.getUsername(), approver.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public void reject(final Long requestId, final String rejectionReason) {
|
||||||
|
final AppUser approver = this.context.authenticatedUser();
|
||||||
|
approver.validateHasPermissionTo("APPROVE_" + PERMISSION);
|
||||||
|
|
||||||
|
final DatabaseExportRequest request = findOneOrThrow(requestId);
|
||||||
|
validatePending(request);
|
||||||
|
|
||||||
|
if (rejectionReason == null || rejectionReason.trim().isEmpty()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.rejection.reason.required",
|
||||||
|
"A reason is required when rejecting a database export request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.reject(approver.getId(), approver.getUsername(), LocalDateTime.now(), rejectionReason.trim());
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Override
|
||||||
|
public byte[] generateExport(final Long requestId) {
|
||||||
|
final AppUser user = this.context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo("DOWNLOAD_" + PERMISSION);
|
||||||
|
|
||||||
|
// Locked for the duration of the transaction: the approval is worth one download, and two concurrent
|
||||||
|
// calls must not both find it unspent.
|
||||||
|
final DatabaseExportRequest request = this.repository.findByIdWithLock(requestId)
|
||||||
|
.orElseThrow(() -> new PlatformDataIntegrityException("error.msg.databaseexport.not.found",
|
||||||
|
"Export request not found."));
|
||||||
|
// Checked before ownership so the requester coming back for a second copy gets told why, rather than
|
||||||
|
// being turned away with a message about approval that no longer describes their situation.
|
||||||
|
if (request.isAlreadyDownloaded()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.downloaded",
|
||||||
|
"This export has already been downloaded. Raise a new request to take another copy.");
|
||||||
|
}
|
||||||
|
if (!request.isDownloadable()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.approved",
|
||||||
|
"This export request has not been approved. Current status: " + request.getStatus() + ".");
|
||||||
|
}
|
||||||
|
validateIsRequester(request, user);
|
||||||
|
|
||||||
|
final List<String> tables = listTenantTables();
|
||||||
|
long totalRows = 0;
|
||||||
|
|
||||||
|
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) {
|
||||||
|
for (final String table : tables) {
|
||||||
|
try {
|
||||||
|
totalRows += writeTableAsCsv(zip, table);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
// One unreadable table (a view, a permissions quirk) must not abandon the whole export.
|
||||||
|
log.error("Database export: skipping table {} — {}", table, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeManifest(zip, request, tables, totalRows, user);
|
||||||
|
} catch (final IOException e) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.generation.failed",
|
||||||
|
"Failed to generate the database export: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] archive = buffer.toByteArray();
|
||||||
|
final String fileName = "cbsnexora-export-" + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now())
|
||||||
|
+ ".zip";
|
||||||
|
|
||||||
|
request.markGenerated(user.getId(), LocalDateTime.now(), fileName, (long) archive.length, totalRows);
|
||||||
|
this.repository.saveAndFlush(request);
|
||||||
|
|
||||||
|
log.warn("Database export request {} downloaded by {} ({}) — {} tables, {} rows, {} bytes", requestId, user.getUsername(),
|
||||||
|
user.getId(), tables.size(), totalRows, archive.length);
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> listTenantTables() {
|
||||||
|
return this.jdbcTemplate.queryForList("SELECT table_name FROM information_schema.tables "
|
||||||
|
+ "WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes one table as a CSV entry and returns how many rows it contained. */
|
||||||
|
private long writeTableAsCsv(final ZipOutputStream zip, final String table) throws IOException {
|
||||||
|
// The table name comes from information_schema, never from user input, so it cannot carry injection.
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate
|
||||||
|
.queryForList("SELECT * FROM \"" + table + "\" LIMIT " + MAX_ROWS_PER_TABLE);
|
||||||
|
|
||||||
|
zip.putNextEntry(new ZipEntry("data/" + table + ".csv"));
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
// Still emit the header so an empty table is visibly empty rather than missing.
|
||||||
|
final List<String> columns = this.jdbcTemplate.queryForList("SELECT column_name FROM information_schema.columns "
|
||||||
|
+ "WHERE table_schema = current_schema() AND table_name = ? ORDER BY ordinal_position", String.class, table);
|
||||||
|
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.write('\n');
|
||||||
|
zip.closeEntry();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<String> columns = new ArrayList<>(rows.get(0).keySet());
|
||||||
|
zip.write(String.join(",", columns).getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.write('\n');
|
||||||
|
|
||||||
|
for (final Map<String, Object> row : rows) {
|
||||||
|
final StringBuilder line = new StringBuilder();
|
||||||
|
for (int i = 0; i < columns.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
line.append(',');
|
||||||
|
}
|
||||||
|
line.append(csvEscape(row.get(columns.get(i))));
|
||||||
|
}
|
||||||
|
line.append('\n');
|
||||||
|
zip.write(line.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
zip.closeEntry();
|
||||||
|
return rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records who authorised the export inside the archive, so the file carries its own provenance. */
|
||||||
|
private void writeManifest(final ZipOutputStream zip, final DatabaseExportRequest request, final List<String> tables,
|
||||||
|
final long totalRows, final AppUser downloadedBy) throws IOException {
|
||||||
|
final Map<String, Object> details = retrieveOne(request.getId());
|
||||||
|
final StringBuilder manifest = new StringBuilder();
|
||||||
|
manifest.append("CBSNexora — Full Database Export\n");
|
||||||
|
manifest.append("=================================\n\n");
|
||||||
|
manifest.append("CONFIDENTIAL. This archive contains customer personal, KYC and account data.\n");
|
||||||
|
manifest.append("Handle under your institution's data protection policy.\n\n");
|
||||||
|
manifest.append("Request ID : ").append(request.getId()).append('\n');
|
||||||
|
manifest.append("Requested by : ").append(details.get("requestedByUsername")).append('\n');
|
||||||
|
manifest.append("Requested on : ").append(details.get("requestedOn")).append('\n');
|
||||||
|
manifest.append("Reason : ").append(details.get("reason")).append('\n');
|
||||||
|
manifest.append("Approved by : ").append(details.get("approvedByUsername")).append('\n');
|
||||||
|
manifest.append("Approved on : ").append(details.get("approvedOn")).append('\n');
|
||||||
|
manifest.append("Downloaded by : ").append(downloadedBy.getUsername()).append('\n');
|
||||||
|
manifest.append("Downloaded on : ").append(LocalDateTime.now()).append('\n');
|
||||||
|
manifest.append("Tables exported : ").append(tables.size()).append('\n');
|
||||||
|
manifest.append("Rows exported : ").append(totalRows).append('\n');
|
||||||
|
manifest.append("Row cap / table : ").append(MAX_ROWS_PER_TABLE).append('\n');
|
||||||
|
|
||||||
|
zip.putNextEntry(new ZipEntry("MANIFEST.txt"));
|
||||||
|
zip.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String csvEscape(final Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final String text = String.valueOf(value);
|
||||||
|
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
|
||||||
|
return '"' + text.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DatabaseExportRequest findOneOrThrow(final Long requestId) {
|
||||||
|
return this.repository.findById(requestId).orElseThrow(() -> new PlatformDataIntegrityException(
|
||||||
|
"error.msg.databaseexport.not.found", "Export request not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validatePending(final DatabaseExportRequest request) {
|
||||||
|
if (!request.isPendingApproval()) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.already.actioned",
|
||||||
|
"This request has already been actioned. Current status: " + request.getStatus() + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The archive goes to the person who asked for it and stated the reason, and to nobody else. An approver
|
||||||
|
* authorises the export; that is not the same as being entitled to take a copy of it, and letting the
|
||||||
|
* approver download would collapse the two-person control back into one.
|
||||||
|
*/
|
||||||
|
private void validateIsRequester(final DatabaseExportRequest request, final AppUser user) {
|
||||||
|
if (!user.getId().equals(request.getRequestedBy())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.not.requester",
|
||||||
|
"Only the user who raised this export request can download the archive.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entire control is that two different people are involved, so this is the check that enforces it. */
|
||||||
|
private void validateNotSelfApproval(final DatabaseExportRequest request, final AppUser approver) {
|
||||||
|
if (approver.getId().equals(request.getRequestedBy())) {
|
||||||
|
throw new PlatformDataIntegrityException("error.msg.databaseexport.self.approval",
|
||||||
|
"A database export cannot be approved by the same user who requested it.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -111,12 +111,16 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
|||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
|
|
||||||
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
|
public ClientContactOtpServiceImpl(final OtpLogRepository otpLogRepository, final EmailSenderService emailSenderService,
|
||||||
final EmailTemplateService emailTemplateService) {
|
final EmailTemplateService emailTemplateService,
|
||||||
|
final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService) {
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.otpLogRepository = otpLogRepository;
|
this.otpLogRepository = otpLogRepository;
|
||||||
this.emailSenderService = emailSenderService;
|
this.emailSenderService = emailSenderService;
|
||||||
this.emailTemplateService = emailTemplateService;
|
this.emailTemplateService = emailTemplateService;
|
||||||
|
this.mobileLookupService = mobileLookupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate ─────────────────────────────────────────────────────────────
|
// ── Generate ─────────────────────────────────────────────────────────────
|
||||||
@@ -128,6 +132,18 @@ public class ClientContactOtpServiceImpl implements ClientContactOtpService {
|
|||||||
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
|
return OtpGenerateResponse.builder().success(false).message("mobileNumber is required").build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A number the DoT flagged may already be in someone else's hands — an MNRL number is one
|
||||||
|
// that was disconnected and can be reassigned. Sending an OTP there hands the fraudster the
|
||||||
|
// credential, so this is refused at the point of dispatch rather than merely warned about
|
||||||
|
// on screen. Only an officer's recorded PROCEEDED decision reopens it.
|
||||||
|
if (!this.mobileLookupService.isMobileNumberUsable(mobileNumber.trim())) {
|
||||||
|
LOG.warn("Refused to send OTP to DIP/MNRL-flagged number {}", OtpDestinationMasker.maskMobile(mobileNumber));
|
||||||
|
return OtpGenerateResponse.builder().success(false)
|
||||||
|
.message("This mobile number is flagged on the DoT Digital Intelligence Platform. "
|
||||||
|
+ "Contact the customer through an alternate channel and obtain a new number.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
final OtpLog log = new OtpLog();
|
final OtpLog log = new OtpLog();
|
||||||
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
log.setPurpose(PURPOSE_CLIENT_CONTACT_CHANGE);
|
||||||
log.setProvider(PROVIDER_LINCPAY);
|
log.setProvider(PROVIDER_LINCPAY);
|
||||||
|
|||||||
+6
-1
@@ -34,7 +34,7 @@ import org.mapstruct.Mapper;
|
|||||||
import org.mapstruct.Mapping;
|
import org.mapstruct.Mapping;
|
||||||
import org.mapstruct.MappingTarget;
|
import org.mapstruct.MappingTarget;
|
||||||
import org.mapstruct.Named;
|
import org.mapstruct.Named;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
|
|
||||||
@Mapper(config = MapstructMapperConfig.class)
|
@Mapper(config = MapstructMapperConfig.class)
|
||||||
public interface ClientMapper {
|
public interface ClientMapper {
|
||||||
@@ -107,6 +107,11 @@ public interface ClientMapper {
|
|||||||
@Mapping(target = "legalFormId", ignore = true)
|
@Mapping(target = "legalFormId", ignore = true)
|
||||||
@Mapping(target = "clientCollateralManagements", ignore = true)
|
@Mapping(target = "clientCollateralManagements", ignore = true)
|
||||||
@Mapping(target = "groups", ignore = true)
|
@Mapping(target = "groups", ignore = true)
|
||||||
|
// Resolved from the latest DIP/MNRL lookup after mapping, not from a Client column — see
|
||||||
|
// ClientReadPlatformServiceImpl#retrieveOne.
|
||||||
|
@Mapping(target = "dipLiveStatus", ignore = true)
|
||||||
|
@Mapping(target = "alternateDipLiveStatus", ignore = true)
|
||||||
|
@Mapping(target = "sanctionsAlertStatus", ignore = true)
|
||||||
ClientData map(Client source);
|
ClientData map(Client source);
|
||||||
|
|
||||||
@Named("clientTypeCode")
|
@Named("clientTypeCode")
|
||||||
|
|||||||
+13
@@ -84,6 +84,8 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
private final ColumnValidator columnValidator;
|
private final ColumnValidator columnValidator;
|
||||||
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
||||||
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
private final ClientRepositoryWrapper clientRepositoryWrapper;
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
|
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
|
||||||
private final ClientMapper clientMapper;
|
private final ClientMapper clientMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -235,6 +237,17 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
// those fields comes back blank however carefully they were filled in at creation.
|
// those fields comes back blank however carefully they were filled in at creation.
|
||||||
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
|
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
|
||||||
|
|
||||||
|
// dipVerified is the flag captured when the client was onboarded and never changes
|
||||||
|
// afterwards. Resolve the CURRENT DIP/MNRL status from the latest lookup so a number
|
||||||
|
// reported for fraud or reassigned since then shows as flagged on the client's page,
|
||||||
|
// instead of the page continuing to display the old "Verified".
|
||||||
|
clientData.setDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getMobileNo()));
|
||||||
|
clientData.setAlternateDipLiveStatus(this.mobileLookupService.getLatestStatus(client.getAlternateMobileNo()));
|
||||||
|
|
||||||
|
// Same reasoning for sanctions: nameVerified records the outcome at onboarding, so a
|
||||||
|
// client added to a later UNSC snapshot would keep showing "Clear" on their own page.
|
||||||
|
clientData.setSanctionsAlertStatus(this.sanctionsScreeningService.getLatestAlertStatus(clientId));
|
||||||
|
|
||||||
// Get client collaterals
|
// Get client collaterals
|
||||||
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
||||||
.getCollateralsPerClient(clientId);
|
.getCollateralsPerClient(clientId);
|
||||||
|
|||||||
+32
@@ -908,12 +908,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
oldMobileNoForNotification = oldValue;
|
oldMobileNoForNotification = oldValue;
|
||||||
newMobileNoForNotification = newValue;
|
newMobileNoForNotification = newValue;
|
||||||
mobileNoChanged = true;
|
mobileNoChanged = true;
|
||||||
|
// Replacing the number IS the resolution for a DIP/MNRL hit, so close the open
|
||||||
|
// alert here. Left to the officer as a second manual step it would be forgotten,
|
||||||
|
// and the queue would fill with cases that were in fact already dealt with.
|
||||||
|
closeDipAlertForReplacedNumber(oldValue, newValue, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.alternateMobileNoParamName, clientForUpdate.getAlternateMobileNo())) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.alternateMobileNoParamName);
|
||||||
|
final String oldAlternate = clientForUpdate.getAlternateMobileNo();
|
||||||
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
|
changes.put(ClientApiConstants.alternateMobileNoParamName, newValue);
|
||||||
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
clientForUpdate.setAlternateMobileNo(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
|
closeDipAlertForReplacedNumber(oldAlternate, newValue, clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.emailAddressParamName, clientForUpdate.getEmailAddress())) {
|
||||||
@@ -1761,6 +1767,32 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
* saved because the browser said it was fine.
|
* saved because the browser said it was fine.
|
||||||
* </p>
|
* </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) {
|
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
|
||||||
if (StringUtils.isBlank(mobileNo)) {
|
if (StringUtils.isBlank(mobileNo)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+41
-8
@@ -37,6 +37,7 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
@@ -129,11 +130,25 @@ public class MobileLookupApiResource {
|
|||||||
logs = mobileLookupService.getLookupLogs();
|
logs = mobileLookupService.getLookupLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<MobileLookupLogData> logDataList = logs.stream()
|
return listSerializer.serialize(withClientNames(logs));
|
||||||
.map(MobileLookupLogData::fromEntity)
|
}
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
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
|
@GET
|
||||||
@@ -146,10 +161,7 @@ public class MobileLookupApiResource {
|
|||||||
public String getPendingDecisions() {
|
public String getPendingDecisions() {
|
||||||
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
||||||
|
|
||||||
final List<MobileLookupLogData> pending = mobileLookupService.getPendingDecisions().stream()
|
return listSerializer.serialize(withClientNames(mobileLookupService.getPendingDecisions()));
|
||||||
.map(MobileLookupLogData::fromEntity)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
return listSerializer.serialize(pending);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -170,4 +182,25 @@ public class MobileLookupApiResource {
|
|||||||
.recordDecision(logId, decision, reason, this.context.authenticatedUser().getId()));
|
.recordDecision(logId, decision, reason, this.context.authenticatedUser().getId()));
|
||||||
return logSerializer.serialize(result);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -34,6 +34,12 @@ public class MobileLookupLogData {
|
|||||||
private final String responseMessage;
|
private final String responseMessage;
|
||||||
private final String lookupSource;
|
private final String lookupSource;
|
||||||
private final Long clientId;
|
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 lookedUpOn;
|
||||||
private final String overrideDecision;
|
private final String overrideDecision;
|
||||||
private final String overrideReason;
|
private final String overrideReason;
|
||||||
@@ -43,6 +49,11 @@ public class MobileLookupLogData {
|
|||||||
private final boolean usable;
|
private final boolean usable;
|
||||||
|
|
||||||
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
|
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(
|
return new MobileLookupLogData(
|
||||||
log.getId(),
|
log.getId(),
|
||||||
log.getMobileNumber(),
|
log.getMobileNumber(),
|
||||||
@@ -52,6 +63,8 @@ public class MobileLookupLogData {
|
|||||||
log.getResponseMessage(),
|
log.getResponseMessage(),
|
||||||
log.getLookupSource(),
|
log.getLookupSource(),
|
||||||
log.getClientId(),
|
log.getClientId(),
|
||||||
|
clientName,
|
||||||
|
clientAccountNo,
|
||||||
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null,
|
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null,
|
||||||
log.getOverrideDecision(),
|
log.getOverrideDecision(),
|
||||||
log.getOverrideReason(),
|
log.getOverrideReason(),
|
||||||
|
|||||||
+35
@@ -59,4 +59,39 @@ public interface MobileLookupService {
|
|||||||
* turned away once should not be screened as if it had no history.
|
* turned away once should not be screened as if it had no history.
|
||||||
*/
|
*/
|
||||||
MobileLookupLog findPriorRejection(String mobileNumber);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-1
@@ -21,8 +21,12 @@ package org.apache.fineract.portfolio.mobilelookup.service;
|
|||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData;
|
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupDetailData;
|
||||||
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult;
|
import org.apache.fineract.portfolio.mobilelookup.data.MobileLookupResult;
|
||||||
@@ -32,6 +36,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
@@ -62,14 +67,16 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final MobileLookupLogRepository logRepository;
|
private final MobileLookupLogRepository logRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
|
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
|
||||||
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
|
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
|
||||||
MobileLookupLogRepository logRepository) {
|
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.logRepository = logRepository;
|
this.logRepository = logRepository;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -229,6 +236,112 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
return rows.isEmpty() ? null : rows.get(0);
|
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
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public MobileLookupLog findPriorRejection(final String mobileNumber) {
|
public MobileLookupLog findPriorRejection(final String mobileNumber) {
|
||||||
|
|||||||
+32
-8
@@ -36,6 +36,7 @@ import java.io.InputStream;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
@@ -186,11 +187,23 @@ public class SanctionsScreeningApiResource {
|
|||||||
logs = sanctionsScreeningService.getScreeningLogs();
|
logs = sanctionsScreeningService.getScreeningLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<SanctionsScreeningLogData> logDataList = logs.stream()
|
return listSerializer.serialize(withClientNames(logs));
|
||||||
.map(SanctionsScreeningLogData::fromEntity)
|
}
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
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 ─────────────────────────────────────────────────────
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
@@ -209,10 +222,7 @@ public class SanctionsScreeningApiResource {
|
|||||||
@QueryParam("status") @Parameter(description = "Filter by disposition") final String status) {
|
@QueryParam("status") @Parameter(description = "Filter by disposition") final String status) {
|
||||||
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
|
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
|
||||||
|
|
||||||
final List<SanctionsScreeningLogData> alerts = sanctionsScreeningService.getAlerts(status).stream()
|
return listSerializer.serialize(withClientNames(sanctionsScreeningService.getAlerts(status)));
|
||||||
.map(SanctionsScreeningLogData::fromEntity)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
return listSerializer.serialize(alerts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -267,6 +277,20 @@ public class SanctionsScreeningApiResource {
|
|||||||
return logSerializer.serialize(result);
|
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
|
@POST
|
||||||
@Path("/rescreen")
|
@Path("/rescreen")
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
|||||||
+33
-3
@@ -34,6 +34,12 @@ public final class SanctionsScreeningLogData {
|
|||||||
private final String matchedNames;
|
private final String matchedNames;
|
||||||
private final String screeningSource;
|
private final String screeningSource;
|
||||||
private final Long clientId;
|
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 String ipAddress;
|
||||||
private final OffsetDateTime screenedOn;
|
private final OffsetDateTime screenedOn;
|
||||||
private final Long screenedBy;
|
private final Long screenedBy;
|
||||||
@@ -47,15 +53,19 @@ public final class SanctionsScreeningLogData {
|
|||||||
private final OffsetDateTime nodalOfficerInformedOn;
|
private final OffsetDateTime nodalOfficerInformedOn;
|
||||||
private final String frozenAccountIds;
|
private final String frozenAccountIds;
|
||||||
private final OffsetDateTime frozenOn;
|
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,
|
public SanctionsScreeningLogData(final Long id, final String screenedName, final String screeningResult,
|
||||||
final Integer matchCount, final String matchedEntryIds, final String matchedNames,
|
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 OffsetDateTime screenedOn, final Long screenedBy, final String disposition,
|
||||||
final String dispositionReason, final Long reviewedBy, final OffsetDateTime reviewedOn,
|
final String dispositionReason, final Long reviewedBy, final OffsetDateTime reviewedOn,
|
||||||
final Long approvedBy, final OffsetDateTime approvedOn, final String fiuStrReference,
|
final Long approvedBy, final OffsetDateTime approvedOn, final String fiuStrReference,
|
||||||
final OffsetDateTime nodalOfficerInformedOn, final String frozenAccountIds,
|
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.id = id;
|
||||||
this.screenedName = screenedName;
|
this.screenedName = screenedName;
|
||||||
this.screeningResult = screeningResult;
|
this.screeningResult = screeningResult;
|
||||||
@@ -64,6 +74,8 @@ public final class SanctionsScreeningLogData {
|
|||||||
this.matchedNames = matchedNames;
|
this.matchedNames = matchedNames;
|
||||||
this.screeningSource = screeningSource;
|
this.screeningSource = screeningSource;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
|
this.clientName = clientName;
|
||||||
|
this.clientAccountNo = clientAccountNo;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
this.screenedOn = screenedOn;
|
this.screenedOn = screenedOn;
|
||||||
this.screenedBy = screenedBy;
|
this.screenedBy = screenedBy;
|
||||||
@@ -77,9 +89,17 @@ public final class SanctionsScreeningLogData {
|
|||||||
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||||
this.frozenAccountIds = frozenAccountIds;
|
this.frozenAccountIds = frozenAccountIds;
|
||||||
this.frozenOn = frozenOn;
|
this.frozenOn = frozenOn;
|
||||||
|
this.strStatus = strStatus;
|
||||||
|
this.strDueDate = strDueDate;
|
||||||
|
this.strRaisedOn = strRaisedOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
|
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(
|
return new SanctionsScreeningLogData(
|
||||||
log.getId(),
|
log.getId(),
|
||||||
log.getScreenedName(),
|
log.getScreenedName(),
|
||||||
@@ -89,6 +109,8 @@ public final class SanctionsScreeningLogData {
|
|||||||
log.getMatchedNames(),
|
log.getMatchedNames(),
|
||||||
log.getScreeningSource(),
|
log.getScreeningSource(),
|
||||||
log.getClientId(),
|
log.getClientId(),
|
||||||
|
clientName,
|
||||||
|
clientAccountNo,
|
||||||
log.getIpAddress(),
|
log.getIpAddress(),
|
||||||
log.getScreenedOn(),
|
log.getScreenedOn(),
|
||||||
log.getCreatedBy().orElse(null),
|
log.getCreatedBy().orElse(null),
|
||||||
@@ -101,7 +123,10 @@ public final class SanctionsScreeningLogData {
|
|||||||
log.getFiuStrReference(),
|
log.getFiuStrReference(),
|
||||||
log.getNodalOfficerInformedOn(),
|
log.getNodalOfficerInformedOn(),
|
||||||
log.getFrozenAccountIds(),
|
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 getMatchedNames() { return matchedNames; }
|
||||||
public String getScreeningSource() { return screeningSource; }
|
public String getScreeningSource() { return screeningSource; }
|
||||||
public Long getClientId() { return clientId; }
|
public Long getClientId() { return clientId; }
|
||||||
|
public String getClientName() { return clientName; }
|
||||||
|
public String getClientAccountNo() { return clientAccountNo; }
|
||||||
public String getIpAddress() { return ipAddress; }
|
public String getIpAddress() { return ipAddress; }
|
||||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
||||||
public Long getScreenedBy() { return screenedBy; }
|
public Long getScreenedBy() { return screenedBy; }
|
||||||
@@ -126,4 +153,7 @@ public final class SanctionsScreeningLogData {
|
|||||||
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||||
public String getFrozenAccountIds() { return frozenAccountIds; }
|
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||||
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||||
|
public String getStrStatus() { return strStatus; }
|
||||||
|
public String getStrDueDate() { return strDueDate; }
|
||||||
|
public String getStrRaisedOn() { return strRaisedOn; }
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -112,4 +112,24 @@ public interface SanctionsScreeningService {
|
|||||||
* @return number of clients that produced a new flagged alert
|
* @return number of clients that produced a new flagged alert
|
||||||
*/
|
*/
|
||||||
int rescreenExistingClients();
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-6
@@ -22,9 +22,12 @@ import java.time.LocalDate;
|
|||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
@@ -170,8 +173,13 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
final Long approverId = currentUserId();
|
final Long approverId = currentUserId();
|
||||||
// Maker-checker: whoever assessed the hit cannot also sign it off.
|
// Maker-checker: whoever assessed the hit cannot also sign it off.
|
||||||
if (approverId != null && approverId.equals(log.getReviewedBy())) {
|
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",
|
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);
|
log.approve(approverId);
|
||||||
@@ -183,6 +191,12 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
log.recordFreeze(frozen);
|
log.recordFreeze(frozen);
|
||||||
LOG.warn("Sanctions alert {}: froze accounts [{}] for client {}", logId, frozen, log.getClientId());
|
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.
|
// A cleared false positive releases any freeze the alert previously imposed.
|
||||||
if (log.isApprovedFalsePositive() && log.getFrozenAccountIds() != null && !log.getFrozenAccountIds().isBlank()) {
|
if (log.isApprovedFalsePositive() && log.getFrozenAccountIds() != null && !log.getFrozenAccountIds().isBlank()) {
|
||||||
sanctionsFreezeService.unfreezeClientAccounts(log.getClientId());
|
sanctionsFreezeService.unfreezeClientAccounts(log.getClientId());
|
||||||
@@ -228,15 +242,27 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public int rescreenExistingClients() {
|
public int rescreenExistingClients() {
|
||||||
// display_name is maintained by Fineract for both persons and entities, so one query
|
// display_name is denormalised from the name parts and can drift out of sync with them —
|
||||||
// covers individual and non-individual clients alike.
|
// a direct DB edit, or any write path that misses it, leaves it stale. Screening the
|
||||||
final List<Map<String, Object>> clients = jdbcTemplate
|
// stale value would silently clear a client whose actual name is on the list, so the
|
||||||
.queryForList("SELECT id, display_name FROM m_client WHERE status_enum = 300 AND display_name IS NOT NULL");
|
// 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;
|
int flagged = 0;
|
||||||
for (final Map<String, Object> row : clients) {
|
for (final Map<String, Object> row : clients) {
|
||||||
final Long clientId = ((Number) row.get("id")).longValue();
|
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 {
|
try {
|
||||||
final SanctionsScreeningResult result = screenName(name, "PERIODIC_RESCREEN", clientId, null);
|
final SanctionsScreeningResult result = screenName(name, "PERIODIC_RESCREEN", clientId, null);
|
||||||
if (result.isMatchFound()) {
|
if (result.isMatchFound()) {
|
||||||
@@ -250,6 +276,106 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
return flagged;
|
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) {
|
private SanctionsScreeningLog findAlertOrThrow(final Long logId) {
|
||||||
final SanctionsScreeningLog log = screeningLogRepository.findById(logId)
|
final SanctionsScreeningLog log = screeningLogRepository.findById(logId)
|
||||||
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.found",
|
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.found",
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@ import lombok.Data;
|
|||||||
public class ClientWelcomeKitData implements Serializable {
|
public class ClientWelcomeKitData implements Serializable {
|
||||||
|
|
||||||
private final String clientName;
|
private final String clientName;
|
||||||
|
private final boolean clientActive;
|
||||||
private final boolean canDispatchNew;
|
private final boolean canDispatchNew;
|
||||||
private final List<WelcomeKitDispatchData> dispatches;
|
private final List<WelcomeKitDispatchData> dispatches;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -31,10 +31,13 @@ public class WelcomeKitDispatchData implements Serializable {
|
|||||||
private final Long clientId;
|
private final Long clientId;
|
||||||
private final String clientName;
|
private final String clientName;
|
||||||
private final String deliveryAddress;
|
private final String deliveryAddress;
|
||||||
|
private final Long addressId;
|
||||||
private final String deliveryMode;
|
private final String deliveryMode;
|
||||||
private final String deliveryModeOtherText;
|
private final String deliveryModeOtherText;
|
||||||
private final String trackingId;
|
private final String trackingId;
|
||||||
private final String status;
|
private final String status;
|
||||||
private final LocalDate dispatchedOnDate;
|
private final LocalDate dispatchedOnDate;
|
||||||
private final LocalDate deliveredOnDate;
|
private final LocalDate deliveredOnDate;
|
||||||
|
private final LocalDate returnedOnDate;
|
||||||
|
private final String remarks;
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-7
@@ -39,6 +39,13 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|||||||
@EntityListeners(AuditingEntityListener.class)
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
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
|
@ManyToOne
|
||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
private Client client;
|
||||||
@@ -46,6 +53,13 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "delivery_address", length = 500, nullable = false)
|
@Column(name = "delivery_address", length = 500, nullable = false)
|
||||||
private String deliveryAddress;
|
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)
|
@Column(name = "delivery_mode", length = 50, nullable = false)
|
||||||
private String deliveryMode;
|
private String deliveryMode;
|
||||||
|
|
||||||
@@ -64,6 +78,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "delivered_on_date")
|
@Column(name = "delivered_on_date")
|
||||||
private LocalDate deliveredOnDate;
|
private LocalDate deliveredOnDate;
|
||||||
|
|
||||||
|
@Column(name = "returned_on_date")
|
||||||
|
private LocalDate returnedOnDate;
|
||||||
|
|
||||||
|
@Column(name = "remarks", length = 500)
|
||||||
|
private String remarks;
|
||||||
|
|
||||||
@CreatedBy
|
@CreatedBy
|
||||||
@Column(name = "created_by", updatable = false)
|
@Column(name = "created_by", updatable = false)
|
||||||
private Long createdBy;
|
private Long createdBy;
|
||||||
@@ -84,11 +104,12 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
// Default constructor for JPA
|
// 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 String deliveryModeOtherText, final String trackingId, final String status,
|
||||||
final LocalDate dispatchedOnDate) {
|
final LocalDate dispatchedOnDate) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.deliveryAddress = deliveryAddress;
|
this.deliveryAddress = deliveryAddress;
|
||||||
|
this.addressId = addressId;
|
||||||
this.deliveryMode = deliveryMode;
|
this.deliveryMode = deliveryMode;
|
||||||
this.deliveryModeOtherText = deliveryModeOtherText;
|
this.deliveryModeOtherText = deliveryModeOtherText;
|
||||||
this.trackingId = trackingId;
|
this.trackingId = trackingId;
|
||||||
@@ -96,9 +117,10 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
this.dispatchedOnDate = dispatchedOnDate;
|
this.dispatchedOnDate = dispatchedOnDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final String deliveryMode,
|
public static WelcomeKitDispatch createNew(final Client client, final String deliveryAddress, final Long addressId,
|
||||||
final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
|
final String deliveryMode, final String deliveryModeOtherText, final String trackingId, final LocalDate dispatchedOnDate) {
|
||||||
return new WelcomeKitDispatch(client, deliveryAddress, deliveryMode, deliveryModeOtherText, trackingId, "DISPATCHED", dispatchedOnDate);
|
return new WelcomeKitDispatch(client, deliveryAddress, addressId, deliveryMode, deliveryModeOtherText, trackingId,
|
||||||
|
STATUS_DISPATCHED, dispatchedOnDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Client getClient() {
|
public Client getClient() {
|
||||||
@@ -109,10 +131,23 @@ public class WelcomeKitDispatch extends AbstractPersistableCustom<Long> {
|
|||||||
return this.status;
|
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;
|
this.status = newStatus;
|
||||||
if ("DELIVERED".equals(newStatus)) {
|
if (STATUS_DELIVERED.equals(newStatus)) {
|
||||||
this.deliveredOnDate = deliveredOnDate;
|
this.deliveredOnDate = outcomeDate;
|
||||||
|
this.returnedOnDate = null;
|
||||||
|
} else if (STATUS_RETURNED.equals(newStatus)) {
|
||||||
|
this.returnedOnDate = outcomeDate;
|
||||||
|
this.deliveredOnDate = null;
|
||||||
}
|
}
|
||||||
|
this.remarks = remarks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -25,4 +25,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
|||||||
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
|
public interface WelcomeKitDispatchRepository extends JpaRepository<WelcomeKitDispatch, Long>, JpaSpecificationExecutor<WelcomeKitDispatch> {
|
||||||
|
|
||||||
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
|
List<WelcomeKitDispatch> findByClientIdOrderByCreatedDateDesc(Long clientId);
|
||||||
|
|
||||||
|
boolean existsByTrackingIdIgnoreCase(String trackingId);
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-13
@@ -22,10 +22,13 @@ import java.sql.ResultSet;
|
|||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.domain.JdbcSupport;
|
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.ClientWelcomeKitData;
|
||||||
import org.apache.fineract.portfolio.welcomekit.data.WelcomeKitDispatchData;
|
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.JdbcTemplate;
|
||||||
import org.springframework.jdbc.core.RowMapper;
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -43,20 +46,21 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
|
|||||||
|
|
||||||
List<WelcomeKitDispatchData> dispatches = this.jdbcTemplate.query(sql, rm, clientId);
|
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);
|
final Map<String, Object> client = this.jdbcTemplate.queryForMap("SELECT display_name, status_enum FROM m_client WHERE id = ?",
|
||||||
|
clientId);
|
||||||
boolean canDispatchNew = true;
|
final String clientName = (String) client.get("display_name");
|
||||||
if (!dispatches.isEmpty()) {
|
final boolean clientActive = ClientStatus.fromInt((Integer) client.get("status_enum")).isActive();
|
||||||
WelcomeKitDispatchData latest = dispatches.get(0);
|
|
||||||
if ("RETURNED".equals(latest.getStatus())) {
|
// A kit only goes out for an active client, and only one kit is in flight at a time: a fresh one can follow
|
||||||
canDispatchNew = true;
|
// a returned kit (the address is being retried) but never a delivered or still-in-transit one.
|
||||||
} else {
|
boolean canDispatchNew = clientActive;
|
||||||
canDispatchNew = false;
|
if (clientActive && !dispatches.isEmpty()) {
|
||||||
}
|
canDispatchNew = WelcomeKitDispatch.STATUS_RETURNED.equals(dispatches.get(0).getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
return ClientWelcomeKitData.builder()
|
return ClientWelcomeKitData.builder()
|
||||||
.clientName(clientName)
|
.clientName(clientName)
|
||||||
|
.clientActive(clientActive)
|
||||||
.canDispatchNew(canDispatchNew)
|
.canDispatchNew(canDispatchNew)
|
||||||
.dispatches(dispatches)
|
.dispatches(dispatches)
|
||||||
.build();
|
.build();
|
||||||
@@ -66,9 +70,10 @@ public class WelcomeKitReadPlatformServiceImpl implements WelcomeKitReadPlatform
|
|||||||
|
|
||||||
public String schema() {
|
public String schema() {
|
||||||
return " w.id as id, w.client_id as clientId, c.display_name as clientName, "
|
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.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 "
|
+ " FROM m_welcome_kit_dispatch w "
|
||||||
+ " JOIN m_client c ON c.id = w.client_id ";
|
+ " 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 Long clientId = rs.getLong("clientId");
|
||||||
final String clientName = rs.getString("clientName");
|
final String clientName = rs.getString("clientName");
|
||||||
final String deliveryAddress = rs.getString("deliveryAddress");
|
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 deliveryMode = rs.getString("deliveryMode");
|
||||||
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
|
final String deliveryModeOtherText = rs.getString("deliveryModeOtherText");
|
||||||
final String trackingId = rs.getString("trackingId");
|
final String trackingId = rs.getString("trackingId");
|
||||||
final String status = rs.getString("status");
|
final String status = rs.getString("status");
|
||||||
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
|
final LocalDate dispatchedOnDate = JdbcSupport.getLocalDate(rs, "dispatchedOnDate");
|
||||||
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
|
final LocalDate deliveredOnDate = JdbcSupport.getLocalDate(rs, "deliveredOnDate");
|
||||||
|
final LocalDate returnedOnDate = JdbcSupport.getLocalDate(rs, "returnedOnDate");
|
||||||
|
final String remarks = rs.getString("remarks");
|
||||||
|
|
||||||
return WelcomeKitDispatchData.builder()
|
return WelcomeKitDispatchData.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.clientId(clientId)
|
.clientId(clientId)
|
||||||
.clientName(clientName)
|
.clientName(clientName)
|
||||||
.deliveryAddress(deliveryAddress)
|
.deliveryAddress(deliveryAddress)
|
||||||
|
.addressId(addressId)
|
||||||
.deliveryMode(deliveryMode)
|
.deliveryMode(deliveryMode)
|
||||||
.deliveryModeOtherText(deliveryModeOtherText)
|
.deliveryModeOtherText(deliveryModeOtherText)
|
||||||
.trackingId(trackingId)
|
.trackingId(trackingId)
|
||||||
.status(status)
|
.status(status)
|
||||||
.dispatchedOnDate(dispatchedOnDate)
|
.dispatchedOnDate(dispatchedOnDate)
|
||||||
.deliveredOnDate(deliveredOnDate)
|
.deliveredOnDate(deliveredOnDate)
|
||||||
|
.returnedOnDate(returnedOnDate)
|
||||||
|
.remarks(remarks)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-8
@@ -19,12 +19,12 @@
|
|||||||
package org.apache.fineract.portfolio.welcomekit.service;
|
package org.apache.fineract.portfolio.welcomekit.service;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
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.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.client.domain.Client;
|
import org.apache.fineract.portfolio.client.domain.Client;
|
||||||
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
|
||||||
@@ -48,9 +48,16 @@ public class WelcomeKitWritePlatformServiceImpl implements WelcomeKitWritePlatfo
|
|||||||
|
|
||||||
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
|
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 deliveryAddress = command.stringValueOfParameterNamed("deliveryAddress");
|
||||||
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
|
final String deliveryMode = command.stringValueOfParameterNamed("deliveryMode");
|
||||||
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
|
final String deliveryModeOtherText = command.stringValueOfParameterNamed("deliveryModeOtherText");
|
||||||
|
final String trackingId = command.stringValueOfParameterNamed("trackingId");
|
||||||
|
final Long addressId = command.longValueOfParameterNamed("addressId");
|
||||||
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
|
final LocalDate dispatchedOnDate = command.localDateValueOfParameterNamed("dispatchedOnDate");
|
||||||
|
|
||||||
if (deliveryAddress == null || deliveryAddress.trim().isEmpty()) {
|
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",
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.deliveryMode.cannot.be.blank",
|
||||||
"Delivery mode 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) {
|
if (dispatchedOnDate == null) {
|
||||||
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.dispatchedOnDate.cannot.be.blank",
|
||||||
"Dispatched on date 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,
|
final WelcomeKitDispatch newDispatch = WelcomeKitDispatch.createNew(client, deliveryAddress, addressId, deliveryMode,
|
||||||
trackingId, dispatchedOnDate);
|
deliveryModeOtherText, sanitisedTrackingId, dispatchedOnDate);
|
||||||
|
|
||||||
this.welcomeKitDispatchRepository.saveAndFlush(newDispatch);
|
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.");
|
throw new PlatformDataIntegrityException("error.msg.welcomekit.status.cannot.be.blank", "Status cannot be blank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDate deliveredOnDate = null;
|
final String newStatus = status.trim().toUpperCase();
|
||||||
if ("DELIVERED".equals(status)) {
|
if (!WelcomeKitDispatch.STATUS_DELIVERED.equals(newStatus) && !WelcomeKitDispatch.STATUS_RETURNED.equals(newStatus)) {
|
||||||
deliveredOnDate = LocalDate.now();
|
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);
|
this.welcomeKitDispatchRepository.saveAndFlush(dispatch);
|
||||||
|
|
||||||
|
|||||||
@@ -533,4 +533,8 @@
|
|||||||
<include file="parts/0464_add_sanctions_alert_disposition.xml" relativeToChangelogFile="true"/>
|
<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_add_dip_override_and_client_status.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0465_create_loan_origination_pipeline.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>
|
</databaseChangeLog>
|
||||||
|
|||||||
+40
@@ -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>
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.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>
|
||||||
+20
@@ -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>
|
||||||
+72
@@ -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>
|
||||||
Reference in New Issue
Block a user