From a8ca7dd948ba46ab9489ae5cf0493403be9123c6 Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 14 Aug 2026 10:46:11 +0530 Subject: [PATCH] feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows - 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. --- .../client/api/ClientApiConstants.java | 1 + .../client/data/ClientNonPersonData.java | 14 ++ .../portfolio/client/domain/Client.java | 11 ++ .../portfolio/client/domain/ClientKyc.java | 52 ++++++ .../mobilelookup/domain/MobileLookupLog.java | 46 +++++ .../domain/MobileLookupLogRepository.java | 27 +++ .../domain/SanctionsScreeningLog.java | 98 ++++++++++ .../SanctionsScreeningLogRepository.java | 20 +++ .../data/ClientApiCollectionConstants.java | 7 +- .../portfolio/client/data/ClientKycData.java | 5 + .../client/domain/ClientNonPerson.java | 25 ++- .../client/kyc/KycVerificationResult.java | 23 ++- .../LincPayKycVerificationServiceImpl.java | 8 +- .../ClientKycReadPlatformServiceImpl.java | 3 + ...WritePlatformServiceJpaRepositoryImpl.java | 83 +++++++-- .../ClientReadPlatformServiceImpl.java | 59 ++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 69 ++++++- .../api/MobileLookupApiResource.java | 57 ++++++ .../data/MobileLookupLogData.java | 13 +- .../service/MobileLookupService.java | 26 +++ .../service/MobileLookupServiceImpl.java | 83 ++++++++- .../api/SanctionsScreeningApiResource.java | 93 ++++++++++ .../data/SanctionsScreeningLogData.java | 48 ++++- .../service/SanctionsFreezeService.java | 117 ++++++++++++ .../service/SanctionsScreeningService.java | 53 ++++++ .../SanctionsScreeningServiceImpl.java | 169 ++++++++++++++++++ .../src/main/resources/application.properties | 24 ++- .../db/changelog/tenant/changelog-tenant.xml | 3 + ...natory_client_ids_to_client_non_person.xml | 28 +++ .../0464_add_sanctions_alert_disposition.xml | 78 ++++++++ ...465_add_dip_override_and_client_status.xml | 68 +++++++ scripts/mock-dip-lookup-service.js | 167 +++++++++++++++++ 32 files changed, 1541 insertions(+), 37 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsFreezeService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_authorized_signatory_client_ids_to_client_non_person.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_add_sanctions_alert_disposition.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_add_dip_override_and_client_status.xml create mode 100644 scripts/mock-dip-lookup-service.js diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java index eabe2eb9a..5f5e99096 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java @@ -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"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientNonPersonData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientNonPersonData.java index 8f3aaffa4..9676da4d3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientNonPersonData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/data/ClientNonPersonData.java @@ -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; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java index ea246c221..a32a43a65 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java @@ -294,6 +294,17 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom { @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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 4edc0de2a..0b2b05eef 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -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 { @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 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 { public Map update(final JsonCommand command) { final Map 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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. + * + *

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. + * + *

Reset at the start of every {@link #update} and never persisted: it describes one call. + */ + public Set 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(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java index b1a705ae2..8a2653dbb 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/mobilelookup/domain/MobileLookupLog.java @@ -65,6 +65,29 @@ public class MobileLookupLog extends AbstractAuditableWithUTCDateTimeCustom 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 findPendingDecisions(); + + /** + * Most recent lookup for a number, used by the server-side gate to confirm the browser's + * claim that a number was verified. + * + *

+ * Paged rather than a JPQL {@code LIMIT}, which EclipseLink does not accept — the caller + * takes the first row. + *

+ */ + @Query("SELECT l FROM MobileLookupLog l WHERE l.mobileNumber = :mobileNumber ORDER BY l.lookedUpOn DESC") + List 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 findRejections(@Param("mobileNumber") String mobileNumber, Pageable pageable); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java index b92741d41..3bf35f0b2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLog.java @@ -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; } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java index 2eaa457ed..17bb4845a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/sanctions/domain/SanctionsScreeningLogRepository.java @@ -39,4 +39,24 @@ public interface SanctionsScreeningLogRepository extends JpaRepository findByScreenedNameContaining(@Param("name") String name); + + @Query("SELECT l FROM SanctionsScreeningLog l WHERE l.disposition = :disposition ORDER BY l.screenedOn DESC") + List 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 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 findFlaggedByClientId(@Param("clientId") Long clientId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java index 5dc927e18..49abb5238 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientApiCollectionConstants.java @@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants { protected static final Set 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 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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java index 76442bb39..8359f4938 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java index 894c2a92a..d9417db74 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/domain/ClientNonPerson.java @@ -75,6 +75,14 @@ public class ClientNonPerson extends AbstractPersistableCustom { @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 { 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 { 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 { 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 { 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java index ebb80d612..98bcb3093 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/KycVerificationResult.java @@ -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; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java index b315e79eb..ff4754e19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/kyc/LincPayKycVerificationServiceImpl.java @@ -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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index 4a3a3b88b..a9dd3e91d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -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()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index e46cb290c..33e3347a3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 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() : ""; - 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")) + java.time.LocalDate incorpDate = kyc.getIncorporationDate(); + if (incorpDate == null) { + final ClientNonPerson np = this.clientNonPersonRepository.findOneByClientId(client.getId()); + 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 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 changes = kyc.update(jsonCommand); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java index 4a2480852..c05016008 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientReadPlatformServiceImpl.java @@ -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 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 details = this.jdbcTemplate.query(sql, this.clientNonPersonMapper, // NOSONAR + clientId); + return details.isEmpty() ? null : details.get(0); + } + + private static final class ClientNonPersonDataMapper implements RowMapper { + + 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 retrieveAllForLookup(final String extraCriteria) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index 21dbf361b..d68f38566 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -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. + * + *

+ * 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. + *

+ */ + 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. + * + *

+ * 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. + *

+ */ + 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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java index 104df622c..a5d4cfe13 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/api/MobileLookupApiResource.java @@ -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 resultSerializer; private final DefaultToApiJsonSerializer listSerializer; + private final DefaultToApiJsonSerializer logSerializer; + private final DefaultToApiJsonSerializer 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 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 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); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java index cfa7d7c0e..fad7c70d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/data/MobileLookupLogData.java @@ -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()); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java index e38b9b6ea..f8b0593e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupService.java @@ -33,4 +33,30 @@ public interface MobileLookupService { List getLookupLogsByMobileNumber(String mobileNumber); List 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 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java index 75e5f3b04..92878b569 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/mobilelookup/service/MobileLookupServiceImpl.java @@ -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 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 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 rejections = logRepository.findRejections(mobileNumber.trim(), PageRequest.of(0, 1)); + return rejections.isEmpty() ? null : rejections.get(0); + } + @Override @Transactional(readOnly = true) public List getLookupLogs() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java index 38f1c2477..9643e8644 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/api/SanctionsScreeningApiResource.java @@ -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 screeningResultSerializer; private final DefaultToApiJsonSerializer mapSerializer; private final DefaultToApiJsonSerializer listSerializer; + private final DefaultToApiJsonSerializer 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 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 response = new HashMap<>(); + response.put("message", "Re-screening complete"); + response.put("clientsFlagged", flagged); + return mapSerializer.serialize(response); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java index 12b6ef728..ada39de25 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/data/SanctionsScreeningLogData.java @@ -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; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsFreezeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsFreezeService.java new file mode 100644 index 000000000..daf161bb9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsFreezeService.java @@ -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. + * + *

+ * 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. + *

+ */ +@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 accounts = savingsAccountRepository.findSavingAccountByClientId(clientId); + final List 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()); + } + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java index 838edc7ed..c8f6c55b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningService.java @@ -59,4 +59,57 @@ public interface SanctionsScreeningService { * Search screening logs by screened name. */ List searchScreeningLogs(String name); + + /** + * Alerts awaiting or carrying a disposition. Pass null for every alert regardless of state. + */ + List 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. + * + *

+ * 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. + *

+ */ + 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(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java index 5fd41625d..7afbf9c0d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsScreeningServiceImpl.java @@ -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 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> 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 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) { diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 74ca54959..6ff256688 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -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} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index dcc3ec932..70bbb4537 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -529,4 +529,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_authorized_signatory_client_ids_to_client_non_person.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_authorized_signatory_client_ids_to_client_non_person.xml new file mode 100644 index 000000000..2dd9479e0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0463_add_authorized_signatory_client_ids_to_client_non_person.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_add_sanctions_alert_disposition.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_add_sanctions_alert_disposition.xml new file mode 100644 index 000000000..fca0c03cb --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0464_add_sanctions_alert_disposition.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + screening_result = 'FLAGGED' AND disposition IS NULL + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DISPOSE_SANCTIONS' + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_add_dip_override_and_client_status.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_add_dip_override_and_client_status.xml new file mode 100644 index 000000000..17a3f459a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0465_add_dip_override_and_client_status.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'OVERRIDE_MOBILE_LOOKUP' + + + + + + + + + + + + diff --git a/scripts/mock-dip-lookup-service.js b/scripts/mock-dip-lookup-service.js new file mode 100644 index 000000000..0d0c54cb4 --- /dev/null +++ b/scripts/mock-dip-lookup-service.js @@ -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}`); +});