feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

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