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

- 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:
2026-08-14 10:46:59 +05:30
parent c8833367b9
commit a8ca7dd948
32 changed files with 1541 additions and 37 deletions
@@ -191,6 +191,7 @@ public class ClientApiConstants {
public static final String constitutionIdParamName = "constitutionId";
public static final String mainBusinessLineIdParamName = "mainBusinessLineId";
public static final String authorizedSignatoryParamName = "authorizedSignatory";
public static final String authorizedSignatoryClientIdsParamName = "authorizedSignatoryClientIds";
public static final String additionalInfoParamName = "additionalInfo";
public static final String dateOfRegistrationParamName = "dateOfRegistration";
@@ -34,6 +34,8 @@ public class ClientNonPersonData implements Serializable {
private final CodeValueData mainBusinessLine;
private final String remarks;
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 LocalDate dateOfRegistration;
@@ -62,13 +64,24 @@ public class ClientNonPersonData implements Serializable {
this.constitution = null;
this.mainBusinessLine = null;
this.authorizedSignatory = null;
this.authorizedSignatoryClientIds = null;
this.additionalInfo = 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,
CodeValueData mainBusinessLine, String remarks, String authorizedSignatory, String additionalInfo,
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.incorpNumber = incorpNo;
@@ -76,6 +89,7 @@ public class ClientNonPersonData implements Serializable {
this.mainBusinessLine = mainBusinessLine;
this.remarks = remarks;
this.authorizedSignatory = authorizedSignatory;
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds;
this.additionalInfo = additionalInfo;
this.dateOfRegistration = dateOfRegistration;
}
@@ -294,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "alternate_dip_timestamp")
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")
private Boolean nameVerified;
@@ -25,11 +25,15 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
@@ -61,6 +65,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@JoinColumn(name = "client_id", nullable = false)
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 */
@Column(name = "kyc_status", nullable = false)
private Integer kycStatus;
@@ -440,11 +452,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public Map<String, Object> update(final JsonCommand command) {
final Map<String, Object> actualChanges = new LinkedHashMap<>();
this.fieldsOverwrittenByLastUpdate.clear();
final String panNumberParam = "panNumber";
if (command.isChangeInStringParameterNamed(panNumberParam, this.panNumber)) {
final String newValue = command.stringValueOfParameterNamed(panNumberParam);
actualChanges.put(panNumberParam, newValue);
recordOverwrite(panNumberParam, this.panNumber);
this.panNumber = StringUtils.defaultIfEmpty(newValue, null);
}
@@ -452,6 +466,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(aadhaarParam, this.aadhaarNumberMasked)) {
final String newValue = command.stringValueOfParameterNamed(aadhaarParam);
actualChanges.put(aadhaarParam, newValue);
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
}
@@ -459,6 +474,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(gstinParam, this.gstinNumber)) {
final String newValue = command.stringValueOfParameterNamed(gstinParam);
actualChanges.put(gstinParam, newValue);
recordOverwrite(gstinParam, this.gstinNumber);
this.gstinNumber = StringUtils.defaultIfEmpty(newValue, null);
}
@@ -466,9 +482,12 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInIntegerParameterNamed(riskParam, this.riskCategory)) {
final Integer newValue = command.integerValueOfParameterNamed(riskParam);
actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory);
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";
if (command.isChangeInStringParameterNamed(remarksParam, this.remarks)) {
final String newValue = command.stringValueOfParameterNamed(remarksParam);
@@ -551,6 +570,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInStringParameterNamed(param, current)) {
final String newValue = command.stringValueOfParameterNamed(param);
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
}
}
@@ -560,6 +580,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInLocalDateParameterNamed(param, current)) {
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue);
}
}
@@ -570,6 +591,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
changes.put(param, newValue);
recordOverwrite(param, current);
setter.accept(newValue);
}
}
@@ -580,10 +602,40 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
if (command.isChangeInBigDecimalParameterNamed(param, current)) {
final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param);
changes.put(param, newValue);
recordOverwrite(param, current);
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. */
public void markInProgress() {
this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue();
@@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
@Column(name = "looked_up_on", nullable = false)
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() {}
public MobileLookupLog(final String mobileNumber, final String lookupStatus,
@@ -81,4 +104,27 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom<Long
this.clientId = clientId;
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);
}
}
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.mobilelookup.domain;
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.Query;
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")
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);
}
@@ -61,6 +61,47 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
@Column(name = "screened_on", nullable = false)
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() {}
public SanctionsScreeningLog(final String screenedName, final String screeningResult,
@@ -75,8 +116,55 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
this.clientId = clientId;
this.ipAddress = ipAddress;
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 ─────────────────────────────────────────────────────────────
public String getScreenedName() { return screenedName; }
@@ -89,4 +177,14 @@ public class SanctionsScreeningLog extends AbstractAuditableWithUTCDateTimeCusto
public void setClientId(Long clientId) { this.clientId = clientId; }
public String getIpAddress() { return ipAddress; }
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; }
}
@@ -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")
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);
}
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(familyMembers, address, localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName,
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,
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<>(
Arrays.asList(localeParamName, dateFormatParamName, incorpNumberParamName, remarksParamName, incorpValidityTillParamName,
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName, additionalInfoParamName,
dateOfRegistrationParamName));
constitutionIdParamName, mainBusinessLineIdParamName, authorizedSignatoryParamName,
authorizedSignatoryClientIdsParamName, additionalInfoParamName, dateOfRegistrationParamName));
/**
* These parameters will match the class level parameters of {@link ClientData}. Where possible, we try to get
@@ -87,6 +87,11 @@ public final class ClientKycData {
private final Boolean cinVerified;
private final LocalDate cinVerifiedOn;
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 ─────────────────────────────────────────────────────────
private final String kycDocumentName;
@@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
@Column(name = "authorized_signatory", length = 500, nullable = true)
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)
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,
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,
additionalInfo, dateOfRegistration);
additionalInfo, dateOfRegistration, authorizedSignatoryClientIds);
}
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,
final LocalDate incorpValidityTill, final String remarks, final String authorizedSignatory, final String additionalInfo,
final LocalDate dateOfRegistration) {
final LocalDate dateOfRegistration, final String authorizedSignatoryClientIds) {
if (client != null) {
this.client = client;
}
@@ -137,6 +145,10 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
this.authorizedSignatory = authorizedSignatory.trim();
}
if (StringUtils.isNotBlank(authorizedSignatoryClientIds)) {
this.authorizedSignatoryClientIds = authorizedSignatoryClientIds.trim();
}
if (StringUtils.isNotBlank(additionalInfo)) {
this.additionalInfo = additionalInfo.trim();
}
@@ -258,6 +270,13 @@ public class ClientNonPerson extends AbstractPersistableCustom<Long> {
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)) {
final String valueAsInput = command.stringValueOfParameterNamed(ClientApiConstants.dateOfRegistrationParamName);
actualChanges.put(ClientApiConstants.dateOfRegistrationParamName, valueAsInput);
@@ -48,7 +48,10 @@ public class KycVerificationResult {
.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) {
return KycVerificationResult.builder()
.status(Status.FAILURE)
@@ -57,7 +60,25 @@ public class KycVerificationResult {
.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() {
return Status.SUCCESS == this.status;
}
/** Distinguishes an unreachable provider from one that answered with a rejection. */
public boolean isServiceError() {
return Status.ERROR == this.status;
}
}
@@ -322,7 +322,9 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
final JsonObject response = parseJson(rawResponse);
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),
rawResponse);
}
@@ -338,12 +340,12 @@ public class LincPayKycVerificationServiceImpl implements KycVerificationService
} catch (final HttpClientErrorException | HttpServerErrorException e) {
LOG.error("LincPay CIN validation HTTP error for CIN {}: {} {}", cinNumber,
e.getStatusCode(), e.getResponseBodyAsString());
return KycVerificationResult.failure(
return KycVerificationResult.error(
"CIN verification failed (HTTP " + e.getStatusCode() + "): "
+ e.getResponseBodyAsString(), null);
} catch (final Exception 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);
}
}
@@ -107,6 +107,9 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.cinVerified(kyc.isCinVerified())
.cinVerifiedOn(kyc.getCinVerifiedOn())
.incorporationDate(kyc.getIncorporationDate())
.dinNumber(kyc.getDinNumber())
.dinVerified(kyc.isDinVerified())
.dinVerifiedOn(kyc.getDinVerifiedOn())
.kycDocumentName(kyc.getKycDocumentName())
.kycDocumentNumber(kyc.getKycDocumentNumber())
.kycDocIssueDate(kyc.getKycDocIssueDate())
@@ -93,9 +93,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kycCommand.validateForInitiate(Boolean.TRUE.equals(clientForValidation.getIsMinor()));
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final Integer kycTypeOnInitiate = command.integerValueOfParameterNamed("kycType");
final boolean isIndividualInitiate = kycTypeOnInitiate != null && kycTypeOnInitiate == 1;
enforceNonIndividualOnlyFields(command, isIndividualInitiate);
enforceNonIndividualOnlyFields(command, isIndividualClient(clientForValidation));
if (kycRepositoryWrapper.existsByClientId(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);
// CIN / GSTIN are only applicable for NON_INDIVIDUAL clients
final boolean isIndividualExisting = kyc.getKycType() != null && kyc.getKycType().isIndividual();
enforceNonIndividualOnlyFields(command, isIndividualExisting);
enforceNonIndividualOnlyFields(command, isIndividualClient(client));
final ClientKycStatus currentStatus = kyc.getKycStatus();
// When re-editing a VERIFIED record, remarks are mandatory (audit trail)
if (currentStatus.isVerified()) {
final Map<String, Object> changes = kyc.update(command);
// 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");
if (StringUtils.isBlank(remarks)) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.parameterError(
"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()) {
kycRepositoryWrapper.saveAndFlush(kyc);
}
@@ -335,7 +339,11 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
// firstname/middlename/lastname ← individual columns; NULL for entities
// fullname ← the entity name lives here
// 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"
// 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.
@@ -343,10 +351,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final String dob;
if (client.getLegalForm() != null && LegalForm.fromInt(client.getLegalForm()).isEntity()) {
clientNameForPan = client.getFullname() != null ? client.getFullname().trim() : "";
java.time.LocalDate incorpDate = kyc.getIncorporationDate();
if (incorpDate == null) {
final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId());
final java.time.LocalDate regDate = (np != null) ? np.getDateOfRegistration() : null;
dob = regDate != null
? regDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
incorpDate = (np != null) ? np.getDateOfRegistration() : null;
}
dob = incorpDate != null
? incorpDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
: null;
} else {
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.");
}
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(
"error.msg.client.kyc.cin.verification.failed",
"CIN verification failed: " + 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.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
@@ -537,6 +569,10 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("cinVerified", true);
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());
return buildResult(command, client, clientId, kycId, changes);
}
@@ -701,6 +737,16 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
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
* 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());
// 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 JsonCommand jsonCommand = JsonCommand.from(kycJson, parsedJson, this.fromApiJsonHelper,
"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 Map<String, Object> changes = kyc.update(jsonCommand);
@@ -79,6 +79,7 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
private final ClientLookupMapper lookupMapper = new ClientLookupMapper();
private final ClientMembersOfGroupMapper membersOfGroupMapper = new ClientMembersOfGroupMapper();
private final ParentGroupsMapper clientGroupsMapper = new ParentGroupsMapper();
private final ClientNonPersonDataMapper clientNonPersonMapper = new ClientNonPersonDataMapper();
private final ColumnValidator columnValidator;
private final ClientCollateralManagementRepositoryWrapper clientCollateralManagementRepositoryWrapper;
@@ -227,6 +228,13 @@ public class ClientReadPlatformServiceImpl implements ClientReadPlatformService
final Client client = clientRepositoryWrapper.getClientByClientIdAndHierarchy(clientId, hierarchySearchString);
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
final Collection<ClientCollateralManagement> clientCollateralManagements = this.clientCollateralManagementRepositoryWrapper
.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
public Collection<ClientData> retrieveAllForLookup(final String extraCriteria) {
@@ -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.domain.ExternalId;
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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -134,6 +135,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
private final ClientRepository clientJpaRepository;
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.service.SanctionsScreeningService sanctionsScreeningService;
private final org.apache.fineract.portfolio.mobilelookup.service.MobileLookupService mobileLookupService;
private final CifNumberGenerator cifNumberGenerator;
private final ClientContactOtpService clientContactOtpService;
private final ClientChangeNotificationService clientChangeNotificationService;
@@ -611,6 +614,18 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
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
this.clientRepository.saveAndFlush(newClient);
if (StringUtils.isBlank(accountNo)) {
@@ -756,6 +771,8 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final String authorizedSignatory = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryParamName, clientNonPersonElement);
final String authorizedSignatoryClientIds = this.fromApiJsonHelper
.extractStringNamed(ClientApiConstants.authorizedSignatoryClientIdsParamName, clientNonPersonElement);
final String additionalInfo = this.fromApiJsonHelper.extractStringNamed(ClientApiConstants.additionalInfoParamName,
clientNonPersonElement);
final LocalDate dateOfRegistration = this.fromApiJsonHelper
@@ -763,7 +780,7 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP
final ClientNonPerson newClientNonPerson = ClientNonPerson.createNew(client, clientNonPersonConstitution,
clientNonPersonMainBusinessLine, incorpNumber, incorpValidityTill, remarks, authorizedSignatory, additionalInfo,
dateOfRegistration);
dateOfRegistration, authorizedSignatoryClientIds);
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.
*/
/**
* 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) {
Integer minNumberOfClients = configurationDomainService.retrieveMinAllowedClientsInGroup();
Integer maxNumberOfClients = configurationDomainService.retrieveMaxAllowedClientsInGroup();
@@ -18,6 +18,8 @@
*/
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.Parameter;
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 jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
@@ -63,6 +69,8 @@ public class MobileLookupApiResource {
private final MobileLookupService mobileLookupService;
private final DefaultToApiJsonSerializer<MobileLookupResult> resultSerializer;
private final DefaultToApiJsonSerializer<List> listSerializer;
private final DefaultToApiJsonSerializer<MobileLookupLogData> logSerializer;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
@GET
@Path("/check")
@@ -80,6 +88,20 @@ public class MobileLookupApiResource {
@QueryParam("mobileNumber") @Parameter(description = "Mobile number to check (10-15 digits)") final String mobileNumber) {
this.context.authenticatedUser().validateHasReadPermission("MOBILE_LOOKUP");
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);
}
@@ -113,4 +135,39 @@ public class MobileLookupApiResource {
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);
}
}
@@ -35,6 +35,12 @@ public class MobileLookupLogData {
private final String lookupSource;
private final Long clientId;
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) {
return new MobileLookupLogData(
@@ -46,6 +52,11 @@ public class MobileLookupLogData {
log.getResponseMessage(),
log.getLookupSource(),
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());
}
}
@@ -33,4 +33,30 @@ public interface MobileLookupService {
List<MobileLookupLog> getLookupLogsByMobileNumber(String mobileNumber);
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);
}
@@ -23,6 +23,7 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.List;
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.MobileLookupResult;
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.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
@@ -113,13 +115,88 @@ public class MobileLookupServiceImpl implements MobileLookupService {
parsed.getData(), parsed.getTimestamp());
} catch (Exception e) {
LOG.error("Failed to call DIP/MNRL lookup API for {}: {}", trimmed, e.getMessage());
final Long logId = saveLog(trimmed, "ERROR", false, false,
e.getMessage(), rawResponse, source, clientId);
// An unreachable DoT service means the number was NOT checked. It is logged under its
// 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,
"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
@Transactional(readOnly = true)
public List<MobileLookupLog> getLookupLogs() {
@@ -28,6 +28,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@@ -77,6 +78,7 @@ public class SanctionsScreeningApiResource {
private final DefaultToApiJsonSerializer<SanctionsScreeningResult> screeningResultSerializer;
private final DefaultToApiJsonSerializer<Map> mapSerializer;
private final DefaultToApiJsonSerializer<List> listSerializer;
private final DefaultToApiJsonSerializer<SanctionsScreeningLogData> logSerializer;
@GET
@Path("/screen")
@@ -190,4 +192,95 @@ public class SanctionsScreeningApiResource {
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);
}
}
@@ -37,11 +37,25 @@ public final class SanctionsScreeningLogData {
private final String ipAddress;
private final OffsetDateTime screenedOn;
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,
final Integer matchCount, final String matchedEntryIds, final String matchedNames,
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.screenedName = screenedName;
this.screeningResult = screeningResult;
@@ -53,6 +67,16 @@ public final class SanctionsScreeningLogData {
this.ipAddress = ipAddress;
this.screenedOn = screenedOn;
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) {
@@ -67,7 +91,17 @@ public final class SanctionsScreeningLogData {
log.getClientId(),
log.getIpAddress(),
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 OffsetDateTime getScreenedOn() { return screenedOn; }
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; }
}
@@ -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());
}
}
}
}
@@ -59,4 +59,57 @@ public interface SanctionsScreeningService {
* Search screening logs by screened 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();
}
@@ -18,10 +18,16 @@
*/
package org.apache.fineract.portfolio.sanctions.service;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
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.SanctionsScreeningResult;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,6 +49,9 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
private final SanctionsEntryRepository sanctionsEntryRepository;
private final SanctionsScreeningLogRepository screeningLogRepository;
private final SanctionsFreezeService sanctionsFreezeService;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
@@ -113,6 +123,165 @@ public class SanctionsScreeningServiceImpl implements SanctionsScreeningService
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,
final String matchedEntryIds, final String matchedNames, final String source,
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}
# ── 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 ─────────────────────────
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/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/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>
@@ -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>
@@ -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>
@@ -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>
+167
View File
@@ -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}`);
});