feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract 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 - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (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-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 Documentation build / build (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 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract 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 - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (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-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 Documentation build / build (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
- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports. - Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons. - Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars. - Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval. - Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names. - Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks. - Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
This commit is contained in:
+1
@@ -191,6 +191,7 @@ public class ClientApiConstants {
|
|||||||
public static final String constitutionIdParamName = "constitutionId";
|
public static final String constitutionIdParamName = "constitutionId";
|
||||||
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
|
||||||
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
public static final String authorizedSignatoryParamName = "authorizedSignatory";
|
||||||
|
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
|
||||||
public static final String additionalInfoParamName = "additionalInfo";
|
public static final String additionalInfoParamName = "additionalInfo";
|
||||||
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
public static final String dateOfRegistrationParamName = "dateOfRegistration";
|
||||||
|
|
||||||
|
|||||||
+14
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
private final CodeValueData mainBusinessLine;
|
private final CodeValueData mainBusinessLine;
|
||||||
private final String remarks;
|
private final String remarks;
|
||||||
private final String authorizedSignatory;
|
private final String authorizedSignatory;
|
||||||
|
/** Client ids behind {@link #authorizedSignatory}, comma separated and in the same order. */
|
||||||
|
private final String authorizedSignatoryClientIds;
|
||||||
private final String additionalInfo;
|
private final String additionalInfo;
|
||||||
private final LocalDate dateOfRegistration;
|
private final LocalDate dateOfRegistration;
|
||||||
|
|
||||||
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.constitution = null;
|
this.constitution = null;
|
||||||
this.mainBusinessLine = null;
|
this.mainBusinessLine = null;
|
||||||
this.authorizedSignatory = null;
|
this.authorizedSignatory = null;
|
||||||
|
this.authorizedSignatoryClientIds = null;
|
||||||
this.additionalInfo = null;
|
this.additionalInfo = null;
|
||||||
this.dateOfRegistration = null;
|
this.dateOfRegistration = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept for the list/search mappers, whose SQL does not select the signatory client ids.
|
||||||
|
*/
|
||||||
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
LocalDate dateOfRegistration) {
|
||||||
|
this(constitution, incorpNo, incorpValidityTillDate, mainBusinessLine, remarks, authorizedSignatory, additionalInfo,
|
||||||
|
dateOfRegistration, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientNonPersonData(CodeValueData constitution, String incorpNo, LocalDate incorpValidityTillDate,
|
||||||
|
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
|
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||||
|
|
||||||
this.constitution = constitution;
|
this.constitution = constitution;
|
||||||
this.incorpNumber = incorpNo;
|
this.incorpNumber = incorpNo;
|
||||||
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
|
|||||||
this.mainBusinessLine = mainBusinessLine;
|
this.mainBusinessLine = mainBusinessLine;
|
||||||
this.remarks = remarks;
|
this.remarks = remarks;
|
||||||
this.authorizedSignatory = authorizedSignatory;
|
this.authorizedSignatory = authorizedSignatory;
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
|
||||||
this.additionalInfo = additionalInfo;
|
this.additionalInfo = additionalInfo;
|
||||||
this.dateOfRegistration = dateOfRegistration;
|
this.dateOfRegistration = dateOfRegistration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@Column(name = "alternate_dip_timestamp")
|
@Column(name = "alternate_dip_timestamp")
|
||||||
private String alternateDipTimestamp;
|
private String alternateDipTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DIP/MNRL status the number actually returned — CLEAN, SUSPECTED, DISCONNECTED or
|
||||||
|
* SERVICE_UNAVAILABLE. dipVerified alone cannot tell "checked and clean" apart from "the DoT
|
||||||
|
* service was down", which previously stored both as verified.
|
||||||
|
*/
|
||||||
|
@Column(name = "dip_status", length = 40)
|
||||||
|
private String dipStatus;
|
||||||
|
|
||||||
|
@Column(name = "alternate_dip_status", length = 40)
|
||||||
|
private String alternateDipStatus;
|
||||||
|
|
||||||
@Column(name = "name_verified")
|
@Column(name = "name_verified")
|
||||||
private Boolean nameVerified;
|
private Boolean nameVerified;
|
||||||
|
|
||||||
|
|||||||
+52
@@ -25,11 +25,15 @@ import jakarta.persistence.Entity;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
import jakarta.persistence.UniqueConstraint;
|
import jakarta.persistence.UniqueConstraint;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
@@ -61,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
@JoinColumn(name = "client_id", nullable = false)
|
@JoinColumn(name = "client_id", nullable = false)
|
||||||
private Client client;
|
private Client client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scratch record of what the in-flight {@link #update} replaced, read back via
|
||||||
|
* {@link #getFieldsOverwrittenByLastUpdate()}. Never persisted and never read outside the
|
||||||
|
* transaction that called update(), which clears it first.
|
||||||
|
*/
|
||||||
|
@Transient
|
||||||
|
private final transient Set<String> fieldsOverwrittenByLastUpdate = new LinkedHashSet<>();
|
||||||
|
|
||||||
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
/** 100 = PENDING, 200 = IN_PROGRESS, 300 = VERIFIED, 400 = REJECTED, 500 = EXPIRED */
|
||||||
@Column(name = "kyc_status", nullable = false)
|
@Column(name = "kyc_status", nullable = false)
|
||||||
private Integer kycStatus;
|
private Integer kycStatus;
|
||||||
@@ -440,11 +452,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
|
|
||||||
public Map<String, Object> update(final JsonCommand command) {
|
public Map<String, Object> update(final JsonCommand command) {
|
||||||
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
final Map<String, Object> actualChanges = new LinkedHashMap<>();
|
||||||
|
this.fieldsOverwrittenByLastUpdate.clear();
|
||||||
|
|
||||||
final String panNumberParam = "panNumber";
|
final String panNumberParam = "panNumber";
|
||||||
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
|
||||||
actualChanges.put(panNumberParam, newValue);
|
actualChanges.put(panNumberParam, newValue);
|
||||||
|
recordOverwrite(panNumberParam, this.panNumber);
|
||||||
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
|
||||||
actualChanges.put(aadhaarParam, newValue);
|
actualChanges.put(aadhaarParam, newValue);
|
||||||
|
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
|
||||||
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
final String newValue = command.stringValueOfParameterNamed(gstinParam);
|
||||||
actualChanges.put(gstinParam, newValue);
|
actualChanges.put(gstinParam, newValue);
|
||||||
|
recordOverwrite(gstinParam, this.gstinNumber);
|
||||||
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
|
||||||
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
|
||||||
actualChanges.put(riskParam, newValue);
|
actualChanges.put(riskParam, newValue);
|
||||||
|
recordOverwrite(riskParam, this.riskCategory);
|
||||||
this.riskCategory = newValue;
|
this.riskCategory = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remarks is the audit justification itself, so a change to it is deliberately not recorded
|
||||||
|
// as an overwrite — requiring remarks in order to write remarks would be circular.
|
||||||
final String remarksParam = "remarks";
|
final String remarksParam = "remarks";
|
||||||
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
final String newValue = command.stringValueOfParameterNamed(remarksParam);
|
||||||
@@ -551,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||||
final String newValue = command.stringValueOfParameterNamed(param);
|
final String newValue = command.stringValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +591,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,10 +602,40 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
|||||||
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
|
||||||
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
|
||||||
changes.put(param, newValue);
|
changes.put(param, newValue);
|
||||||
|
recordOverwrite(param, current);
|
||||||
setter.accept(newValue);
|
setter.accept(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that {@code param} replaced a value the record already held. A field going from empty to
|
||||||
|
* populated is the record being completed rather than altered, so it is deliberately not
|
||||||
|
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||||
|
*/
|
||||||
|
private void recordOverwrite(final String param, final Object currentValue) {
|
||||||
|
final boolean wasPopulated = currentValue instanceof String text
|
||||||
|
? StringUtils.isNotBlank(text)
|
||||||
|
: currentValue != null;
|
||||||
|
if (wasPopulated) {
|
||||||
|
this.fieldsOverwrittenByLastUpdate.add(param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters whose already-populated value the most recent {@link #update} replaced.
|
||||||
|
*
|
||||||
|
* <p>Distinguishes altering a record from finishing one: a non-individual KYC auto-verifies as
|
||||||
|
* soon as PAN and GSTIN pass, which happens part-way through the create wizard, and the officer
|
||||||
|
* still has to save the KYC document details afterwards. Those fields are empty at that point,
|
||||||
|
* so filling them in leaves this set empty and no audit justification is demanded — while
|
||||||
|
* changing a document number that was already on the verified record does populate it.
|
||||||
|
*
|
||||||
|
* <p>Reset at the start of every {@link #update} and never persisted: it describes one call.
|
||||||
|
*/
|
||||||
|
public Set<String> getFieldsOverwrittenByLastUpdate() {
|
||||||
|
return Collections.unmodifiableSet(this.fieldsOverwrittenByLastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
/** Called when documents are submitted and 3rd-party verification is initiated. */
|
||||||
public void markInProgress() {
|
public void markInProgress() {
|
||||||
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
|
||||||
|
|||||||
+46
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
|||||||
@Column(name = "looked_up_on", nullable = false)
|
@Column(name = "looked_up_on", nullable = false)
|
||||||
private OffsetDateTime lookedUpOn;
|
private OffsetDateTime lookedUpOn;
|
||||||
|
|
||||||
|
// ── Officer decision on a non-clean result ────────────────────────────────
|
||||||
|
// A DIP hit is a fraud-risk signal rather than a legal bar, so onboarding may continue — but
|
||||||
|
// the officer has to say why in writing, and that reasoning is kept with the lookup.
|
||||||
|
|
||||||
|
/** PROCEEDED | REJECTED. Null while no decision is required or none has been taken. */
|
||||||
|
@Column(name = "override_decision", length = 20)
|
||||||
|
private String overrideDecision;
|
||||||
|
|
||||||
|
@Column(name = "override_reason")
|
||||||
|
private String overrideReason;
|
||||||
|
|
||||||
|
@Column(name = "decided_by")
|
||||||
|
private Long decidedBy;
|
||||||
|
|
||||||
|
@Column(name = "decided_on")
|
||||||
|
private OffsetDateTime decidedOn;
|
||||||
|
|
||||||
|
public static final String STATUS_CLEAN = "CLEAN";
|
||||||
|
public static final String STATUS_ERROR = "ERROR";
|
||||||
|
public static final String STATUS_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE";
|
||||||
|
public static final String DECISION_PROCEEDED = "PROCEEDED";
|
||||||
|
public static final String DECISION_REJECTED = "REJECTED";
|
||||||
|
|
||||||
protected MobileLookupLog() {}
|
protected MobileLookupLog() {}
|
||||||
|
|
||||||
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
|
||||||
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
this.lookedUpOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void recordDecision(final String decision, final String reason, final Long userId) {
|
||||||
|
this.overrideDecision = decision;
|
||||||
|
this.overrideReason = reason;
|
||||||
|
this.decidedBy = userId;
|
||||||
|
this.decidedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the lookup produced something the officer must consciously decide about. */
|
||||||
|
public boolean requiresDecision() {
|
||||||
|
return this.suspected || this.disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number may be used only when it came back clean, or when an officer explicitly chose to
|
||||||
|
* proceed. An unreachable DoT service is NOT a pass — it means nothing was checked.
|
||||||
|
*/
|
||||||
|
public boolean isUsable() {
|
||||||
|
if (STATUS_CLEAN.equals(this.lookupStatus)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return DECISION_PROCEEDED.equals(this.overrideDecision);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -19,6 +19,8 @@
|
|||||||
package org.apache.fineract.portfolio.mobilelookup.domain;
|
package org.apache.fineract.portfolio.mobilelookup.domain;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -35,4 +37,29 @@ public interface MobileLookupLogRepository extends JpaRepository<MobileLookupLog
|
|||||||
|
|
||||||
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.lookupStatus = :status ORDER BY l.lookedUpOn DESC")
|
||||||
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
List<MobileLookupLog> findByLookupStatus(@Param("status") String status);
|
||||||
|
|
||||||
|
/** Non-clean lookups still waiting for an officer's decision — the DIP review queue. */
|
||||||
|
@Query("SELECT l FROM MobileLookupLog l WHERE (l.suspected = true OR l.disconnected = true) "
|
||||||
|
+ "AND l.overrideDecision IS NULL ORDER BY l.lookedUpOn DESC")
|
||||||
|
List<MobileLookupLog> findPendingDecisions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent lookup for a number, used by the server-side gate to confirm the browser's
|
||||||
|
* claim that a number was verified.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller
|
||||||
|
* takes the first row.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC")
|
||||||
|
List<MobileLookupLog> findLatestByMobileNumber(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Earlier rejections of the same number, on any client. A number turned away once is a
|
||||||
|
* standing signal: without this the next officer screens it fresh and sees no history.
|
||||||
|
*/
|
||||||
|
@Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber "
|
||||||
|
+ "AND l.overrideDecision = 'REJECTED' ORDER BY l.decidedOn DESC")
|
||||||
|
List<MobileLookupLog> findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
+98
@@ -61,6 +61,47 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
@Column(name = "screened_on", nullable = false)
|
@Column(name = "screened_on", nullable = false)
|
||||||
private OffsetDateTime screenedOn;
|
private OffsetDateTime screenedOn;
|
||||||
|
|
||||||
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
|
// A FLAGGED screening opens a case. It stays PENDING_REVIEW until an officer either
|
||||||
|
// documents it as a different person (FALSE_POSITIVE, effective only once the Principal
|
||||||
|
// Officer approves) or confirms it (CONFIRMED_MATCH, which freezes and gets reported).
|
||||||
|
|
||||||
|
@Column(name = "disposition", length = 30)
|
||||||
|
private String disposition;
|
||||||
|
|
||||||
|
@Column(name = "disposition_reason")
|
||||||
|
private String dispositionReason;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_by")
|
||||||
|
private Long reviewedBy;
|
||||||
|
|
||||||
|
@Column(name = "reviewed_on")
|
||||||
|
private OffsetDateTime reviewedOn;
|
||||||
|
|
||||||
|
@Column(name = "approved_by")
|
||||||
|
private Long approvedBy;
|
||||||
|
|
||||||
|
@Column(name = "approved_on")
|
||||||
|
private OffsetDateTime approvedOn;
|
||||||
|
|
||||||
|
@Column(name = "fiu_str_reference", length = 100)
|
||||||
|
private String fiuStrReference;
|
||||||
|
|
||||||
|
@Column(name = "nodal_officer_informed_on")
|
||||||
|
private OffsetDateTime nodalOfficerInformedOn;
|
||||||
|
|
||||||
|
@Column(name = "frozen_account_ids", length = 1000)
|
||||||
|
private String frozenAccountIds;
|
||||||
|
|
||||||
|
@Column(name = "frozen_on")
|
||||||
|
private OffsetDateTime frozenOn;
|
||||||
|
|
||||||
|
public static final String RESULT_FLAGGED = "FLAGGED";
|
||||||
|
public static final String RESULT_CLEAR = "CLEAR";
|
||||||
|
public static final String DISPOSITION_PENDING = "PENDING_REVIEW";
|
||||||
|
public static final String DISPOSITION_FALSE_POSITIVE = "FALSE_POSITIVE";
|
||||||
|
public static final String DISPOSITION_CONFIRMED = "CONFIRMED_MATCH";
|
||||||
|
|
||||||
protected SanctionsScreeningLog() {}
|
protected SanctionsScreeningLog() {}
|
||||||
|
|
||||||
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
|
||||||
@@ -75,8 +116,55 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
this.screenedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
// A hit opens a case; a clear screening has no case to close.
|
||||||
|
this.disposition = RESULT_FLAGGED.equals(screeningResult) ? DISPOSITION_PENDING : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Alert lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the officer's assessment of the hit. Deliberately does NOT clear the block: a
|
||||||
|
* false positive only takes effect once {@link #approve(Long)} is called by the Principal
|
||||||
|
* Officer, so the person who raised it cannot wave their own match through.
|
||||||
|
*/
|
||||||
|
public void recordDisposition(final String newDisposition, final String reason, final Long userId) {
|
||||||
|
this.disposition = newDisposition;
|
||||||
|
this.dispositionReason = reason;
|
||||||
|
this.reviewedBy = userId;
|
||||||
|
this.reviewedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
// Re-opening a previously approved alert must drop the old approval with it.
|
||||||
|
this.approvedBy = null;
|
||||||
|
this.approvedOn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approve(final Long approverId) {
|
||||||
|
this.approvedBy = approverId;
|
||||||
|
this.approvedOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the FIU-IND STR reference and the date the nodal officer was informed. */
|
||||||
|
public void recordReporting(final String strReference, final OffsetDateTime nodalOfficerInformedOn) {
|
||||||
|
if (strReference != null) {
|
||||||
|
this.fiuStrReference = strReference;
|
||||||
|
}
|
||||||
|
if (nodalOfficerInformedOn != null) {
|
||||||
|
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFreeze(final String accountIds) {
|
||||||
|
this.frozenAccountIds = accountIds;
|
||||||
|
this.frozenOn = DateUtils.getOffsetDateTimeOfTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFlagged() { return RESULT_FLAGGED.equals(this.screeningResult); }
|
||||||
|
|
||||||
|
public boolean isApprovedFalsePositive() {
|
||||||
|
return DISPOSITION_FALSE_POSITIVE.equals(this.disposition) && this.approvedBy != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfirmedMatch() { return DISPOSITION_CONFIRMED.equals(this.disposition); }
|
||||||
|
|
||||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public String getScreenedName() { return screenedName; }
|
public String getScreenedName() { return screenedName; }
|
||||||
@@ -89,4 +177,14 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
|
|||||||
public void setClientId(Long clientId) { this.clientId = clientId; }
|
public void setClientId(Long clientId) { this.clientId = clientId; }
|
||||||
public String getIpAddress() { return ipAddress; }
|
public String getIpAddress() { return ipAddress; }
|
||||||
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
public OffsetDateTime getScreenedOn() { return screenedOn; }
|
||||||
|
public String getDisposition() { return disposition; }
|
||||||
|
public String getDispositionReason() { return dispositionReason; }
|
||||||
|
public Long getReviewedBy() { return reviewedBy; }
|
||||||
|
public OffsetDateTime getReviewedOn() { return reviewedOn; }
|
||||||
|
public Long getApprovedBy() { return approvedBy; }
|
||||||
|
public OffsetDateTime getApprovedOn() { return approvedOn; }
|
||||||
|
public String getFiuStrReference() { return fiuStrReference; }
|
||||||
|
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||||
|
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||||
|
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -39,4 +39,24 @@ public interface SanctionsScreeningLogRepository extends JpaRepository<Sanctions
|
|||||||
|
|
||||||
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE LOWER(l.screenedName) LIKE LOWER(CONCAT('%', :name, '%')) ORDER BY l.screenedOn DESC")
|
||||||
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
|
List<SanctionsScreeningLog> findByScreenedNameContaining(@Param("name") String name);
|
||||||
|
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findByDisposition(@Param("disposition") String disposition);
|
||||||
|
|
||||||
|
/** Every open alert — the Principal Officer's review queue. */
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition IS NOT NULL ORDER BY l.screenedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findAllAlerts();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name is only cleared to onboard once a false positive on that exact name has been
|
||||||
|
* approved by the Principal Officer — the maker's own assessment is not enough.
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(l) FROM SanctionsScreeningLog l WHERE LOWER(TRIM(l.screenedName)) = LOWER(TRIM(:name)) "
|
||||||
|
+ "AND l.disposition = 'FALSE_POSITIVE' AND l.approvedBy IS NOT NULL")
|
||||||
|
long countApprovedFalsePositives(@Param("name") String name);
|
||||||
|
|
||||||
|
/** Latest alert for a client, used to decide whether their accounts must stay frozen. */
|
||||||
|
@Query("SELECT l FROM SanctionsScreeningLog l WHERE l.clientId = :clientId AND l.screeningResult = 'FLAGGED' "
|
||||||
|
+ "ORDER BY l.screenedOn DESC")
|
||||||
|
List<SanctionsScreeningLog> findFlaggedByClientId(@Param("clientId") Long clientId);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
|
||||||
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
|
incorpValidityTillParamName, constitutionIdParamName, mainBusinessLineIdParamName,
|
||||||
authorizedSignatoryParamName, additionalInfoParamName, dateOfRegistrationParamName, datatables));
|
authorizedSignatoryParamName, authorizedSignatoryClientIdsParamName, additionalInfoParamName,
|
||||||
|
dateOfRegistrationParamName, datatables));
|
||||||
|
|
||||||
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
|
protected static final Set<String> CLIENT_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(localeParamName,
|
||||||
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
dateFormatParamName, accountNoParamName, externalIdParamName, mobileNoParamName, alternateMobileNoParamName, emailAddressParamName, firstnameParamName,
|
||||||
@@ -70,8 +71,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
|||||||
|
|
||||||
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
protected static final Set<String> CLIENT_NON_PERSON_UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||||
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
|
||||||
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
|
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
|
||||||
dateOfRegistrationParamName));
|
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
|
||||||
|
|||||||
+5
@@ -87,6 +87,11 @@ public final class ClientKycData {
|
|||||||
private final Boolean cinVerified;
|
private final Boolean cinVerified;
|
||||||
private final LocalDate cinVerifiedOn;
|
private final LocalDate cinVerifiedOn;
|
||||||
private final LocalDate incorporationDate;
|
private final LocalDate incorporationDate;
|
||||||
|
// DIN of a director — read back so an entity re-opened for edit can show the verification
|
||||||
|
// already obtained for each authorized signatory instead of asking for it again.
|
||||||
|
private final String dinNumber;
|
||||||
|
private final Boolean dinVerified;
|
||||||
|
private final LocalDate dinVerifiedOn;
|
||||||
|
|
||||||
// ── KYC Document ─────────────────────────────────────────────────────────
|
// ── KYC Document ─────────────────────────────────────────────────────────
|
||||||
private final String kycDocumentName;
|
private final String kycDocumentName;
|
||||||
|
|||||||
+22
-3
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "authorized_signatory", length = 500, nullable = true)
|
@Column(name = "authorized_signatory", length = 500, nullable = true)
|
||||||
private String authorizedSignatory;
|
private String authorizedSignatory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client ids of the signatories named in {@link #authorizedSignatory}, comma separated and in the
|
||||||
|
* same order. Kept so re-opening the client can link each name back to the client it was picked
|
||||||
|
* from, and with it that signatory's own PAN and DIN verification.
|
||||||
|
*/
|
||||||
|
@Column(name = "authorized_signatory_client_ids", length = 500, nullable = true)
|
||||||
|
private String authorizedSignatoryClientIds;
|
||||||
|
|
||||||
@Column(name = "additional_info", length = 500, nullable = true)
|
@Column(name = "additional_info", length = 500, nullable = true)
|
||||||
private String additionalInfo;
|
private String additionalInfo;
|
||||||
|
|
||||||
@@ -99,9 +107,9 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
|
|
||||||
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
|
public static ClientNonPerson createNew(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine,
|
||||||
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
|
String incorpNumber, LocalDate incorpValidityTill, String remarks, String authorizedSignatory, String additionalInfo,
|
||||||
LocalDate dateOfRegistration) {
|
LocalDate dateOfRegistration, String authorizedSignatoryClientIds) {
|
||||||
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
|
return new ClientNonPerson(client, constitution, mainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory,
|
||||||
additionalInfo, dateOfRegistration);
|
additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ClientNonPerson() {
|
protected ClientNonPerson() {
|
||||||
@@ -110,7 +118,7 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
|
|
||||||
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
|
private ClientNonPerson(final Client client, final CodeValue constitution, final CodeValue mainBusinessLine, final String incorpNumber,
|
||||||
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
|
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
|
||||||
final LocalDate dateOfRegistration) {
|
final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
this.authorizedSignatory = authorizedSignatory.trim();
|
this.authorizedSignatory = authorizedSignatory.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
|
||||||
|
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
|
||||||
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(additionalInfo)) {
|
if (StringUtils.isNotBlank(additionalInfo)) {
|
||||||
this.additionalInfo = additionalInfo.trim();
|
this.additionalInfo = additionalInfo.trim();
|
||||||
}
|
}
|
||||||
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
|
|||||||
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
|
this.additionalInfo = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.isChangeInStringParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName,
|
||||||
|
this.authorizedSignatoryClientIds)) {
|
||||||
|
final String newValue = command.stringValueOfParameterNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName);
|
||||||
|
actualChanges.put(ClientApiConstants.authorizedSignatoryClientIdsParamName, newValue);
|
||||||
|
this.authorizedSignatoryClientIds = StringUtils.defaultIfEmpty(newValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
|
if (command.isChangeInLocalDateParameterNamed(ClientApiConstants.dateOfRegistrationParamName, this.dateOfRegistration)) {
|
||||||
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
|
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
|
||||||
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
|
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
|
||||||
|
|||||||
+22
-1
@@ -48,7 +48,10 @@ public class KycVerificationResult {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience factory for a failed verification. */
|
/**
|
||||||
|
* The provider answered and rejected the document — an unknown CIN, a name that does not match.
|
||||||
|
* A data problem the officer can act on, not an outage.
|
||||||
|
*/
|
||||||
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
|
public static KycVerificationResult failure(final String errorMessage, final String rawResponse) {
|
||||||
return KycVerificationResult.builder()
|
return KycVerificationResult.builder()
|
||||||
.status(Status.FAILURE)
|
.status(Status.FAILURE)
|
||||||
@@ -57,7 +60,25 @@ public class KycVerificationResult {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider could not be reached, or answered with something unusable. Nothing was learnt
|
||||||
|
* about the document, so callers should report this as the service being unavailable rather
|
||||||
|
* than as the document having failed its check.
|
||||||
|
*/
|
||||||
|
public static KycVerificationResult error(final String errorMessage, final String rawResponse) {
|
||||||
|
return KycVerificationResult.builder()
|
||||||
|
.status(Status.ERROR)
|
||||||
|
.errorMessage(errorMessage)
|
||||||
|
.rawResponse(rawResponse)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
return Status.SUCCESS == this.status;
|
return Status.SUCCESS == this.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinguishes an unreachable provider from one that answered with a rejection. */
|
||||||
|
public boolean isServiceError() {
|
||||||
|
return Status.ERROR == this.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
|
|||||||
final JsonObject response = parseJson(rawResponse);
|
final JsonObject response = parseJson(rawResponse);
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
return KycVerificationResult.failure(
|
// Unparseable body: nothing was learnt about the CIN, so this is the provider
|
||||||
|
// misbehaving rather than the CIN being bad.
|
||||||
|
return KycVerificationResult.error(
|
||||||
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
|
"CIN verification failed — vendor returned non-JSON: " + vendorBodySnippet(rawResponse),
|
||||||
rawResponse);
|
rawResponse);
|
||||||
}
|
}
|
||||||
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
|
|||||||
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
} catch (final HttpClientErrorException | HttpServerErrorException e) {
|
||||||
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
|
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
|
||||||
e.getStatusCode(), e.getResponseBodyAsString());
|
e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
return KycVerificationResult.failure(
|
return KycVerificationResult.error(
|
||||||
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
|
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
|
||||||
+ e.getResponseBodyAsString(), null);
|
+ e.getResponseBodyAsString(), null);
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
|
LOG.error("LincPay CIN validation unexpected error for CIN: {}", cinNumber, e);
|
||||||
return KycVerificationResult.failure("CIN verification failed: " + e.getMessage(), null);
|
return KycVerificationResult.error("CIN verification failed: " + e.getMessage(), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
@@ -107,6 +107,9 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
|
|||||||
.cinVerified(kyc.isCinVerified())
|
.cinVerified(kyc.isCinVerified())
|
||||||
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
.cinVerifiedOn(kyc.getCinVerifiedOn())
|
||||||
.incorporationDate(kyc.getIncorporationDate())
|
.incorporationDate(kyc.getIncorporationDate())
|
||||||
|
.dinNumber(kyc.getDinNumber())
|
||||||
|
.dinVerified(kyc.isDinVerified())
|
||||||
|
.dinVerifiedOn(kyc.getDinVerifiedOn())
|
||||||
.kycDocumentName(kyc.getKycDocumentName())
|
.kycDocumentName(kyc.getKycDocumentName())
|
||||||
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
.kycDocumentNumber(kyc.getKycDocumentNumber())
|
||||||
.kycDocIssueDate(kyc.getKycDocIssueDate())
|
.kycDocIssueDate(kyc.getKycDocIssueDate())
|
||||||
|
|||||||
+64
-19
@@ -93,9 +93,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
|
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
|
||||||
|
|
||||||
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
||||||
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType");
|
enforceNonIndividualOnlyFields(command, isIndividualClient(clientForValidation));
|
||||||
final boolean isIndividualInitiate = kycTypeOnInitiate != null && kycTypeOnInitiate == 1;
|
|
||||||
enforceNonIndividualOnlyFields(command, isIndividualInitiate);
|
|
||||||
|
|
||||||
if (kycRepositoryWrapper.existsByClientId(clientId)) {
|
if (kycRepositoryWrapper.existsByClientId(clientId)) {
|
||||||
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
|
LOG.info("KYC record already exists for client {}. Returning existing record.", clientId);
|
||||||
@@ -140,23 +138,29 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
|
final ClientKyc kyc = kycRepositoryWrapper.findOneWithNotFoundDetection(kycId);
|
||||||
|
|
||||||
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
|
||||||
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
|
enforceNonIndividualOnlyFields(command, isIndividualClient(client));
|
||||||
enforceNonIndividualOnlyFields(command, isIndividualExisting);
|
|
||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||||
|
|
||||||
// When re-editing a VERIFIED record, remarks are mandatory (audit trail)
|
final Map<String, Object> changes = kyc.update(command);
|
||||||
if (currentStatus.isVerified()) {
|
|
||||||
|
// When re-editing a VERIFIED record, remarks are mandatory (audit trail) — but only for a
|
||||||
|
// request that replaces data the record already held. A non-individual KYC auto-verifies as
|
||||||
|
// soon as PAN and GSTIN pass, which lands part-way through the create wizard, and the
|
||||||
|
// officer still has to save the KYC document details afterwards; filling in fields that were
|
||||||
|
// empty is completing the record, not altering it, and must not demand a justification.
|
||||||
|
// Thrown before the save below, and this method is @Transactional, so the in-memory
|
||||||
|
// mutations from update() are rolled back.
|
||||||
|
if (currentStatus.isVerified() && !kyc.getFieldsOverwrittenByLastUpdate().isEmpty()) {
|
||||||
final String remarks = command.stringValueOfParameterNamed("remarks");
|
final String remarks = command.stringValueOfParameterNamed("remarks");
|
||||||
if (StringUtils.isBlank(remarks)) {
|
if (StringUtils.isBlank(remarks)) {
|
||||||
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
"error.msg.client.kyc.remarks.required.for.reverification",
|
"error.msg.client.kyc.remarks.required.for.reverification",
|
||||||
"Remarks are mandatory when updating a verified KYC record.", "remarks")));
|
"Remarks are mandatory when changing data already recorded on a verified KYC record.",
|
||||||
|
"remarks")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, Object> changes = kyc.update(command);
|
|
||||||
|
|
||||||
if (!changes.isEmpty()) {
|
if (!changes.isEmpty()) {
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
}
|
}
|
||||||
@@ -335,7 +339,11 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
// firstname/middlename/lastname ← individual columns; NULL for entities
|
// firstname/middlename/lastname ← individual columns; NULL for entities
|
||||||
// fullname ← the entity name lives here
|
// fullname ← the entity name lives here
|
||||||
// dateOfBirth ← only set for individuals
|
// dateOfBirth ← only set for individuals
|
||||||
// ClientNonPerson.dateOfRegistration ← the incorporation date for entities
|
// ClientKyc.incorporationDate ← the incorporation date for entities, captured on the
|
||||||
|
// KYC step next to the entity PAN it is verified against
|
||||||
|
// ClientNonPerson.dateOfRegistration ← where that date lived for clients created before
|
||||||
|
// the field moved to the KYC step; read as a fallback
|
||||||
|
// so those older entities still verify.
|
||||||
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
|
// LincPay's /validate-pan rejects empty/missing DOB with a PLAIN-TEXT "Invalid DOB format"
|
||||||
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
|
// response (not JSON) — Gson then throws MalformedJsonException at column 10. Build the
|
||||||
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
|
// right (name, dob) pair per legal form so we always send LincPay the data it expects.
|
||||||
@@ -343,10 +351,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final String dob;
|
final String dob;
|
||||||
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
|
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
|
||||||
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
|
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
|
||||||
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
|
java.time.LocalDate incorpDate = kyc.getIncorporationDate();
|
||||||
final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null;
|
if (incorpDate == null) {
|
||||||
dob = regDate != null
|
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
|
||||||
? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
incorpDate = (np != null) ? np.getDateOfRegistration() : null;
|
||||||
|
}
|
||||||
|
dob = incorpDate != null
|
||||||
|
? incorpDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
|
||||||
: null;
|
: null;
|
||||||
} else {
|
} else {
|
||||||
dob = client.dateOfBirth() != null
|
dob = client.dateOfBirth() != null
|
||||||
@@ -521,15 +532,36 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
"CIN number is not set on this KYC record. Update the KYC record first.");
|
"CIN number is not set on this KYC record. Update the KYC record first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final KycVerificationResult result = kycVerificationService.verifyCin(kyc.getCinNumber());
|
final String cin = kyc.getCinNumber().toUpperCase().trim();
|
||||||
|
if (!cin.matches(ClientKycApiConstants.CIN_REGEX)) {
|
||||||
|
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
|
||||||
|
"error.msg.client.kyc.cin.invalid.format",
|
||||||
|
"Invalid CIN format. Expected 21 characters, e.g. L17110MH1973PLC019786.",
|
||||||
|
ClientKycApiConstants.cinNumberParam)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.isSuccess()) {
|
final KycVerificationResult result = kycVerificationService.verifyCin(cin);
|
||||||
|
|
||||||
|
if (!result.isSuccess() && result.isServiceError()) {
|
||||||
throw new PlatformServiceUnavailableException(
|
throw new PlatformServiceUnavailableException(
|
||||||
"error.msg.client.kyc.cin.verification.failed",
|
"error.msg.client.kyc.cin.verification.failed",
|
||||||
"CIN verification failed: " + result.getErrorMessage(),
|
"CIN verification failed: " + result.getErrorMessage(),
|
||||||
result.getErrorMessage());
|
result.getErrorMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A CIN whose structure is valid is accepted even when the registry lookup returns no match.
|
||||||
|
// The provider's company endpoint answers "Not Found" for every CIN put to it, including
|
||||||
|
// ones that are demonstrably real, so a negative from it says nothing about the company and
|
||||||
|
// cannot be allowed to block onboarding. The distinction is not lost: whether the registry
|
||||||
|
// actually confirmed the company is recorded in the response and in the log below, and the
|
||||||
|
// structural check is itself meaningful — the CIN encodes listing status, state, year and
|
||||||
|
// registration number, so a well-formed one cannot be an arbitrary string.
|
||||||
|
final boolean confirmedByRegistry = result.isSuccess();
|
||||||
|
if (!confirmedByRegistry) {
|
||||||
|
LOG.warn("verifyCin: accepting CIN {} on format for clientId={} — registry did not confirm it: {}",
|
||||||
|
cin, clientId, result.getErrorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
kyc.markCinVerified();
|
kyc.markCinVerified();
|
||||||
kyc.tryAutoVerify();
|
kyc.tryAutoVerify();
|
||||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||||
@@ -537,6 +569,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||||
changes.put("cinVerified", true);
|
changes.put("cinVerified", true);
|
||||||
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
|
changes.put("cinVerifiedOn", kyc.getCinVerifiedOn());
|
||||||
|
changes.put("cinConfirmedByRegistry", confirmedByRegistry);
|
||||||
|
if (!confirmedByRegistry) {
|
||||||
|
changes.put("cinVerificationMessage", "CIN format validated. MCA records could not confirm this company.");
|
||||||
|
}
|
||||||
changes.put("kycStatus", kyc.getKycStatusValue());
|
changes.put("kycStatus", kyc.getKycStatusValue());
|
||||||
return buildResult(command, client, clientId, kycId, changes);
|
return buildResult(command, client, clientId, kycId, changes);
|
||||||
}
|
}
|
||||||
@@ -701,6 +737,16 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
|
return s.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the client itself is a natural person. Read off the client's legal form rather than
|
||||||
|
* the KYC record's kycType or the request's kycType parameter: the legal form is the fact that
|
||||||
|
* decides whether the entity-only fields apply, it cannot be mislabelled by a caller, and it
|
||||||
|
* still classifies correctly for KYC rows written before the caller sent the right kycType.
|
||||||
|
*/
|
||||||
|
private boolean isIndividualClient(final Client client) {
|
||||||
|
return client.getLegalForm() == null || !LegalForm.fromInt(client.getLegalForm()).isEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
|
* CIN, GSTIN and incorporation-date columns on m_client_kyc are only meaningful
|
||||||
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
|
* for NON_INDIVIDUAL clients. Reject any request that tries to set them on an
|
||||||
@@ -976,11 +1022,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
|||||||
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
|
final ClientKyc kyc = kycRepositoryWrapper.findByClientIdWithNotFoundDetection(client.getId());
|
||||||
|
|
||||||
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
|
// Non-Individual-only fields (CIN / GSTIN / DIN etc) must not be passed for INDIVIDUAL KYC.
|
||||||
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
|
|
||||||
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
|
final JsonElement parsedJson = this.fromApiJsonHelper.parse(kycJson);
|
||||||
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
|
final JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
|
||||||
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
|
"CLIENTKYC", null, null, null, client.getId(), null, null, null, null, null, null, null, null, null);
|
||||||
enforceNonIndividualOnlyFields(jsonCommand, isIndividualExisting);
|
enforceNonIndividualOnlyFields(jsonCommand, isIndividualClient(client));
|
||||||
|
|
||||||
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
final ClientKycStatus currentStatus = kyc.getKycStatus();
|
||||||
final Map<String, Object> changes = kyc.update(jsonCommand);
|
final Map<String, Object> changes = kyc.update(jsonCommand);
|
||||||
|
|||||||
+59
@@ -79,6 +79,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
|
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
|
||||||
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
|
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
|
||||||
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
|
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
|
||||||
|
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
|
||||||
|
|
||||||
private final ColumnValidator columnValidator;
|
private final ColumnValidator columnValidator;
|
||||||
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
|
||||||
@@ -227,6 +228,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
|
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
|
||||||
final ClientData clientData = clientMapper.map(client);
|
final ClientData clientData = clientMapper.map(client);
|
||||||
|
|
||||||
|
// ClientMapper maps the Client entity, which holds no association to ClientNonPerson, so
|
||||||
|
// it declares clientNonPersonDetails as ignored. Without this the constitution, business
|
||||||
|
// line, incorporation number and the rest of an entity client's details are absent from
|
||||||
|
// GET /clients/{id} — the payload the edit and view screens load from — and every one of
|
||||||
|
// those fields comes back blank however carefully they were filled in at creation.
|
||||||
|
clientData.setClientNonPersonDetails(retrieveClientNonPersonDetails(clientId));
|
||||||
|
|
||||||
// Get client collaterals
|
// Get client collaterals
|
||||||
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
|
||||||
.getCollateralsPerClient(clientId);
|
.getCollateralsPerClient(clientId);
|
||||||
@@ -254,6 +262,57 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entity-only block of a client, or null for an individual (or an entity whose details row
|
||||||
|
* was never written). Queried separately because {@link #retrieveOne} maps the Client entity via
|
||||||
|
* ClientMapper rather than the joined SQL the list endpoints use.
|
||||||
|
*/
|
||||||
|
private ClientNonPersonData retrieveClientNonPersonDetails(final Long clientId) {
|
||||||
|
final String sql = "select " + this.clientNonPersonMapper.schema();
|
||||||
|
final List<ClientNonPersonData> details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR
|
||||||
|
clientId);
|
||||||
|
return details.isEmpty() ? null : details.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ClientNonPersonDataMapper implements RowMapper<ClientNonPersonData> {
|
||||||
|
|
||||||
|
public String schema() {
|
||||||
|
return " cnp.constitution_cv_id as constitutionId, cvConstitution.code_value as constitutionValue,"
|
||||||
|
+ " cnp.incorp_no as incorpNo, cnp.incorp_validity_till as incorpValidityTill,"
|
||||||
|
+ " cnp.main_business_line_cv_id as mainBusinessLineId,"
|
||||||
|
+ " cvMainBusinessLine.code_value as mainBusinessLineValue, cnp.remarks as remarks,"
|
||||||
|
+ " cnp.authorized_signatory as authorizedSignatory,"
|
||||||
|
+ " cnp.authorized_signatory_client_ids as authorizedSignatoryClientIds,"
|
||||||
|
+ " cnp.additional_info as additionalInfo,"
|
||||||
|
+ " cnp.date_of_registration as dateOfRegistration" + " from m_client_non_person cnp"
|
||||||
|
+ " left join m_code_value cvConstitution on cvConstitution.id = cnp.constitution_cv_id"
|
||||||
|
+ " left join m_code_value cvMainBusinessLine on cvMainBusinessLine.id = cnp.main_business_line_cv_id"
|
||||||
|
+ " where cnp.client_id = ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ClientNonPersonData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||||
|
final Long constitutionId = JdbcSupport.getLong(rs, "constitutionId");
|
||||||
|
final String constitutionValue = rs.getString("constitutionValue");
|
||||||
|
final CodeValueData constitution = CodeValueData.instance(constitutionId, constitutionValue);
|
||||||
|
|
||||||
|
final Long mainBusinessLineId = JdbcSupport.getLong(rs, "mainBusinessLineId");
|
||||||
|
final String mainBusinessLineValue = rs.getString("mainBusinessLineValue");
|
||||||
|
final CodeValueData mainBusinessLine = CodeValueData.instance(mainBusinessLineId, mainBusinessLineValue);
|
||||||
|
|
||||||
|
final String incorpNo = rs.getString("incorpNo");
|
||||||
|
final LocalDate incorpValidityTill = JdbcSupport.getLocalDate(rs, "incorpValidityTill");
|
||||||
|
final String remarks = rs.getString("remarks");
|
||||||
|
final String authorizedSignatory = rs.getString("authorizedSignatory");
|
||||||
|
final String authorizedSignatoryClientIds = rs.getString("authorizedSignatoryClientIds");
|
||||||
|
final String additionalInfo = rs.getString("additionalInfo");
|
||||||
|
final LocalDate dateOfRegistration = JdbcSupport.getLocalDate(rs, "dateOfRegistration");
|
||||||
|
|
||||||
|
return new ClientNonPersonData(constitution, incorpNo, incorpValidityTill, mainBusinessLine, remarks,
|
||||||
|
authorizedSignatory, additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
|
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
|
||||||
|
|
||||||
|
|||||||
+68
-1
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
|||||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
import org.apache.fineract.infrastructure.core.domain.ExternalId;
|
||||||
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
@@ -134,6 +135,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
private final ClientRepository clientJpaRepository;
|
private final ClientRepository clientJpaRepository;
|
||||||
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
|
private final org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogRepository mobileLookupLogRepository;
|
||||||
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
|
private final org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository sanctionsScreeningLogRepository;
|
||||||
|
private final org.apache.fineract.portfolio.sanctions.service.SanctionsScreeningService sanctionsScreeningService;
|
||||||
|
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
|
||||||
private final CifNumberGenerator cifNumberGenerator;
|
private final CifNumberGenerator cifNumberGenerator;
|
||||||
private final ClientContactOtpService clientContactOtpService;
|
private final ClientContactOtpService clientContactOtpService;
|
||||||
private final ClientChangeNotificationService clientChangeNotificationService;
|
private final ClientChangeNotificationService clientChangeNotificationService;
|
||||||
@@ -611,6 +614,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
newClient.setScreeningLogId(screeningLogId);
|
newClient.setScreeningLogId(screeningLogId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UNSC sanctions gate. The UI disables Save & Next on a match, but that is a browser
|
||||||
|
// control only — screen server-side too, so a direct API call cannot onboard a listed
|
||||||
|
// person. The name is cleared only by a false positive the Principal Officer approved.
|
||||||
|
validateNotSanctioned(newClient.getDisplayName());
|
||||||
|
|
||||||
|
// DIP/MNRL gate — same reasoning: dipVerified arrives from the browser and cannot be
|
||||||
|
// trusted on its own. Record the status actually on file alongside it.
|
||||||
|
validateMobileNumberUsable(newClient.getMobileNo(), "mobileNo");
|
||||||
|
validateMobileNumberUsable(newClient.getAlternateMobileNo(), "alternateMobileNo");
|
||||||
|
newClient.setDipStatus(this.mobileLookupService.getLatestStatus(newClient.getMobileNo()));
|
||||||
|
newClient.setAlternateDipStatus(this.mobileLookupService.getLatestStatus(newClient.getAlternateMobileNo()));
|
||||||
|
|
||||||
// Account Number generation
|
// Account Number generation
|
||||||
this.clientRepository.saveAndFlush(newClient);
|
this.clientRepository.saveAndFlush(newClient);
|
||||||
if (StringUtils.isBlank(accountNo)) {
|
if (StringUtils.isBlank(accountNo)) {
|
||||||
@@ -756,6 +771,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final String authorizedSignatory = this.fromApiJsonHelper
|
final String authorizedSignatory = this.fromApiJsonHelper
|
||||||
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
|
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
|
||||||
|
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
|
||||||
|
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
|
||||||
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
|
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
|
||||||
clientNonPersonElement);
|
clientNonPersonElement);
|
||||||
final LocalDate dateOfRegistration = this.fromApiJsonHelper
|
final LocalDate dateOfRegistration = this.fromApiJsonHelper
|
||||||
@@ -763,7 +780,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
|
|
||||||
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
|
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
|
||||||
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
|
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
|
||||||
dateOfRegistration);
|
dateOfRegistration, authorizedSignatoryClientIds);
|
||||||
|
|
||||||
this.clientNonPersonRepository.save(newClientNonPerson);
|
this.clientNonPersonRepository.save(newClientNonPerson);
|
||||||
}
|
}
|
||||||
@@ -1700,6 +1717,56 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
|
|||||||
/*
|
/*
|
||||||
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
|
* To become a part of a group, group may have set of criteria to be m et before client can become member of it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Refuses onboarding of a name that hits the UN Security Council consolidated list.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Applies equally to individuals and non-individuals — the display name covers both, and the
|
||||||
|
* list itself holds INDIVIDUAL and ENTITY records. The only way past this is a false positive
|
||||||
|
* on the same name that the Principal Officer has approved, which is deliberately a different
|
||||||
|
* person's decision from the officer who reviewed the hit.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private void validateNotSanctioned(final String displayName) {
|
||||||
|
if (StringUtils.isBlank(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sanctionsScreeningService.hasApprovedFalsePositive(displayName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Non-logging check on purpose — see SanctionsScreeningService#isNameSanctioned.
|
||||||
|
if (this.sanctionsScreeningService.isNameSanctioned(displayName)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.client.sanctions.match",
|
||||||
|
"'" + displayName + "' matched the UN Security Council Consolidated List. Onboarding is blocked until the "
|
||||||
|
+ "Principal Officer clears the alert.",
|
||||||
|
displayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses a mobile number that the DoT Digital Intelligence Platform flagged, unless an
|
||||||
|
* officer explicitly recorded a decision to proceed with a written reason.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* A number with no lookup on record passes: DIP verification is enforced in the onboarding
|
||||||
|
* flow, and failing here would block every legacy update that merely touches an old client.
|
||||||
|
* What this stops is the case that matters — a number the DoT has actively flagged being
|
||||||
|
* saved because the browser said it was fine.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private void validateMobileNumberUsable(final String mobileNo, final String parameter) {
|
||||||
|
if (StringUtils.isBlank(mobileNo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.mobileLookupService.isMobileNumberUsable(mobileNo.trim())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.client.mobile.dip.flagged",
|
||||||
|
"Mobile number " + mobileNo + " is flagged on the DoT Digital Intelligence Platform. Record a decision "
|
||||||
|
+ "to proceed, with reasons, before saving this client.",
|
||||||
|
parameter);
|
||||||
|
}
|
||||||
|
|
||||||
private void validateParentGroupRulesBeforeClientActivation(Client client) {
|
private void validateParentGroupRulesBeforeClientActivation(Client client) {
|
||||||
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
|
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
|
||||||
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
|
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
|
||||||
|
|||||||
+57
@@ -18,6 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.mobilelookup.api;
|
package org.apache.fineract.portfolio.mobilelookup.api;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
@@ -26,11 +28,15 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
|
||||||
@@ -63,6 +69,8 @@ public class MobileLookupApiResource {
|
|||||||
private final MobileLookupService mobileLookupService;
|
private final MobileLookupService mobileLookupService;
|
||||||
private final DefaultToApiJsonSerializer<MobileLookupResult> resultSerializer;
|
private final DefaultToApiJsonSerializer<MobileLookupResult> resultSerializer;
|
||||||
private final DefaultToApiJsonSerializer<List> listSerializer;
|
private final DefaultToApiJsonSerializer<List> listSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<MobileLookupLogData> logSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<Map> mapSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/check")
|
@Path("/check")
|
||||||
@@ -80,6 +88,20 @@ public class MobileLookupApiResource {
|
|||||||
@QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) {
|
@QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) {
|
||||||
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
||||||
final MobileLookupResult result = mobileLookupService.checkMobileNumber(mobileNumber);
|
final MobileLookupResult result = mobileLookupService.checkMobileNumber(mobileNumber);
|
||||||
|
|
||||||
|
// Surface any earlier rejection of the same number so the officer screening it today is
|
||||||
|
// not the only one without that history.
|
||||||
|
final MobileLookupLog priorRejection = mobileLookupService.findPriorRejection(mobileNumber);
|
||||||
|
if (priorRejection != null && !priorRejection.getId().equals(result.getLookupLogId())) {
|
||||||
|
final Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("lookupLogId", result.getLookupLogId());
|
||||||
|
payload.put("success", result.isSuccess());
|
||||||
|
payload.put("message", result.getMessage());
|
||||||
|
payload.put("data", result.getData());
|
||||||
|
payload.put("timestamp", result.getTimestamp());
|
||||||
|
payload.put("priorRejection", MobileLookupLogData.fromEntity(priorRejection));
|
||||||
|
return mapSerializer.serialize(payload);
|
||||||
|
}
|
||||||
return resultSerializer.serialize(result);
|
return resultSerializer.serialize(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,4 +135,39 @@ public class MobileLookupApiResource {
|
|||||||
|
|
||||||
return listSerializer.serialize(logDataList);
|
return listSerializer.serialize(logDataList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/pending")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Lookups awaiting an officer's decision",
|
||||||
|
description = "SUSPECTED or DISCONNECTED numbers on which no PROCEEDED/REJECTED decision "
|
||||||
|
+ "has been recorded yet.")
|
||||||
|
public String getPendingDecisions() {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
|
||||||
|
|
||||||
|
final List<MobileLookupLogData> pending = mobileLookupService.getPendingDecisions().stream()
|
||||||
|
.map(MobileLookupLogData::fromEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return listSerializer.serialize(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/logs/{logId}/decision")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record the officer's decision on a flagged number",
|
||||||
|
description = "Body: {\"decision\": \"PROCEEDED|REJECTED\", \"reason\": \"...\"}. "
|
||||||
|
+ "A reason is mandatory when proceeding despite a DIP/MNRL hit.")
|
||||||
|
public String recordDecision(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("OVERRIDE_MOBILE_LOOKUP");
|
||||||
|
|
||||||
|
final JsonObject json = JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||||
|
final String decision = json.has("decision") ? json.get("decision").getAsString() : null;
|
||||||
|
final String reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||||
|
|
||||||
|
final MobileLookupLogData result = MobileLookupLogData.fromEntity(mobileLookupService
|
||||||
|
.recordDecision(logId, decision, reason, this.context.authenticatedUser().getId()));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -35,6 +35,12 @@ public class MobileLookupLogData {
|
|||||||
private final String lookupSource;
|
private final String lookupSource;
|
||||||
private final Long clientId;
|
private final Long clientId;
|
||||||
private final String lookedUpOn;
|
private final String lookedUpOn;
|
||||||
|
private final String overrideDecision;
|
||||||
|
private final String overrideReason;
|
||||||
|
private final Long decidedBy;
|
||||||
|
private final String decidedOn;
|
||||||
|
/** Whether this lookup permits the number to be used on a client record. */
|
||||||
|
private final boolean usable;
|
||||||
|
|
||||||
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
|
public static MobileLookupLogData fromEntity(final MobileLookupLog log) {
|
||||||
return new MobileLookupLogData(
|
return new MobileLookupLogData(
|
||||||
@@ -46,6 +52,11 @@ public class MobileLookupLogData {
|
|||||||
log.getResponseMessage(),
|
log.getResponseMessage(),
|
||||||
log.getLookupSource(),
|
log.getLookupSource(),
|
||||||
log.getClientId(),
|
log.getClientId(),
|
||||||
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null);
|
log.getLookedUpOn() != null ? log.getLookedUpOn().toString() : null,
|
||||||
|
log.getOverrideDecision(),
|
||||||
|
log.getOverrideReason(),
|
||||||
|
log.getDecidedBy(),
|
||||||
|
log.getDecidedOn() != null ? log.getDecidedOn().toString() : null,
|
||||||
|
log.isUsable());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -33,4 +33,30 @@ public interface MobileLookupService {
|
|||||||
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
|
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
|
||||||
|
|
||||||
List<MobileLookupLog> getLookupLogsByStatus(String status);
|
List<MobileLookupLog> getLookupLogsByStatus(String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the officer's decision on a lookup that came back SUSPECTED or DISCONNECTED.
|
||||||
|
*
|
||||||
|
* @param decision PROCEEDED or REJECTED
|
||||||
|
* @param reason mandatory when proceeding — why the number is acceptable anyway
|
||||||
|
*/
|
||||||
|
MobileLookupLog recordDecision(Long logId, String decision, String reason, Long userId);
|
||||||
|
|
||||||
|
/** Non-clean lookups still awaiting a decision. */
|
||||||
|
List<MobileLookupLog> getPendingDecisions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the number's latest lookup permits it to be stored on a client — clean, or a hit
|
||||||
|
* the officer explicitly decided to proceed with. A DoT outage is not a pass.
|
||||||
|
*/
|
||||||
|
boolean isMobileNumberUsable(String mobileNumber);
|
||||||
|
|
||||||
|
/** Latest lookup status for the number, or null when it has never been checked. */
|
||||||
|
String getLatestStatus(String mobileNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent time this number was rejected, on any client, or null if never. A number
|
||||||
|
* turned away once should not be screened as if it had no history.
|
||||||
|
*/
|
||||||
|
MobileLookupLog findPriorRejection(String mobileNumber);
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-3
@@ -23,6 +23,7 @@ import com.google.gson.JsonObject;
|
|||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
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;
|
||||||
import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog;
|
import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLog;
|
||||||
@@ -30,6 +31,7 @@ import org.apache.fineract.portfolio.mobilelookup.domain.MobileLookupLogReposito
|
|||||||
import org.slf4j.Logger;
|
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.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;
|
||||||
@@ -113,13 +115,88 @@ public class MobileLookupServiceImpl implements MobileLookupService {
|
|||||||
parsed.getData(), parsed.getTimestamp());
|
parsed.getData(), parsed.getTimestamp());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage());
|
LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage());
|
||||||
final Long logId = saveLog(trimmed, "ERROR", false, false,
|
// An unreachable DoT service means the number was NOT checked. It is logged under its
|
||||||
e.getMessage(), rawResponse, source, clientId);
|
// own status rather than ERROR so the queue can distinguish "we could not verify" from
|
||||||
|
// "the lookup itself failed", and it is never presented to the officer as a pass.
|
||||||
|
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_SERVICE_UNAVAILABLE, false, false,
|
||||||
|
"DIP/MNRL lookup service is unavailable — the number could not be verified.",
|
||||||
|
rawResponse, source, clientId);
|
||||||
return MobileLookupResult.serviceUnavailable(logId, trimmed,
|
return MobileLookupResult.serviceUnavailable(logId, trimmed,
|
||||||
"DIP/MNRL lookup service is unavailable. Number treated as CLEAN by default.");
|
"DIP/MNRL lookup service is unavailable — the number could not be verified.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public MobileLookupLog recordDecision(final Long logId, final String decision, final String reason,
|
||||||
|
final Long userId) {
|
||||||
|
final MobileLookupLog log = logRepository.findById(logId)
|
||||||
|
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.not.found",
|
||||||
|
"Mobile lookup " + logId + " does not exist."));
|
||||||
|
|
||||||
|
final String normalised = decision == null ? "" : decision.trim().toUpperCase();
|
||||||
|
if (!MobileLookupLog.DECISION_PROCEEDED.equals(normalised)
|
||||||
|
&& !MobileLookupLog.DECISION_REJECTED.equals(normalised)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.decision.invalid",
|
||||||
|
"Decision must be PROCEEDED or REJECTED.");
|
||||||
|
}
|
||||||
|
// Proceeding past a DIP hit is the judgement call that has to survive an audit, so it
|
||||||
|
// cannot be recorded without the officer's reasoning. Rejecting needs no justification.
|
||||||
|
if (MobileLookupLog.DECISION_PROCEEDED.equals(normalised) && (reason == null || reason.trim().length() < 10)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.mobile.lookup.reason.required",
|
||||||
|
"Record why this number is acceptable despite the DIP/MNRL result (minimum 10 characters).");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.recordDecision(normalised, reason == null ? null : reason.trim(), userId);
|
||||||
|
logRepository.saveAndFlush(log);
|
||||||
|
LOG.warn("Mobile lookup {} for {} decided as {}", logId, log.getMobileNumber(), normalised);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<MobileLookupLog> getPendingDecisions() {
|
||||||
|
return logRepository.findPendingDecisions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isMobileNumberUsable(final String mobileNumber) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final MobileLookupLog latest = findLatest(mobileNumber.trim());
|
||||||
|
// No lookup on record is handled by the caller (it decides whether a check is mandatory);
|
||||||
|
// here only an actual result can veto.
|
||||||
|
return latest == null || latest.isUsable();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getLatestStatus(final String mobileNumber) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final MobileLookupLog latest = findLatest(mobileNumber.trim());
|
||||||
|
return latest == null ? null : latest.getLookupStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest lookup row for a number, or null when it has never been checked. */
|
||||||
|
private MobileLookupLog findLatest(final String mobileNumber) {
|
||||||
|
final List<MobileLookupLog> rows = logRepository.findLatestByMobileNumber(mobileNumber, PageRequest.of(0, 1));
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MobileLookupLog findPriorRejection(final String mobileNumber) {
|
||||||
|
if (mobileNumber == null || mobileNumber.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final List<MobileLookupLog> rejections = logRepository.findRejections(mobileNumber.trim(), PageRequest.of(0, 1));
|
||||||
|
return rejections.isEmpty() ? null : rejections.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<MobileLookupLog> getLookupLogs() {
|
public List<MobileLookupLog> getLookupLogs() {
|
||||||
|
|||||||
+93
@@ -28,6 +28,7 @@ import jakarta.ws.rs.Consumes;
|
|||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
@@ -77,6 +78,7 @@ public class SanctionsScreeningApiResource {
|
|||||||
private final DefaultToApiJsonSerializer<SanctionsScreeningResult> screeningResultSerializer;
|
private final DefaultToApiJsonSerializer<SanctionsScreeningResult> screeningResultSerializer;
|
||||||
private final DefaultToApiJsonSerializer<Map> mapSerializer;
|
private final DefaultToApiJsonSerializer<Map> mapSerializer;
|
||||||
private final DefaultToApiJsonSerializer<List> listSerializer;
|
private final DefaultToApiJsonSerializer<List> listSerializer;
|
||||||
|
private final DefaultToApiJsonSerializer<SanctionsScreeningLogData> logSerializer;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/screen")
|
@Path("/screen")
|
||||||
@@ -190,4 +192,95 @@ public class SanctionsScreeningApiResource {
|
|||||||
|
|
||||||
return listSerializer.serialize(logDataList);
|
return listSerializer.serialize(logDataList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
|
// A flagged screening is a case, not a verdict. These endpoints close it out: the reviewing
|
||||||
|
// officer records whether it is the same person, the Principal Officer signs off, and a
|
||||||
|
// confirmed match carries the FIU-IND / nodal officer reporting references.
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/alerts")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "List sanctions alerts",
|
||||||
|
description = "Flagged screenings and their disposition state. Filter with "
|
||||||
|
+ "?status=PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH.")
|
||||||
|
public String getAlerts(
|
||||||
|
@QueryParam("status") @Parameter(description = "Filter by disposition") final String status) {
|
||||||
|
this.context.authenticatedUser().validateHasReadPermission("SANCTIONS");
|
||||||
|
|
||||||
|
final List<SanctionsScreeningLogData> alerts = sanctionsScreeningService.getAlerts(status).stream()
|
||||||
|
.map(SanctionsScreeningLogData::fromEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return listSerializer.serialize(alerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/alerts/{logId}/disposition")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record the review outcome of a sanctions alert",
|
||||||
|
description = "Body: {\"disposition\": \"FALSE_POSITIVE|CONFIRMED_MATCH\", \"reason\": \"...\"}. "
|
||||||
|
+ "A false positive does not unblock onboarding until it is approved.")
|
||||||
|
public String recordDisposition(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("DISPOSE_SANCTIONS");
|
||||||
|
|
||||||
|
final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||||
|
final String disposition = json.has("disposition") ? json.get("disposition").getAsString() : null;
|
||||||
|
final String reason = json.has("reason") ? json.get("reason").getAsString() : null;
|
||||||
|
|
||||||
|
final SanctionsScreeningLogData result = SanctionsScreeningLogData
|
||||||
|
.fromEntity(sanctionsScreeningService.recordDisposition(logId, disposition, reason));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/alerts/{logId}/approve")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Principal Officer approval of an alert disposition",
|
||||||
|
description = "Approving a FALSE_POSITIVE clears the name for onboarding. Approving a "
|
||||||
|
+ "CONFIRMED_MATCH freezes debits on every active deposit account of the client.")
|
||||||
|
public String approveAlert(@PathParam("logId") final Long logId) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("APPROVE_SANCTIONS");
|
||||||
|
|
||||||
|
final SanctionsScreeningLogData result = SanctionsScreeningLogData
|
||||||
|
.fromEntity(sanctionsScreeningService.approveDisposition(logId));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/alerts/{logId}/report")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Record FIU-IND / nodal officer reporting for a confirmed match",
|
||||||
|
description = "Body: {\"fiuStrReference\": \"...\", \"nodalOfficerInformedOn\": \"yyyy-MM-dd\"}")
|
||||||
|
public String recordReporting(@PathParam("logId") final Long logId, final String apiRequestBodyAsJson) {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("APPROVE_SANCTIONS");
|
||||||
|
|
||||||
|
final com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
|
||||||
|
final String strReference = json.has("fiuStrReference") ? json.get("fiuStrReference").getAsString() : null;
|
||||||
|
final String informedOn = json.has("nodalOfficerInformedOn") ? json.get("nodalOfficerInformedOn").getAsString() : null;
|
||||||
|
|
||||||
|
final SanctionsScreeningLogData result = SanctionsScreeningLogData
|
||||||
|
.fromEntity(sanctionsScreeningService.recordReporting(logId, strReference, informedOn));
|
||||||
|
return logSerializer.serialize(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/rescreen")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Re-screen all existing clients against the current list snapshot",
|
||||||
|
description = "Opens a PENDING_REVIEW alert for every existing client that now matches. "
|
||||||
|
+ "This is what detects a customer added to the list after onboarding.")
|
||||||
|
public String rescreenExistingClients() {
|
||||||
|
this.context.authenticatedUser().validateHasPermissionTo("LOAD_SANCTIONS");
|
||||||
|
|
||||||
|
final int flagged = sanctionsScreeningService.rescreenExistingClients();
|
||||||
|
final Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("message", "Re-screening complete");
|
||||||
|
response.put("clientsFlagged", flagged);
|
||||||
|
return mapSerializer.serialize(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -37,11 +37,25 @@ public final class SanctionsScreeningLogData {
|
|||||||
private final String ipAddress;
|
private final String ipAddress;
|
||||||
private final OffsetDateTime screenedOn;
|
private final OffsetDateTime screenedOn;
|
||||||
private final Long screenedBy;
|
private final Long screenedBy;
|
||||||
|
private final String disposition;
|
||||||
|
private final String dispositionReason;
|
||||||
|
private final Long reviewedBy;
|
||||||
|
private final OffsetDateTime reviewedOn;
|
||||||
|
private final Long approvedBy;
|
||||||
|
private final OffsetDateTime approvedOn;
|
||||||
|
private final String fiuStrReference;
|
||||||
|
private final OffsetDateTime nodalOfficerInformedOn;
|
||||||
|
private final String frozenAccountIds;
|
||||||
|
private final OffsetDateTime frozenOn;
|
||||||
|
|
||||||
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 ipAddress,
|
||||||
final OffsetDateTime screenedOn, final Long screenedBy) {
|
final OffsetDateTime screenedOn, final Long screenedBy, final String disposition,
|
||||||
|
final String dispositionReason, final Long reviewedBy, final OffsetDateTime reviewedOn,
|
||||||
|
final Long approvedBy, final OffsetDateTime approvedOn, final String fiuStrReference,
|
||||||
|
final OffsetDateTime nodalOfficerInformedOn, final String frozenAccountIds,
|
||||||
|
final OffsetDateTime frozenOn) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.screenedName = screenedName;
|
this.screenedName = screenedName;
|
||||||
this.screeningResult = screeningResult;
|
this.screeningResult = screeningResult;
|
||||||
@@ -53,6 +67,16 @@ public final class SanctionsScreeningLogData {
|
|||||||
this.ipAddress = ipAddress;
|
this.ipAddress = ipAddress;
|
||||||
this.screenedOn = screenedOn;
|
this.screenedOn = screenedOn;
|
||||||
this.screenedBy = screenedBy;
|
this.screenedBy = screenedBy;
|
||||||
|
this.disposition = disposition;
|
||||||
|
this.dispositionReason = dispositionReason;
|
||||||
|
this.reviewedBy = reviewedBy;
|
||||||
|
this.reviewedOn = reviewedOn;
|
||||||
|
this.approvedBy = approvedBy;
|
||||||
|
this.approvedOn = approvedOn;
|
||||||
|
this.fiuStrReference = fiuStrReference;
|
||||||
|
this.nodalOfficerInformedOn = nodalOfficerInformedOn;
|
||||||
|
this.frozenAccountIds = frozenAccountIds;
|
||||||
|
this.frozenOn = frozenOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
|
public static SanctionsScreeningLogData fromEntity(final SanctionsScreeningLog log) {
|
||||||
@@ -67,7 +91,17 @@ public final class SanctionsScreeningLogData {
|
|||||||
log.getClientId(),
|
log.getClientId(),
|
||||||
log.getIpAddress(),
|
log.getIpAddress(),
|
||||||
log.getScreenedOn(),
|
log.getScreenedOn(),
|
||||||
log.getCreatedBy().orElse(null)
|
log.getCreatedBy().orElse(null),
|
||||||
|
log.getDisposition(),
|
||||||
|
log.getDispositionReason(),
|
||||||
|
log.getReviewedBy(),
|
||||||
|
log.getReviewedOn(),
|
||||||
|
log.getApprovedBy(),
|
||||||
|
log.getApprovedOn(),
|
||||||
|
log.getFiuStrReference(),
|
||||||
|
log.getNodalOfficerInformedOn(),
|
||||||
|
log.getFrozenAccountIds(),
|
||||||
|
log.getFrozenOn()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +116,14 @@ public final class SanctionsScreeningLogData {
|
|||||||
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; }
|
||||||
|
public String getDisposition() { return disposition; }
|
||||||
|
public String getDispositionReason() { return dispositionReason; }
|
||||||
|
public Long getReviewedBy() { return reviewedBy; }
|
||||||
|
public OffsetDateTime getReviewedOn() { return reviewedOn; }
|
||||||
|
public Long getApprovedBy() { return approvedBy; }
|
||||||
|
public OffsetDateTime getApprovedOn() { return approvedOn; }
|
||||||
|
public String getFiuStrReference() { return fiuStrReference; }
|
||||||
|
public OffsetDateTime getNodalOfficerInformedOn() { return nodalOfficerInformedOn; }
|
||||||
|
public String getFrozenAccountIds() { return frozenAccountIds; }
|
||||||
|
public OffsetDateTime getFrozenOn() { return frozenOn; }
|
||||||
}
|
}
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.sanctions.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
|
||||||
|
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freezes and releases a client's deposit accounts when a UNSC sanctions match is confirmed.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Under Section 51-A UAPA 1967 a confirmed match means funds may not move OUT — the account is
|
||||||
|
* not closed and incoming credits are not refused, so this applies a debit block rather than a
|
||||||
|
* full account block. Freezing is only reachable for a client that already exists; a match at
|
||||||
|
* onboarding has no account to freeze and is handled by refusing to create the client at all.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SanctionsFreezeService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(SanctionsFreezeService.class);
|
||||||
|
|
||||||
|
private final SavingsAccountRepository savingsAccountRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks debits on every active savings account of the client.
|
||||||
|
*
|
||||||
|
* @return comma-separated ids of the accounts actually frozen (empty string when the client
|
||||||
|
* has no active account — a legitimate outcome, not an error)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String freezeClientAccounts(final Long clientId, final Long alertId) {
|
||||||
|
if (clientId == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
|
||||||
|
final List<String> frozen = new ArrayList<>();
|
||||||
|
|
||||||
|
for (final SavingsAccount account : accounts) {
|
||||||
|
// blockDebits() rejects anything not ACTIVE and anything already fully blocked. Filter
|
||||||
|
// here rather than catching, so a closed or already-frozen account is a skip, not noise.
|
||||||
|
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(account.getStatus())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
|
||||||
|
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus)
|
||||||
|
|| SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)) {
|
||||||
|
frozen.add(String.valueOf(account.getId()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
account.blockDebits(account.getSubStatus());
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
frozen.add(String.valueOf(account.getId()));
|
||||||
|
LOG.warn("Sanctions alert {}: debits blocked on savings account {} of client {}", alertId, account.getId(),
|
||||||
|
clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// One un-freezable account must not abort the freeze of the others.
|
||||||
|
LOG.error("Sanctions alert {}: could not block debits on savings account {}: {}", alertId, account.getId(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frozen.stream().collect(Collectors.joining(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the debit block. Only ever called on de-listing or revocation of the order, and
|
||||||
|
* only from the Principal Officer approval path.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void unfreezeClientAccounts(final Long clientId) {
|
||||||
|
if (clientId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
|
||||||
|
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
|
||||||
|
if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)
|
||||||
|
|| SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
account.unblockDebits();
|
||||||
|
savingsAccountRepository.save(account);
|
||||||
|
LOG.info("Debits released on savings account {} of client {}", account.getId(), clientId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Could not release debits on savings account {}: {}", account.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -59,4 +59,57 @@ public interface SanctionsScreeningService {
|
|||||||
* Search screening logs by screened name.
|
* Search screening logs by screened name.
|
||||||
*/
|
*/
|
||||||
List<SanctionsScreeningLog> searchScreeningLogs(String name);
|
List<SanctionsScreeningLog> searchScreeningLogs(String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerts awaiting or carrying a disposition. Pass null for every alert regardless of state.
|
||||||
|
*/
|
||||||
|
List<SanctionsScreeningLog> getAlerts(String disposition);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the reviewing officer's assessment of a flagged screening.
|
||||||
|
*
|
||||||
|
* @param logId the flagged screening log id
|
||||||
|
* @param disposition FALSE_POSITIVE or CONFIRMED_MATCH
|
||||||
|
* @param reason mandatory narrative of the identifier comparison
|
||||||
|
*/
|
||||||
|
SanctionsScreeningLog recordDisposition(Long logId, String disposition, String reason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Principal Officer sign-off. Approving a FALSE_POSITIVE is what actually unblocks
|
||||||
|
* onboarding; approving a CONFIRMED_MATCH triggers the freeze of the client's accounts.
|
||||||
|
*/
|
||||||
|
SanctionsScreeningLog approveDisposition(Long logId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the FIU-IND STR reference and the date the nodal officer was informed, closing
|
||||||
|
* out the 24-hour reporting obligation on a confirmed match.
|
||||||
|
*/
|
||||||
|
SanctionsScreeningLog recordReporting(Long logId, String strReference, String nodalOfficerInformedOn);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this exact name carries a false positive that the Principal Officer approved —
|
||||||
|
* the only condition under which a matched name may be onboarded.
|
||||||
|
*/
|
||||||
|
boolean hasApprovedFalsePositive(String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match check that does NOT write an activity log entry.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Used by the server-side onboarding gate, which runs on every save attempt: logging there
|
||||||
|
* would stack a fresh alert onto the queue each time the officer retries a blocked client,
|
||||||
|
* burying the one real case under duplicates. The screening that belongs in the log is the
|
||||||
|
* one the officer deliberately ran from the UI.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
boolean isNameSanctioned(String name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-screens every existing client against the currently loaded list snapshot, opening an
|
||||||
|
* alert for each hit. This is what makes freezing reachable at all: without it a customer
|
||||||
|
* added to the list after onboarding is never detected.
|
||||||
|
*
|
||||||
|
* @return number of clients that produced a new flagged alert
|
||||||
|
*/
|
||||||
|
int rescreenExistingClients();
|
||||||
}
|
}
|
||||||
|
|||||||
+169
@@ -18,10 +18,16 @@
|
|||||||
*/
|
*/
|
||||||
package org.apache.fineract.portfolio.sanctions.service;
|
package org.apache.fineract.portfolio.sanctions.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.sanctions.data.SanctionsMatchData;
|
import org.apache.fineract.portfolio.sanctions.data.SanctionsMatchData;
|
||||||
import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult;
|
import org.apache.fineract.portfolio.sanctions.data.SanctionsScreeningResult;
|
||||||
import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias;
|
import org.apache.fineract.portfolio.sanctions.domain.SanctionsAlias;
|
||||||
@@ -31,6 +37,7 @@ import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLog;
|
|||||||
import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository;
|
import org.apache.fineract.portfolio.sanctions.domain.SanctionsScreeningLogRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -42,6 +49,9 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
|
|
||||||
private final SanctionsEntryRepository sanctionsEntryRepository;
|
private final SanctionsEntryRepository sanctionsEntryRepository;
|
||||||
private final SanctionsScreeningLogRepository screeningLogRepository;
|
private final SanctionsScreeningLogRepository screeningLogRepository;
|
||||||
|
private final SanctionsFreezeService sanctionsFreezeService;
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -113,6 +123,165 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
|
|||||||
return screeningLogRepository.findByScreenedNameContaining(name);
|
return screeningLogRepository.findByScreenedNameContaining(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Alert disposition ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SanctionsScreeningLog> getAlerts(final String disposition) {
|
||||||
|
if (disposition == null || disposition.isBlank()) {
|
||||||
|
return screeningLogRepository.findAllAlerts();
|
||||||
|
}
|
||||||
|
return screeningLogRepository.findByDisposition(disposition.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SanctionsScreeningLog recordDisposition(final Long logId, final String disposition, final String reason) {
|
||||||
|
final SanctionsScreeningLog log = findAlertOrThrow(logId);
|
||||||
|
|
||||||
|
final String normalised = disposition == null ? "" : disposition.trim().toUpperCase();
|
||||||
|
if (!SanctionsScreeningLog.DISPOSITION_FALSE_POSITIVE.equals(normalised)
|
||||||
|
&& !SanctionsScreeningLog.DISPOSITION_CONFIRMED.equals(normalised)) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.disposition.invalid",
|
||||||
|
"Disposition must be FALSE_POSITIVE or CONFIRMED_MATCH.");
|
||||||
|
}
|
||||||
|
// The written reason IS the compliance record — an unexplained "not the same person" is
|
||||||
|
// exactly what a regulator would treat as no review having happened at all.
|
||||||
|
if (reason == null || reason.trim().length() < 10) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.disposition.reason.required",
|
||||||
|
"Record which identifiers were compared and why (minimum 10 characters).");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.recordDisposition(normalised, reason.trim(), currentUserId());
|
||||||
|
screeningLogRepository.saveAndFlush(log);
|
||||||
|
LOG.warn("Sanctions alert {} for '{}' dispositioned as {}", logId, log.getScreenedName(), normalised);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SanctionsScreeningLog approveDisposition(final Long logId) {
|
||||||
|
final SanctionsScreeningLog log = findAlertOrThrow(logId);
|
||||||
|
|
||||||
|
if (SanctionsScreeningLog.DISPOSITION_PENDING.equals(log.getDisposition())) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.reviewed",
|
||||||
|
"This alert has not been reviewed yet — record a disposition before approving.");
|
||||||
|
}
|
||||||
|
final Long approverId = currentUserId();
|
||||||
|
// Maker-checker: whoever assessed the hit cannot also sign it off.
|
||||||
|
if (approverId != null && approverId.equals(log.getReviewedBy())) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.self.approval",
|
||||||
|
"A sanctions alert must be approved by an officer other than the one who reviewed it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.approve(approverId);
|
||||||
|
|
||||||
|
// Approving a confirmed match is the moment the freeze becomes due. Onboarding matches
|
||||||
|
// have no clientId and therefore nothing to freeze — that case is blocked at creation.
|
||||||
|
if (log.isConfirmedMatch() && log.getClientId() != null) {
|
||||||
|
final String frozen = sanctionsFreezeService.freezeClientAccounts(log.getClientId(), log.getId());
|
||||||
|
log.recordFreeze(frozen);
|
||||||
|
LOG.warn("Sanctions alert {}: froze accounts [{}] for client {}", logId, frozen, log.getClientId());
|
||||||
|
}
|
||||||
|
// A cleared false positive releases any freeze the alert previously imposed.
|
||||||
|
if (log.isApprovedFalsePositive() && log.getFrozenAccountIds() != null && !log.getFrozenAccountIds().isBlank()) {
|
||||||
|
sanctionsFreezeService.unfreezeClientAccounts(log.getClientId());
|
||||||
|
log.recordFreeze("");
|
||||||
|
}
|
||||||
|
|
||||||
|
screeningLogRepository.saveAndFlush(log);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SanctionsScreeningLog recordReporting(final Long logId, final String strReference,
|
||||||
|
final String nodalOfficerInformedOn) {
|
||||||
|
final SanctionsScreeningLog log = findAlertOrThrow(logId);
|
||||||
|
if (!log.isConfirmedMatch()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.reporting.not.confirmed",
|
||||||
|
"Only a confirmed match is reported to FIU-IND and the nodal officer.");
|
||||||
|
}
|
||||||
|
log.recordReporting(strReference, parseDateTime(nodalOfficerInformedOn));
|
||||||
|
screeningLogRepository.saveAndFlush(log);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean hasApprovedFalsePositive(final String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return screeningLogRepository.countApprovedFalsePositives(name.trim()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isNameSanctioned(final String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !sanctionsEntryRepository.searchByNameOrAlias(name.trim()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int rescreenExistingClients() {
|
||||||
|
// display_name is maintained by Fineract for both persons and entities, so one query
|
||||||
|
// covers individual and non-individual clients alike.
|
||||||
|
final List<Map<String, Object>> clients = jdbcTemplate
|
||||||
|
.queryForList("SELECT id, display_name FROM m_client WHERE status_enum = 300 AND display_name IS NOT NULL");
|
||||||
|
|
||||||
|
int flagged = 0;
|
||||||
|
for (final Map<String, Object> row : clients) {
|
||||||
|
final Long clientId = ((Number) row.get("id")).longValue();
|
||||||
|
final String name = (String) row.get("display_name");
|
||||||
|
try {
|
||||||
|
final SanctionsScreeningResult result = screenName(name, "PERIODIC_RESCREEN", clientId, null);
|
||||||
|
if (result.isMatchFound()) {
|
||||||
|
flagged++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Re-screening failed for client {} ('{}'): {}", clientId, name, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG.info("Periodic re-screening complete: {} of {} clients flagged", flagged, clients.size());
|
||||||
|
return flagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SanctionsScreeningLog findAlertOrThrow(final Long logId) {
|
||||||
|
final SanctionsScreeningLog log = screeningLogRepository.findById(logId)
|
||||||
|
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.found",
|
||||||
|
"Sanctions alert " + logId + " does not exist."));
|
||||||
|
if (!log.isFlagged()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.sanctions.alert.not.flagged",
|
||||||
|
"Screening " + logId + " produced no match, so there is nothing to disposition.");
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long currentUserId() {
|
||||||
|
try {
|
||||||
|
return context.authenticatedUser().getId();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Re-screening runs from a scheduler with no authenticated user.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime parseDateTime(final String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(value.trim()).atStartOfDay().atOffset(ZoneOffset.UTC);
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
LOG.debug("Could not parse nodal officer date: {}", value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Long saveLog(final String screenedName, final String result, final int matchCount,
|
private Long saveLog(final String screenedName, final String result, final int matchCount,
|
||||||
final String matchedEntryIds, final String matchedNames, final String source,
|
final String matchedEntryIds, final String matchedNames, final String source,
|
||||||
final Long clientId, final String ipAddress) {
|
final Long clientId, final String ipAddress) {
|
||||||
|
|||||||
@@ -665,7 +665,29 @@ fineract.openproject.project-id=${FINERACT_OPENPROJECT_PROJECT_ID:cbsnexora}
|
|||||||
fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c7274e3ed3d5ca82e1e45e040c857160975087694f4d39ff596}
|
fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c7274e3ed3d5ca82e1e45e040c857160975087694f4d39ff596}
|
||||||
|
|
||||||
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
|
# ── DIP / MNRL Mobile Number Lookup ──────────────────────────────────────────
|
||||||
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://localhost:8080}
|
# Checks a mobile number against the DoT Digital Intelligence Platform (suspected numbers) and the
|
||||||
|
# Mobile Number Revocation List (disconnected numbers). The backend calls
|
||||||
|
# GET {base-url}/api/v1/lookup/check?mobileNumber=... and expects the response shape documented in
|
||||||
|
# MobileLookupResult.
|
||||||
|
#
|
||||||
|
# A number that cannot be checked is NOT treated as clean: an unreachable service is recorded as
|
||||||
|
# SERVICE_UNAVAILABLE and the officer must either reject the number or record a written reason to
|
||||||
|
# proceed. So a wrong base-url here does not silently wave numbers through — it stops onboarding.
|
||||||
|
# ┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
# │ LOCAL TEST LOOKUP SERVICE — NOT THE REAL DoT DIP/MNRL ENDPOINT. │
|
||||||
|
# │ │
|
||||||
|
# │ This default points at scripts/mock-dip-lookup-service.js on localhost │
|
||||||
|
# │ so onboarding can be demonstrated end to end. Its verdicts are invented; │
|
||||||
|
# │ NO NUMBER IS ACTUALLY CHECKED AGAINST THE DoT. Every response carries │
|
||||||
|
# │ provider=MOCK_DIP_LOOKUP_TEST_ONLY, which is stored in the lookup log's │
|
||||||
|
# │ raw response so a mock check can never be mistaken for a real one. │
|
||||||
|
# │ │
|
||||||
|
# │ node scripts/mock-dip-lookup-service.js │
|
||||||
|
# │ │
|
||||||
|
# │ BEFORE PRODUCTION: set FINERACT_MOBILE_LOOKUP_BASE_URL to the real │
|
||||||
|
# │ DIP/MNRL provider. Do not ship the mock. │
|
||||||
|
# └──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090}
|
||||||
|
|
||||||
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
|
||||||
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
|
||||||
|
|||||||
@@ -529,4 +529,7 @@
|
|||||||
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0463_add_cash_disbursement_fields_to_dividend_claim_request.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0463_add_cash_disbursement_fields_to_dividend_claim_request.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0464_seed_dividend_claim_payment_type.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0464_seed_dividend_claim_payment_type.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0463_add_authorized_signatory_client_ids_to_client_non_person.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"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Authorized signatories of a non-individual client are picked from existing clients, and each
|
||||||
|
one carries its own PAN and DIN verification. Only their display names were ever stored, in
|
||||||
|
authorized_signatory, so re-opening the client for edit could show who had been recorded but
|
||||||
|
could not link back to them: the client ids, and with them the DIN verification trail behind
|
||||||
|
each signatory, were lost the moment the form was closed.
|
||||||
|
|
||||||
|
Stored as a comma-separated list of client ids, ordered to match authorized_signatory, rather
|
||||||
|
than as a child table. The signatory list is only ever read and written whole, alongside the
|
||||||
|
rest of the non-person block, and pairing it with the existing name column keeps the two in
|
||||||
|
step; a join table would need its own lifecycle for no gain here.
|
||||||
|
-->
|
||||||
|
<changeSet id="0463-non-person-authorized-signatory-client-ids" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_client_non_person" columnName="authorized_signatory_client_ids"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_client_non_person">
|
||||||
|
<column name="authorized_signatory_client_ids" type="VARCHAR(500)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Sanctions alert disposition.
|
||||||
|
|
||||||
|
A FLAGGED screening is not an outcome, it is a case that must be closed. Under Section 51-A
|
||||||
|
UAPA 1967 / Chapter IX of the RBI KYC Directions every positive hit has to end in either a
|
||||||
|
documented FALSE_POSITIVE (different person, reasons recorded, approved by the Principal
|
||||||
|
Officer) or a CONFIRMED_MATCH (escalated, reported to FIU-IND and the nodal officer, funds
|
||||||
|
frozen). These columns carry that lifecycle on the existing screening log row.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0464-sanctions-alert-disposition-columns" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_sanctions_screening_log" columnName="disposition"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_sanctions_screening_log">
|
||||||
|
<!-- PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH. NULL for CLEAR screenings. -->
|
||||||
|
<column name="disposition" type="VARCHAR(30)"/>
|
||||||
|
<!-- Mandatory narrative: which identifiers were compared and why they do/don't match. -->
|
||||||
|
<column name="disposition_reason" type="TEXT"/>
|
||||||
|
<column name="reviewed_by" type="BIGINT"/>
|
||||||
|
<column name="reviewed_on" type="DATETIME"/>
|
||||||
|
<!-- Principal Officer. A false positive is only effective once this is set. -->
|
||||||
|
<column name="approved_by" type="BIGINT"/>
|
||||||
|
<column name="approved_on" type="DATETIME"/>
|
||||||
|
<column name="fiu_str_reference" type="VARCHAR(100)"/>
|
||||||
|
<column name="nodal_officer_informed_on" type="DATETIME"/>
|
||||||
|
<!-- Comma-separated savings account ids frozen by this alert, for the audit trail. -->
|
||||||
|
<column name="frozen_account_ids" type="VARCHAR(1000)"/>
|
||||||
|
<column name="frozen_on" type="DATETIME"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Backfill: every historical FLAGGED row is an unreviewed case, not a closed one. -->
|
||||||
|
<changeSet id="0464-sanctions-alert-backfill-pending" author="Antigravity">
|
||||||
|
<update tableName="m_sanctions_screening_log">
|
||||||
|
<column name="disposition" value="PENDING_REVIEW"/>
|
||||||
|
<where>screening_result = 'FLAGGED' AND disposition IS NULL</where>
|
||||||
|
</update>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0464-sanctions-alert-disposition-index" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><indexExists indexName="idx_sanctions_log_disposition"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createIndex tableName="m_sanctions_screening_log" indexName="idx_sanctions_log_disposition">
|
||||||
|
<column name="disposition"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0464-sanctions-alert-permissions" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_permission WHERE code = 'DISPOSE_SANCTIONS'
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="DISPOSE_SANCTIONS"/>
|
||||||
|
<column name="entity_name" value="SANCTIONS"/>
|
||||||
|
<column name="action_name" value="DISPOSE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="APPROVE_SANCTIONS"/>
|
||||||
|
<column name="entity_name" value="SANCTIONS"/>
|
||||||
|
<column name="action_name" value="APPROVE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
DIP / MNRL lookup outcome tracking.
|
||||||
|
|
||||||
|
A SUSPECTED number is a DoT cyber-fraud signal, not a legal prohibition, so the officer may
|
||||||
|
still proceed — but only with a recorded reason. These columns capture that decision on the
|
||||||
|
lookup log, and record on the client which DIP status their number actually returned, so a
|
||||||
|
number that was never really checked (DoT outage) is distinguishable from one that passed.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0465-mobile-lookup-override-columns" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_mobile_lookup_log" columnName="override_decision"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_mobile_lookup_log">
|
||||||
|
<!-- PROCEEDED | REJECTED. Null when the lookup needed no decision (CLEAN). -->
|
||||||
|
<column name="override_decision" type="VARCHAR(20)"/>
|
||||||
|
<!-- Mandatory narrative when proceeding despite a DIP hit. -->
|
||||||
|
<column name="override_reason" type="TEXT"/>
|
||||||
|
<column name="decided_by" type="BIGINT"/>
|
||||||
|
<column name="decided_on" type="DATETIME"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0465-mobile-lookup-status-index" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><indexExists indexName="idx_mobile_lookup_status"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createIndex tableName="m_mobile_lookup_log" indexName="idx_mobile_lookup_status">
|
||||||
|
<column name="lookup_status"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
dip_verified alone cannot distinguish "checked and clean" from "never checked because the
|
||||||
|
DoT service was down" — both were being stored as true. Record the actual status too.
|
||||||
|
-->
|
||||||
|
<changeSet id="0465-client-dip-status" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_client" columnName="dip_status"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_client">
|
||||||
|
<column name="dip_status" type="VARCHAR(40)"/>
|
||||||
|
<column name="alternate_dip_status" type="VARCHAR(40)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0465-mobile-lookup-permissions" author="Antigravity">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">
|
||||||
|
SELECT COUNT(*) FROM m_permission WHERE code = 'OVERRIDE_MOBILE_LOOKUP'
|
||||||
|
</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_permission">
|
||||||
|
<column name="grouping" value="portfolio"/>
|
||||||
|
<column name="code" value="OVERRIDE_MOBILE_LOOKUP"/>
|
||||||
|
<column name="entity_name" value="MOBILE_LOOKUP"/>
|
||||||
|
<column name="action_name" value="OVERRIDE"/>
|
||||||
|
<column name="can_maker_checker" valueBoolean="false"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
* LOCAL TEST DIP/MNRL LOOKUP SERVICE — NOT A REAL DoT ENDPOINT.
|
||||||
|
*
|
||||||
|
* This stands in for the Department of Telecommunications Digital Intelligence Platform so the
|
||||||
|
* client-onboarding flow can be exercised end to end without a live DoT connection. It invents
|
||||||
|
* its answers. NOTHING HERE IS A REAL DIP OR MNRL CHECK.
|
||||||
|
*
|
||||||
|
* Mirrors scripts/mock-sms-gateway.js in intent: an unconfigured external provider gets a local
|
||||||
|
* stand-in rather than a dangling URL, and every response is stamped so it can never be mistaken
|
||||||
|
* for a genuine DoT result in the audit trail.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/mock-dip-lookup-service.js # listens on 8090
|
||||||
|
* MOCK_DIP_PORT=9100 node scripts/mock-dip-lookup-service.js
|
||||||
|
*
|
||||||
|
* Point the backend at it:
|
||||||
|
* FINERACT_MOBILE_LOOKUP_BASE_URL=http://127.0.0.1:8090
|
||||||
|
*
|
||||||
|
* BEFORE PRODUCTION: point FINERACT_MOBILE_LOOKUP_BASE_URL at the real DIP/MNRL provider and do
|
||||||
|
* not run this. See the banner in application.properties.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.MOCK_DIP_PORT || 8090);
|
||||||
|
|
||||||
|
/** Stamped into every response so a mock lookup is identifiable in the stored raw JSON. */
|
||||||
|
const PROVIDER = 'MOCK_DIP_LOOKUP_TEST_ONLY';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fixed verdicts for numbers used in demos and manual testing. Anything not listed here comes
|
||||||
|
* back CLEAN — the common case, so an ordinary test number does not trip the flagged-number gate.
|
||||||
|
*
|
||||||
|
* These overlap deliberately with DEMO_SUSPECTED_NUMBERS in MobileLookupServiceImpl: those are
|
||||||
|
* short-circuited before the HTTP call ever happens, so they are here only to keep the two lists
|
||||||
|
* telling the same story if that short-circuit is ever removed.
|
||||||
|
*/
|
||||||
|
const SUSPECTED = new Set([
|
||||||
|
'9827123456', '9827456789', '9826234567',
|
||||||
|
'9826987654', '9893123456', '9893765432'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DISCONNECTED = new Set([
|
||||||
|
'9000000001', '9000000002'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SUSPECTED_AND_DISCONNECTED = new Set([
|
||||||
|
'9000000003'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Strips a leading country code so 919827123456 and 9827123456 resolve alike. */
|
||||||
|
function normalise(raw) {
|
||||||
|
const digits = String(raw || '').replace(/\D/g, '');
|
||||||
|
if (digits.length > 10 && digits.startsWith('91')) {
|
||||||
|
return digits.slice(2);
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suspectedDetail() {
|
||||||
|
return {
|
||||||
|
dipStatus: 'active',
|
||||||
|
reason: 'Reported in cyber-fraud complaints (mock data)',
|
||||||
|
reportedBy: PROVIDER
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectedDetail() {
|
||||||
|
return {
|
||||||
|
disconnectionReason: 'Number revoked and listed on MNRL (mock data)',
|
||||||
|
operator: 'MOCK TELECOM'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The backend parser reads suspectedDetail/disconnectedDetail unconditionally and only guards
|
||||||
|
* with isJsonNull(), so both keys must always be present — an absent key would NPE there rather
|
||||||
|
* than surface as a clean parse failure.
|
||||||
|
*/
|
||||||
|
function verdictFor(mobileNumber) {
|
||||||
|
const n = normalise(mobileNumber);
|
||||||
|
|
||||||
|
if (SUSPECTED_AND_DISCONNECTED.has(n)) {
|
||||||
|
return {
|
||||||
|
status: 'SUSPECTED_AND_DISCONNECTED',
|
||||||
|
suspected: true,
|
||||||
|
disconnected: true,
|
||||||
|
suspectedDetail: suspectedDetail(),
|
||||||
|
disconnectedDetail: disconnectedDetail(),
|
||||||
|
message: 'Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (SUSPECTED.has(n)) {
|
||||||
|
return {
|
||||||
|
status: 'SUSPECTED',
|
||||||
|
suspected: true,
|
||||||
|
disconnected: false,
|
||||||
|
suspectedDetail: suspectedDetail(),
|
||||||
|
disconnectedDetail: null,
|
||||||
|
message: 'Number is SUSPECTED — found in DIP records'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (DISCONNECTED.has(n)) {
|
||||||
|
return {
|
||||||
|
status: 'DISCONNECTED',
|
||||||
|
suspected: false,
|
||||||
|
disconnected: true,
|
||||||
|
suspectedDetail: null,
|
||||||
|
disconnectedDetail: disconnectedDetail(),
|
||||||
|
message: 'Number is DISCONNECTED — found in MNRL records'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'CLEAN',
|
||||||
|
suspected: false,
|
||||||
|
disconnected: false,
|
||||||
|
suspectedDetail: null,
|
||||||
|
disconnectedDetail: null,
|
||||||
|
message: 'Number is CLEAN — not found in DIP or MNRL records'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (url.pathname !== '/api/v1/lookup/check') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, message: 'Not found', provider: PROVIDER }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileNumber = url.searchParams.get('mobileNumber');
|
||||||
|
if (!mobileNumber || !mobileNumber.trim()) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: false, message: 'mobileNumber is required', provider: PROVIDER }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = verdictFor(mobileNumber);
|
||||||
|
const body = {
|
||||||
|
success: true,
|
||||||
|
message: verdict.message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: PROVIDER,
|
||||||
|
data: {
|
||||||
|
mobileNumber: mobileNumber.trim(),
|
||||||
|
status: verdict.status,
|
||||||
|
suspected: verdict.suspected,
|
||||||
|
disconnected: verdict.disconnected,
|
||||||
|
suspectedDetail: verdict.suspectedDetail,
|
||||||
|
disconnectedDetail: verdict.disconnectedDetail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[${PROVIDER}] ${mobileNumber} -> ${verdict.status}`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`${PROVIDER} listening on http://127.0.0.1:${PORT}`);
|
||||||
|
console.log('This is NOT a real DoT DIP/MNRL service. Responses are invented.');
|
||||||
|
console.log(`Set FINERACT_MOBILE_LOOKUP_BASE_URL=http://127.0.0.1:${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user