feat: implement DIP/MNRL lookups and enhance Sanctions screening workflows
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
- Implement DIP/MNRL lookup logs table, filtering, and detail review under reports. - Integrate DIP verification gating on client creation, allowing officers to record PROCEEDED/REJECTED decisions with reasons. - Upgrade Sanctions verification dialog to support comparing Name, DOB, and Nationality particulars. - Add Sanctions disposition recording (FALSE_POSITIVE / CONFIRMED_MATCH) awaiting Principal Officer approval. - Add sanctions-alert-review-dialog for reviewers to assess and escalate/clear flagged names. - Enforce strict screening reset logic on client name changes, preventing bypass of existing blocks. - Update routes and shell menu navigation to include DIP/MNRL Lookups and Sanctions Logs.
This commit is contained in:
+4
-3
@@ -46,7 +46,8 @@ public class ClientApiCollectionConstants extends ClientApiConstants {
|
||||
protected static final Set<String> CLIENT_NON_PERSON_CREATE_REQUEST_DATA_PARAMETERS = new HashSet<>(
|
||||
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
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+22
-3
@@ -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);
|
||||
|
||||
+22
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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())
|
||||
|
||||
+64
-19
@@ -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);
|
||||
|
||||
+59
@@ -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) {
|
||||
|
||||
|
||||
+68
-1
@@ -44,6 +44,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
|
||||
import org.apache.fineract.infrastructure.core.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();
|
||||
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
|
||||
+80
-3
@@ -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() {
|
||||
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+46
-2
@@ -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; }
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sanctions.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountSubStatusEnum;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Freezes and releases a client's deposit accounts when a UNSC sanctions match is confirmed.
|
||||
*
|
||||
* <p>
|
||||
* Under Section 51-A UAPA 1967 a confirmed match means funds may not move OUT — the account is
|
||||
* not closed and incoming credits are not refused, so this applies a debit block rather than a
|
||||
* full account block. Freezing is only reachable for a client that already exists; a match at
|
||||
* onboarding has no account to freeze and is handled by refusing to create the client at all.
|
||||
* </p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SanctionsFreezeService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SanctionsFreezeService.class);
|
||||
|
||||
private final SavingsAccountRepository savingsAccountRepository;
|
||||
|
||||
/**
|
||||
* Blocks debits on every active savings account of the client.
|
||||
*
|
||||
* @return comma-separated ids of the accounts actually frozen (empty string when the client
|
||||
* has no active account — a legitimate outcome, not an error)
|
||||
*/
|
||||
@Transactional
|
||||
public String freezeClientAccounts(final Long clientId, final Long alertId) {
|
||||
if (clientId == null) {
|
||||
return "";
|
||||
}
|
||||
final List<SavingsAccount> accounts = savingsAccountRepository.findSavingAccountByClientId(clientId);
|
||||
final List<String> frozen = new ArrayList<>();
|
||||
|
||||
for (final SavingsAccount account : accounts) {
|
||||
// blockDebits() rejects anything not ACTIVE and anything already fully blocked. Filter
|
||||
// here rather than catching, so a closed or already-frozen account is a skip, not noise.
|
||||
if (!SavingsAccountStatusType.ACTIVE.hasStateOf(account.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
|
||||
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus)
|
||||
|| SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)) {
|
||||
frozen.add(String.valueOf(account.getId()));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
account.blockDebits(account.getSubStatus());
|
||||
savingsAccountRepository.save(account);
|
||||
frozen.add(String.valueOf(account.getId()));
|
||||
LOG.warn("Sanctions alert {}: debits blocked on savings account {} of client {}", alertId, account.getId(),
|
||||
clientId);
|
||||
} catch (Exception e) {
|
||||
// One un-freezable account must not abort the freeze of the others.
|
||||
LOG.error("Sanctions alert {}: could not block debits on savings account {}: {}", alertId, account.getId(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return frozen.stream().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the debit block. Only ever called on de-listing or revocation of the order, and
|
||||
* only from the Principal Officer approval path.
|
||||
*/
|
||||
@Transactional
|
||||
public void unfreezeClientAccounts(final Long clientId) {
|
||||
if (clientId == null) {
|
||||
return;
|
||||
}
|
||||
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
|
||||
final SavingsAccountSubStatusEnum subStatus = SavingsAccountSubStatusEnum.fromInt(account.getSubStatus());
|
||||
if (!(SavingsAccountSubStatusEnum.BLOCK_DEBIT.hasStateOf(subStatus)
|
||||
|| SavingsAccountSubStatusEnum.BLOCK.hasStateOf(subStatus))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
account.unblockDebits();
|
||||
savingsAccountRepository.save(account);
|
||||
LOG.info("Debits released on savings account {} of client {}", account.getId(), clientId);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Could not release debits on savings account {}: {}", account.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -59,4 +59,57 @@ public interface SanctionsScreeningService {
|
||||
* Search screening logs by screened name.
|
||||
*/
|
||||
List<SanctionsScreeningLog> searchScreeningLogs(String name);
|
||||
|
||||
/**
|
||||
* Alerts awaiting or carrying a disposition. Pass null for every alert regardless of state.
|
||||
*/
|
||||
List<SanctionsScreeningLog> getAlerts(String disposition);
|
||||
|
||||
/**
|
||||
* Records the reviewing officer's assessment of a flagged screening.
|
||||
*
|
||||
* @param logId the flagged screening log id
|
||||
* @param disposition FALSE_POSITIVE or CONFIRMED_MATCH
|
||||
* @param reason mandatory narrative of the identifier comparison
|
||||
*/
|
||||
SanctionsScreeningLog recordDisposition(Long logId, String disposition, String reason);
|
||||
|
||||
/**
|
||||
* Principal Officer sign-off. Approving a FALSE_POSITIVE is what actually unblocks
|
||||
* onboarding; approving a CONFIRMED_MATCH triggers the freeze of the client's accounts.
|
||||
*/
|
||||
SanctionsScreeningLog approveDisposition(Long logId);
|
||||
|
||||
/**
|
||||
* Records the FIU-IND STR reference and the date the nodal officer was informed, closing
|
||||
* out the 24-hour reporting obligation on a confirmed match.
|
||||
*/
|
||||
SanctionsScreeningLog recordReporting(Long logId, String strReference, String nodalOfficerInformedOn);
|
||||
|
||||
/**
|
||||
* True when this exact name carries a false positive that the Principal Officer approved —
|
||||
* the only condition under which a matched name may be onboarded.
|
||||
*/
|
||||
boolean hasApprovedFalsePositive(String name);
|
||||
|
||||
/**
|
||||
* Match check that does NOT write an activity log entry.
|
||||
*
|
||||
* <p>
|
||||
* Used by the server-side onboarding gate, which runs on every save attempt: logging there
|
||||
* would stack a fresh alert onto the queue each time the officer retries a blocked client,
|
||||
* burying the one real case under duplicates. The screening that belongs in the log is the
|
||||
* one the officer deliberately ran from the UI.
|
||||
* </p>
|
||||
*/
|
||||
boolean isNameSanctioned(String name);
|
||||
|
||||
/**
|
||||
* Re-screens every existing client against the currently loaded list snapshot, opening an
|
||||
* alert for each hit. This is what makes freezing reachable at all: without it a customer
|
||||
* added to the list after onboarding is never detected.
|
||||
*
|
||||
* @return number of clients that produced a new flagged alert
|
||||
*/
|
||||
int rescreenExistingClients();
|
||||
}
|
||||
|
||||
+169
@@ -18,10 +18,16 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.sanctions.service;
|
||||
|
||||
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>
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<!--
|
||||
Authorized signatories of a non-individual client are picked from existing clients, and each
|
||||
one carries its own PAN and DIN verification. Only their display names were ever stored, in
|
||||
authorized_signatory, so re-opening the client for edit could show who had been recorded but
|
||||
could not link back to them: the client ids, and with them the DIN verification trail behind
|
||||
each signatory, were lost the moment the form was closed.
|
||||
|
||||
Stored as a comma-separated list of client ids, ordered to match authorized_signatory, rather
|
||||
than as a child table. The signatory list is only ever read and written whole, alongside the
|
||||
rest of the non-person block, and pairing it with the existing name column keeps the two in
|
||||
step; a join table would need its own lifecycle for no gain here.
|
||||
-->
|
||||
<changeSet id="0463-non-person-authorized-signatory-client-ids" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_non_person" columnName="authorized_signatory_client_ids"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_non_person">
|
||||
<column name="authorized_signatory_client_ids" type="VARCHAR(500)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Sanctions alert disposition.
|
||||
|
||||
A FLAGGED screening is not an outcome, it is a case that must be closed. Under Section 51-A
|
||||
UAPA 1967 / Chapter IX of the RBI KYC Directions every positive hit has to end in either a
|
||||
documented FALSE_POSITIVE (different person, reasons recorded, approved by the Principal
|
||||
Officer) or a CONFIRMED_MATCH (escalated, reported to FIU-IND and the nodal officer, funds
|
||||
frozen). These columns carry that lifecycle on the existing screening log row.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet id="0464-sanctions-alert-disposition-columns" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_sanctions_screening_log" columnName="disposition"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_sanctions_screening_log">
|
||||
<!-- PENDING_REVIEW | FALSE_POSITIVE | CONFIRMED_MATCH. NULL for CLEAR screenings. -->
|
||||
<column name="disposition" type="VARCHAR(30)"/>
|
||||
<!-- Mandatory narrative: which identifiers were compared and why they do/don't match. -->
|
||||
<column name="disposition_reason" type="TEXT"/>
|
||||
<column name="reviewed_by" type="BIGINT"/>
|
||||
<column name="reviewed_on" type="DATETIME"/>
|
||||
<!-- Principal Officer. A false positive is only effective once this is set. -->
|
||||
<column name="approved_by" type="BIGINT"/>
|
||||
<column name="approved_on" type="DATETIME"/>
|
||||
<column name="fiu_str_reference" type="VARCHAR(100)"/>
|
||||
<column name="nodal_officer_informed_on" type="DATETIME"/>
|
||||
<!-- Comma-separated savings account ids frozen by this alert, for the audit trail. -->
|
||||
<column name="frozen_account_ids" type="VARCHAR(1000)"/>
|
||||
<column name="frozen_on" type="DATETIME"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!-- Backfill: every historical FLAGGED row is an unreviewed case, not a closed one. -->
|
||||
<changeSet id="0464-sanctions-alert-backfill-pending" author="Antigravity">
|
||||
<update tableName="m_sanctions_screening_log">
|
||||
<column name="disposition" value="PENDING_REVIEW"/>
|
||||
<where>screening_result = 'FLAGGED' AND disposition IS NULL</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0464-sanctions-alert-disposition-index" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_sanctions_log_disposition"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_sanctions_screening_log" indexName="idx_sanctions_log_disposition">
|
||||
<column name="disposition"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0464-sanctions-alert-permissions" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_permission WHERE code = 'DISPOSE_SANCTIONS'
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="DISPOSE_SANCTIONS"/>
|
||||
<column name="entity_name" value="SANCTIONS"/>
|
||||
<column name="action_name" value="DISPOSE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="APPROVE_SANCTIONS"/>
|
||||
<column name="entity_name" value="SANCTIONS"/>
|
||||
<column name="action_name" value="APPROVE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
DIP / MNRL lookup outcome tracking.
|
||||
|
||||
A SUSPECTED number is a DoT cyber-fraud signal, not a legal prohibition, so the officer may
|
||||
still proceed — but only with a recorded reason. These columns capture that decision on the
|
||||
lookup log, and record on the client which DIP status their number actually returned, so a
|
||||
number that was never really checked (DoT outage) is distinguishable from one that passed.
|
||||
-->
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
|
||||
|
||||
<changeSet id="0465-mobile-lookup-override-columns" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_mobile_lookup_log" columnName="override_decision"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_mobile_lookup_log">
|
||||
<!-- PROCEEDED | REJECTED. Null when the lookup needed no decision (CLEAN). -->
|
||||
<column name="override_decision" type="VARCHAR(20)"/>
|
||||
<!-- Mandatory narrative when proceeding despite a DIP hit. -->
|
||||
<column name="override_reason" type="TEXT"/>
|
||||
<column name="decided_by" type="BIGINT"/>
|
||||
<column name="decided_on" type="DATETIME"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0465-mobile-lookup-status-index" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><indexExists indexName="idx_mobile_lookup_status"/></not>
|
||||
</preConditions>
|
||||
<createIndex tableName="m_mobile_lookup_log" indexName="idx_mobile_lookup_status">
|
||||
<column name="lookup_status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
dip_verified alone cannot distinguish "checked and clean" from "never checked because the
|
||||
DoT service was down" — both were being stored as true. Record the actual status too.
|
||||
-->
|
||||
<changeSet id="0465-client-dip-status" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client" columnName="dip_status"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client">
|
||||
<column name="dip_status" type="VARCHAR(40)"/>
|
||||
<column name="alternate_dip_status" type="VARCHAR(40)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0465-mobile-lookup-permissions" author="Antigravity">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">
|
||||
SELECT COUNT(*) FROM m_permission WHERE code = 'OVERRIDE_MOBILE_LOOKUP'
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="portfolio"/>
|
||||
<column name="code" value="OVERRIDE_MOBILE_LOOKUP"/>
|
||||
<column name="entity_name" value="MOBILE_LOOKUP"/>
|
||||
<column name="action_name" value="OVERRIDE"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user