From 6d93cb25895e7834d93613047842f8722e4aca08 Mon Sep 17 00:00:00 2001 From: Anvay Kharb Date: Wed, 9 Sep 2026 15:38:41 +0530 Subject: [PATCH] WEB-1056: Add enrollment status support --- .../api/OnboardingCasesApiResource.java | 51 +++ .../onboarding/data/EnrollmentAgingData.java | 9 + .../onboarding/data/EnrollmentCaseData.java | 17 + .../data/EnrollmentCasesCriteria.java | 9 + .../data/EnrollmentCasesRequest.java | 37 ++ .../onboarding/data/EnrollmentStageData.java | 15 + .../onboarding/data/KycEvidenceData.java | 25 ++ .../EnrollmentStatusReadPlatformService.java | 16 + ...rollmentStatusReadPlatformServiceImpl.java | 274 ++++++++++++++ .../validation/EnrollmentCasesValidator.java | 68 ++++ .../savings/module-changelog-master.xml | 1 + .../053-add-enrollment-status-permission.xml | 38 ++ ...OnboardingCasesApiHttpIntegrationTest.java | 299 +++++++++++++++ .../api/OnboardingCasesApiResourceTest.java | 38 ++ ...mentStatusReadPlatformServiceImplTest.java | 358 ++++++++++++++++++ 15 files changed, 1255 insertions(+) create mode 100644 src/main/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResource.java create mode 100644 src/main/java/org/apache/fineract/onboarding/data/EnrollmentAgingData.java create mode 100644 src/main/java/org/apache/fineract/onboarding/data/EnrollmentCaseData.java create mode 100644 src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesCriteria.java create mode 100644 src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesRequest.java create mode 100644 src/main/java/org/apache/fineract/onboarding/data/EnrollmentStageData.java create mode 100644 src/main/java/org/apache/fineract/onboarding/data/KycEvidenceData.java create mode 100644 src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformService.java create mode 100644 src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImpl.java create mode 100644 src/main/java/org/apache/fineract/onboarding/validation/EnrollmentCasesValidator.java create mode 100644 src/main/resources/db/changelog/tenant/module/savings/parts/053-add-enrollment-status-permission.xml create mode 100644 src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiHttpIntegrationTest.java create mode 100644 src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResourceTest.java create mode 100644 src/test/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImplTest.java diff --git a/src/main/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResource.java b/src/main/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResource.java new file mode 100644 index 0000000..f679812 --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResource.java @@ -0,0 +1,51 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.onboarding.data.EnrollmentCaseData; +import org.apache.fineract.onboarding.data.EnrollmentCasesRequest; +import org.apache.fineract.onboarding.service.EnrollmentStatusReadPlatformService; +import org.springframework.stereotype.Component; + +/** Back-office read model for source-qualified enrollment dashboard data. */ +@Path("/v2/onboarding/cases") +@Component +@Tag(name = "Onboarding Cases", description = "Read enrollment status projections") +@RequiredArgsConstructor +public class OnboardingCasesApiResource { + + private static final String RESOURCE_NAME_FOR_PERMISSIONS = "CLIENT"; + private static final String ENROLLMENT_STATUS_RESOURCE_NAME = "ENROLLMENT_STATUS"; + + private final PlatformSecurityContext context; + private final EnrollmentStatusReadPlatformService readPlatformService; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "List enrollment status cases", + description = + "Returns office-scoped enrollment status projections derived from Fineract client" + + " lifecycle and savings-plugin KYC records. Unsupported stages are returned as" + + " UNKNOWN instead of inferred.") + public Page retrieveEnrollmentCases( + @BeanParam final EnrollmentCasesRequest request) { + context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + context.authenticatedUser().validateHasReadPermission(ENROLLMENT_STATUS_RESOURCE_NAME); + return readPlatformService.retrieveEnrollmentCases(request); + } +} diff --git a/src/main/java/org/apache/fineract/onboarding/data/EnrollmentAgingData.java b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentAgingData.java new file mode 100644 index 0000000..b9354bb --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentAgingData.java @@ -0,0 +1,9 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.data; + +public record EnrollmentAgingData(Integer days, String trafficLight, String source) {} diff --git a/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCaseData.java b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCaseData.java new file mode 100644 index 0000000..f53e47e --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCaseData.java @@ -0,0 +1,17 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.data; + +import java.util.List; + +public record EnrollmentCaseData( + Long clientId, + String clientName, + Long officeId, + String clientLifecycleStatus, + List enrollmentStages, + KycEvidenceData kycEvidence) {} diff --git a/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesCriteria.java b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesCriteria.java new file mode 100644 index 0000000..bcdc9ae --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesCriteria.java @@ -0,0 +1,9 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.data; + +public record EnrollmentCasesCriteria(String view, Long clientId, int limit, int offset) {} diff --git a/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesRequest.java b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesRequest.java new file mode 100644 index 0000000..064ef69 --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentCasesRequest.java @@ -0,0 +1,37 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.data; + +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class EnrollmentCasesRequest { + + @QueryParam("view") + @DefaultValue("enrollment") + @Parameter(description = "Only enrollment is currently supported") + private String view = "enrollment"; + + @QueryParam("clientId") + @Parameter(description = "Optional client id filter") + private Long clientId; + + @QueryParam("limit") + @DefaultValue("50") + @Parameter(description = "Maximum rows to return") + private Integer limit = 50; + + @QueryParam("offset") + @DefaultValue("0") + @Parameter(description = "Rows to skip") + private Integer offset = 0; +} diff --git a/src/main/java/org/apache/fineract/onboarding/data/EnrollmentStageData.java b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentStageData.java new file mode 100644 index 0000000..a8ce31f --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/data/EnrollmentStageData.java @@ -0,0 +1,15 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.data; + +public record EnrollmentStageData( + String name, + String status, + String startedAt, + String completedAt, + String source, + EnrollmentAgingData aging) {} diff --git a/src/main/java/org/apache/fineract/onboarding/data/KycEvidenceData.java b/src/main/java/org/apache/fineract/onboarding/data/KycEvidenceData.java new file mode 100644 index 0000000..b1c0efa --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/data/KycEvidenceData.java @@ -0,0 +1,25 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.data; + +public record KycEvidenceData( + Long verificationId, + String sessionId, + String verificationCreatedAt, + String verificationLastModifiedAt, + String providerDecisionStatus, + String providerDecisionCreatedAt, + String derivedKycStatus, + Boolean faceMatchEvidencePresent, + String faceMatchEvidenceStatus, + Boolean faceMatchEvidenceSucceeded, + Boolean idVerificationEvidencePresent, + String idVerificationEvidenceStatus, + Boolean idVerificationEvidenceSucceeded, + Boolean amlScreeningEvidencePresent, + String amlScreeningEvidenceStatus, + Boolean amlScreeningEvidenceSucceeded) {} diff --git a/src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformService.java b/src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformService.java new file mode 100644 index 0000000..3da5fe6 --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformService.java @@ -0,0 +1,16 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.service; + +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.onboarding.data.EnrollmentCaseData; +import org.apache.fineract.onboarding.data.EnrollmentCasesRequest; + +public interface EnrollmentStatusReadPlatformService { + + Page retrieveEnrollmentCases(EnrollmentCasesRequest request); +} diff --git a/src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImpl.java b/src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImpl.java new file mode 100644 index 0000000..6594b39 --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImpl.java @@ -0,0 +1,274 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.onboarding.data.EnrollmentCaseData; +import org.apache.fineract.onboarding.data.EnrollmentCasesCriteria; +import org.apache.fineract.onboarding.data.EnrollmentCasesRequest; +import org.apache.fineract.onboarding.data.EnrollmentStageData; +import org.apache.fineract.onboarding.data.KycEvidenceData; +import org.apache.fineract.onboarding.validation.EnrollmentCasesValidator; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.client.domain.ClientStatus; +import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EnrollmentStatusReadPlatformServiceImpl + implements EnrollmentStatusReadPlatformService { + + private static final String READ_PERMISSION_RESOURCE = "CLIENT"; + private static final String READ_ENROLLMENT_STATUS_PERMISSION_RESOURCE = "ENROLLMENT_STATUS"; + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + private final PlatformSecurityContext context; + private final DatabaseSpecificSQLGenerator sqlGenerator; + private final EnrollmentCasesValidator validator; + + @Override + public Page retrieveEnrollmentCases(final EnrollmentCasesRequest request) { + final AppUser currentUser = context.authenticatedUser(); + currentUser.validateHasReadPermission(READ_PERMISSION_RESOURCE); + currentUser.validateHasReadPermission(READ_ENROLLMENT_STATUS_PERMISSION_RESOURCE); + final EnrollmentCasesCriteria criteria = validator.validate(request); + + final Office userOffice = currentUser.getOffice(); + final Map parameters = new HashMap<>(); + parameters.put("officeHierarchy", userOffice.getHierarchy() + "%"); + if (criteria.clientId() != null) { + parameters.put("clientId", criteria.clientId()); + } + + final String fromAndWhere = buildFromAndWhere(criteria); + final Long count = + namedParameterJdbcTemplate.queryForObject( + "SELECT COUNT(DISTINCT c.id) " + fromAndWhere, parameters, Long.class); + if (count == null || count == 0) { + return new Page<>(List.of(), 0); + } + + final String sql = + selectColumns() + + fromAndWhere + + groupByColumns() + + " ORDER BY c.id DESC " + + sqlGenerator.limit(criteria.limit(), criteria.offset()); + final List pageItems = + namedParameterJdbcTemplate.query(sql, parameters, new EnrollmentCaseRowMapper()); + return new Page<>(pageItems, Math.toIntExact(count)); + } + + private String buildFromAndWhere(final EnrollmentCasesCriteria criteria) { + final StringBuilder sql = + new StringBuilder( + " FROM m_client c" + + " JOIN m_office o ON o.id = c.office_id" + + " LEFT JOIN m_office transfer_o ON transfer_o.id = c.transfer_to_office_id" + + " LEFT JOIN m_client_kyc_verification v ON v.client_id = c.id" + + " AND NOT EXISTS (" + + " SELECT 1 FROM m_client_kyc_verification newer" + + " WHERE newer.client_id = c.id" + + " AND (COALESCE(newer.last_modified_on_utc, newer.created_on_utc)" + + " > COALESCE(v.last_modified_on_utc, v.created_on_utc)" + + " OR (COALESCE(newer.last_modified_on_utc, newer.created_on_utc)" + + " = COALESCE(v.last_modified_on_utc, v.created_on_utc)" + + " AND newer.id > v.id)))" + + " LEFT JOIN m_client_kyc_decision d ON d.kyc_verification_id = v.id" + + " LEFT JOIN m_client_kyc_face_match fm ON fm.kyc_decision_id = d.id" + + " LEFT JOIN m_client_kyc_id_verification idv ON idv.kyc_decision_id = d.id" + + " LEFT JOIN m_client_kyc_aml_screening aml ON aml.kyc_decision_id = d.id" + + " WHERE (o.hierarchy LIKE :officeHierarchy" + + " OR transfer_o.hierarchy LIKE :officeHierarchy)"); + if (criteria.clientId() != null) { + sql.append(" AND c.id = :clientId"); + } + return sql.toString(); + } + + private String selectColumns() { + return "SELECT c.id AS client_id, c.display_name, c.office_id, c.status_enum," + + " c.submittedon_date, c.activation_date," + + " v.id AS verification_id, v.session_id, v.kyc_status AS derived_kyc_status," + + " v.created_on_utc AS verification_created_on," + + " v.last_modified_on_utc AS verification_last_modified_on," + + " d.decision_status, d.decision_created_at," + + " COUNT(DISTINCT fm.id) AS face_match_count," + + " COUNT(DISTINCT idv.id) AS id_verification_count," + + " COUNT(DISTINCT aml.id) AS aml_screening_count," + + " COUNT(DISTINCT CASE WHEN LOWER(fm.match_status) = 'approved' THEN fm.id ELSE NULL END)" + + " AS approved_face_match_count," + + " COUNT(DISTINCT CASE WHEN LOWER(idv.verification_status) = 'approved' THEN idv.id" + + " ELSE NULL END)" + + " AS approved_id_verification_count," + + " COUNT(DISTINCT CASE WHEN LOWER(aml.screening_status) = 'approved' THEN aml.id" + + " ELSE NULL END)" + + " AS approved_aml_screening_count"; + } + + private String groupByColumns() { + return " GROUP BY c.id, c.display_name, c.office_id, c.status_enum, c.submittedon_date," + + " c.activation_date, v.id, v.session_id, v.kyc_status, v.created_on_utc," + + " v.last_modified_on_utc, d.decision_status, d.decision_created_at"; + } + + static final class EnrollmentCaseRowMapper implements RowMapper { + + @Override + public EnrollmentCaseData mapRow(final ResultSet rs, final int rowNumber) throws SQLException { + final Long clientId = rs.getLong("client_id"); + final Integer statusEnum = rs.getInt("status_enum"); + final ClientStatus clientStatus = ClientStatus.fromInt(statusEnum); + final LocalDate submittedOnDate = localDate(rs, "submittedon_date"); + final LocalDate activationDate = localDate(rs, "activation_date"); + final KycEvidenceData kycEvidence = kycEvidence(rs); + + return new EnrollmentCaseData( + clientId, + rs.getString("display_name"), + rs.getLong("office_id"), + clientStatus.getCode(), + List.of( + commercialRegistrationStage(submittedOnDate), + unsupportedStage("BIOMETRICS_KYC_LEVEL_1", "MISSING_EXPLICIT_KYC_LEVEL_MAPPING"), + unsupportedStage("KYC_LEVEL_2", "MISSING_EXPLICIT_KYC_LEVEL_MAPPING"), + unsupportedStage("KYC_LEVEL_3", "MISSING_EXPLICIT_KYC_LEVEL_MAPPING"), + unsupportedStage("COMPLIANCE", "MISSING_EXPLICIT_COMPLIANCE_DECISION"), + activationStage(clientStatus, activationDate)), + kycEvidence); + } + + private static EnrollmentStageData commercialRegistrationStage( + final LocalDate submittedOnDate) { + final String submitted = submittedOnDate == null ? null : submittedOnDate.toString(); + return new EnrollmentStageData( + "COMMERCIAL_REGISTRATION", + "UNKNOWN", + submitted, + null, + "m_client.submittedon_date", + null); + } + + private static EnrollmentStageData activationStage( + final ClientStatus clientStatus, final LocalDate activationDate) { + final String completed = activationDate == null ? null : activationDate.toString(); + return new EnrollmentStageData( + "ACTIVATION", + activationStatus(clientStatus), + null, + completed, + "m_client.status_enum,m_client.activation_date", + null); + } + + private static EnrollmentStageData unsupportedStage(final String name, final String source) { + return new EnrollmentStageData(name, "UNKNOWN", null, null, source, null); + } + + private static String activationStatus(final ClientStatus clientStatus) { + if (clientStatus.isActive()) { + return "COMPLETED"; + } + if (clientStatus.isRejected()) { + return "REJECTED"; + } + if (clientStatus.isWithdrawn()) { + return "WITHDRAWN"; + } + if (clientStatus.isClosed()) { + return "CLOSED"; + } + return "PENDING"; + } + + private static KycEvidenceData kycEvidence(final ResultSet rs) throws SQLException { + final Long verificationId = nullableLong(rs, "verification_id"); + if (verificationId == null) { + return new KycEvidenceData( + null, + null, + null, + null, + null, + null, + null, + false, + "UNKNOWN", + false, + false, + "UNKNOWN", + false, + false, + "UNKNOWN", + false); + } + return new KycEvidenceData( + verificationId, + rs.getString("session_id"), + offsetDateTime(rs, "verification_created_on"), + offsetDateTime(rs, "verification_last_modified_on"), + rs.getString("decision_status"), + offsetDateTime(rs, "decision_created_at"), + rs.getString("derived_kyc_status"), + rs.getInt("face_match_count") > 0, + evidenceStatus(rs.getInt("face_match_count"), rs.getInt("approved_face_match_count")), + rs.getInt("approved_face_match_count") > 0, + rs.getInt("id_verification_count") > 0, + evidenceStatus( + rs.getInt("id_verification_count"), rs.getInt("approved_id_verification_count")), + rs.getInt("approved_id_verification_count") > 0, + rs.getInt("aml_screening_count") > 0, + evidenceStatus( + rs.getInt("aml_screening_count"), rs.getInt("approved_aml_screening_count")), + rs.getInt("approved_aml_screening_count") > 0); + } + + private static Long nullableLong(final ResultSet rs, final String column) throws SQLException { + final long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static String evidenceStatus(final int evidenceCount, final int approvedCount) { + if (evidenceCount == 0) { + return "UNKNOWN"; + } + return approvedCount > 0 ? "APPROVED" : "NOT_APPROVED"; + } + + private static LocalDate localDate(final ResultSet rs, final String column) + throws SQLException { + return rs.getDate(column) == null ? null : rs.getDate(column).toLocalDate(); + } + + private static String offsetDateTime(final ResultSet rs, final String column) + throws SQLException { + final Timestamp timestamp = rs.getTimestamp(column); + if (timestamp == null) { + return null; + } + return OffsetDateTime.ofInstant(timestamp.toInstant(), ZoneOffset.UTC).toString(); + } + } +} diff --git a/src/main/java/org/apache/fineract/onboarding/validation/EnrollmentCasesValidator.java b/src/main/java/org/apache/fineract/onboarding/validation/EnrollmentCasesValidator.java new file mode 100644 index 0000000..761b7cc --- /dev/null +++ b/src/main/java/org/apache/fineract/onboarding/validation/EnrollmentCasesValidator.java @@ -0,0 +1,68 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.validation; + +import java.util.ArrayList; +import java.util.List; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.onboarding.data.EnrollmentCasesCriteria; +import org.apache.fineract.onboarding.data.EnrollmentCasesRequest; +import org.springframework.stereotype.Component; + +@Component +public class EnrollmentCasesValidator { + + public static final int DEFAULT_LIMIT = 50; + public static final int MAX_LIMIT = 500; + private static final String SUPPORTED_VIEW = "enrollment"; + + public EnrollmentCasesCriteria validate(final EnrollmentCasesRequest request) { + if (request == null) { + return new EnrollmentCasesCriteria(SUPPORTED_VIEW, null, DEFAULT_LIMIT, 0); + } + final List errors = new ArrayList<>(); + final String view = + StringUtils.isBlank(request.getView()) ? SUPPORTED_VIEW : request.getView().trim(); + final int limit = request.getLimit() == null ? DEFAULT_LIMIT : request.getLimit(); + final int offset = request.getOffset() == null ? 0 : request.getOffset(); + + if (!SUPPORTED_VIEW.equalsIgnoreCase(view)) { + addError(errors, "view", view, "Only enrollment view is supported."); + } + if (request.getClientId() != null && request.getClientId() <= 0) { + addError(errors, "clientId", request.getClientId(), "Identifier must be greater than zero."); + } + if (limit < 1 || limit > MAX_LIMIT) { + addError(errors, "limit", limit, "Limit must be between 1 and " + MAX_LIMIT + "."); + } + if (offset < 0) { + addError(errors, "offset", offset, "Offset must be zero or greater."); + } + + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException(errors); + } + + return new EnrollmentCasesCriteria( + SUPPORTED_VIEW, request.getClientId(), limit, offset); + } + + private void addError( + final List errors, + final String parameter, + final Object value, + final String message) { + errors.add( + ApiParameterError.parameterError( + "validation.msg.enrollmentCases." + parameter + ".invalid", + message, + parameter, + value == null ? "" : value)); + } +} diff --git a/src/main/resources/db/changelog/tenant/module/savings/module-changelog-master.xml b/src/main/resources/db/changelog/tenant/module/savings/module-changelog-master.xml index 3bcb6d9..7f0e3f5 100644 --- a/src/main/resources/db/changelog/tenant/module/savings/module-changelog-master.xml +++ b/src/main/resources/db/changelog/tenant/module/savings/module-changelog-master.xml @@ -62,4 +62,5 @@ + diff --git a/src/main/resources/db/changelog/tenant/module/savings/parts/053-add-enrollment-status-permission.xml b/src/main/resources/db/changelog/tenant/module/savings/parts/053-add-enrollment-status-permission.xml new file mode 100644 index 0000000..14bed52 --- /dev/null +++ b/src/main/resources/db/changelog/tenant/module/savings/parts/053-add-enrollment-status-permission.xml @@ -0,0 +1,38 @@ + + + + + + Insert enrollment status read permission + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'READ_ENROLLMENT_STATUS', 'ENROLLMENT_STATUS', 'READ', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_ENROLLMENT_STATUS'); + + + + + Assign enrollment status read permission to administrators + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id + FROM m_role r, m_permission p + WHERE r.name = 'Super user' + AND p.code = 'READ_ENROLLMENT_STATUS' + AND NOT EXISTS ( + SELECT 1 FROM m_role_permission rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + + + + diff --git a/src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiHttpIntegrationTest.java b/src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiHttpIntegrationTest.java new file mode 100644 index 0000000..dced789 --- /dev/null +++ b/src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiHttpIntegrationTest.java @@ -0,0 +1,299 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +import io.restassured.http.ContentType; +import org.apache.fineract.testing.support.SavingsIntegrationTestBase; +import org.apache.fineract.testing.support.SavingsTestUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class OnboardingCasesApiHttpIntegrationTest extends SavingsIntegrationTestBase { + + private static final String ONBOARDING_CASES_PATH = + SavingsTestUtils.CONTEXT_PATH + "/api/v2/onboarding/cases"; + private static final long OFFICE_ID = 9_105_600L; + private static final long VISIBLE_CLIENT_ID = 9_105_601L; + private static final long ACTIVE_CLIENT_ID = 9_105_602L; + private static final long HIDDEN_CLIENT_ID = 9_105_603L; + private static final String NO_ENROLLMENT_PERMISSION_USERNAME = "web1056-client-only"; + private static final String SCOPED_USERNAME = "web1056-scoped"; + private static final String COMMERCIAL_REGISTRATION_STAGE = + "pageItems[0].enrollmentStages.find { it.name == 'COMMERCIAL_REGISTRATION' }"; + private static final String BIOMETRICS_LEVEL_1_STAGE = + "pageItems[0].enrollmentStages.find { it.name == 'BIOMETRICS_KYC_LEVEL_1' }"; + private static final String KYC_LEVEL_2_STAGE = + "pageItems[0].enrollmentStages.find { it.name == 'KYC_LEVEL_2' }"; + private static final String KYC_LEVEL_3_STAGE = + "pageItems[0].enrollmentStages.find { it.name == 'KYC_LEVEL_3' }"; + private static final String COMPLIANCE_STAGE = + "pageItems[0].enrollmentStages.find { it.name == 'COMPLIANCE' }"; + private static final String ACTIVATION_STAGE = + "pageItems[0].enrollmentStages.find { it.name == 'ACTIVATION' }"; + + @BeforeAll + static void seedEnrollmentStatusCasesAndUsers() { + executeSqlInPostgres( + """ + INSERT INTO m_office (id, parent_id, hierarchy, name, opening_date) + VALUES (%s, 1, %s, 'WEB-1056 Office', DATE '2026-09-01') + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_client ( + id, account_no, status_enum, office_id, firstname, lastname, display_name, + submittedon_date, activation_date, activatedon_userid, legal_form_enum, + created_on_utc, created_by, last_modified_on_utc, last_modified_by + ) VALUES + ( + %s, '9105601', 100, %s, 'Visible', 'Pending', 'Visible Pending', + DATE '2026-09-01', NULL, NULL, 1, + CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1 + ), + ( + %s, '9105602', 300, %s, 'Visible', 'Active', 'Visible Active', + DATE '2026-09-01', DATE '2026-09-06', 1, 1, + CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1 + ), + ( + %s, '9105603', 100, 1, 'Hidden', 'Root', 'Hidden Root', + DATE '2026-09-01', NULL, NULL, 1, + CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1 + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_client_kyc_verification ( + id, client_id, session_id, workflow_id, workflow_version, webhook_type, kyc_status, + kyc_timestamp, kyc_created_at, created_by, created_on_utc, last_modified_by, + last_modified_on_utc + ) VALUES + ( + 91056010, %s, 'web1056-old', 'workflow-a', 1, 'status.updated', 'Declined', + 1, 1, 1, TIMESTAMP '2026-09-01 00:00:00', 1, TIMESTAMP '2026-09-02 00:00:00' + ), + ( + 91056011, %s, 'web1056-latest', 'workflow-a', 1, 'status.updated', 'Approved', + 1, 1, 1, TIMESTAMP '2026-09-01 00:00:00', 1, TIMESTAMP '2026-09-03 00:00:00' + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_client_kyc_decision ( + id, kyc_verification_id, decision_status, decision_workflow_id, decision_created_at, + created_by, created_on_utc, last_modified_by, last_modified_on_utc + ) VALUES + ( + 91056011, 91056011, 'Approved', 'workflow-a', TIMESTAMP '2026-09-03 00:00:00', + 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_client_kyc_face_match ( + id, kyc_decision_id, node_id, match_score, match_status, created_by, created_on_utc, + last_modified_by, last_modified_on_utc + ) VALUES + ( + 91056011, 91056011, 'face-a', 99.00, 'Approved', + 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_client_kyc_id_verification ( + id, kyc_decision_id, node_id, verification_status, created_by, created_on_utc, + last_modified_by, last_modified_on_utc + ) VALUES + ( + 91056011, 91056011, 'id-a', 'Approved', + 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_client_kyc_aml_screening ( + id, kyc_decision_id, node_id, screening_status, total_hits, created_by, created_on_utc, + last_modified_by, last_modified_on_utc + ) VALUES + ( + 91056011, 91056011, 'aml-a', 'Approved', 0, + 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING; + + INSERT INTO m_role (name, description, is_disabled) + VALUES ('WEB-1056 client only', 'Enrollment status permission denial role', false) + ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description; + + INSERT INTO m_role (name, description, is_disabled) + VALUES ('WEB-1056 scoped enrollment', 'Enrollment status scoped role', false) + ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description; + + INSERT INTO m_appuser ( + office_id, username, firstname, lastname, password, email, + firsttime_login_remaining, nonexpired, nonlocked, nonexpired_credentials, + enabled, last_time_password_updated, password_never_expires + ) + SELECT 1, %s, 'WEB', 'ClientOnly', password, 'web1056-client-only@example.test', + false, true, true, true, true, CURRENT_DATE, true + FROM m_appuser WHERE username = 'mifos' + ON CONFLICT (username) DO UPDATE SET office_id = EXCLUDED.office_id; + + INSERT INTO m_appuser ( + office_id, username, firstname, lastname, password, email, + firsttime_login_remaining, nonexpired, nonlocked, nonexpired_credentials, + enabled, last_time_password_updated, password_never_expires + ) + SELECT %s, %s, 'WEB', 'Scoped', password, 'web1056-scoped@example.test', + false, true, true, true, true, CURRENT_DATE, true + FROM m_appuser WHERE username = 'mifos' + ON CONFLICT (username) DO UPDATE SET office_id = EXCLUDED.office_id; + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id FROM m_role r, m_permission p + WHERE r.name = 'WEB-1056 client only' AND p.code = 'READ_CLIENT' + ON CONFLICT DO NOTHING; + + INSERT INTO m_appuser_role (appuser_id, role_id) + SELECT u.id, r.id FROM m_appuser u, m_role r + WHERE u.username = %s AND r.name = 'WEB-1056 client only' + ON CONFLICT DO NOTHING; + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT r.id, p.id FROM m_role r, m_permission p + WHERE r.name = 'WEB-1056 scoped enrollment' + AND p.code IN ('READ_CLIENT', 'READ_ENROLLMENT_STATUS') + ON CONFLICT DO NOTHING; + + INSERT INTO m_appuser_role (appuser_id, role_id) + SELECT u.id, r.id FROM m_appuser u, m_role r + WHERE u.username = %s AND r.name = 'WEB-1056 scoped enrollment' + ON CONFLICT DO NOTHING; + """, + OFFICE_ID, + ".1." + OFFICE_ID + ".", + VISIBLE_CLIENT_ID, + OFFICE_ID, + ACTIVE_CLIENT_ID, + OFFICE_ID, + HIDDEN_CLIENT_ID, + VISIBLE_CLIENT_ID, + VISIBLE_CLIENT_ID, + NO_ENROLLMENT_PERMISSION_USERNAME, + OFFICE_ID, + SCOPED_USERNAME, + NO_ENROLLMENT_PERMISSION_USERNAME, + SCOPED_USERNAME); + } + + @Test + void registeredEndpointReturnsSuccessfulEnrollmentJsonFromPostgresSchema() { + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), "mifos", "password")) + .queryParam("clientId", VISIBLE_CLIENT_ID) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("totalFilteredRecords", equalTo(1)) + .body("pageItems.size()", equalTo(1)) + .body("pageItems[0].clientId", equalTo((int) VISIBLE_CLIENT_ID)) + .body("pageItems[0].kycEvidence.sessionId", equalTo("web1056-latest")) + .body("pageItems[0].kycEvidence.providerDecisionStatus", equalTo("Approved")) + .body("pageItems[0].kycEvidence.faceMatchEvidenceSucceeded", equalTo(true)) + .body("pageItems[0].kycEvidence.idVerificationEvidenceSucceeded", equalTo(true)) + .body("pageItems[0].kycEvidence.amlScreeningEvidenceSucceeded", equalTo(true)) + .body(COMMERCIAL_REGISTRATION_STAGE + ".status", equalTo("UNKNOWN")) + .body(COMMERCIAL_REGISTRATION_STAGE + ".startedAt", equalTo("2026-09-01")) + .body(BIOMETRICS_LEVEL_1_STAGE + ".status", equalTo("UNKNOWN")) + .body(KYC_LEVEL_2_STAGE + ".status", equalTo("UNKNOWN")) + .body(KYC_LEVEL_3_STAGE + ".status", equalTo("UNKNOWN")) + .body(COMPLIANCE_STAGE + ".status", equalTo("UNKNOWN")) + .body("pageItems[0].enrollmentStages.aging", everyItem(equalTo(null))); + } + + @Test + void clientIdFilteringCanReturnEmptyResult() { + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), "mifos", "password")) + .queryParam("clientId", 9_105_699L) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("totalFilteredRecords", equalTo(0)) + .body("pageItems.size()", equalTo(0)); + } + + @Test + void officeScopingDoesNotLeakRootOfficeCaseToScopedChildOfficeUser() { + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), SCOPED_USERNAME, "password")) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("pageItems.clientId", hasItems((int) VISIBLE_CLIENT_ID, (int) ACTIVE_CLIENT_ID)) + .body("pageItems.clientId", not(hasItems((int) HIDDEN_CLIENT_ID))); + } + + @Test + void userWithoutEnrollmentStatusPermissionIsForbidden() { + given( + SavingsTestUtils.requestSpecWithAuth( + getFineractPort(), NO_ENROLLMENT_PERMISSION_USERNAME, "password")) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(403); + } + + @Test + void unsupportedViewAndInvalidPagingUseValidationResponse() { + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), "mifos", "password")) + .queryParam("view", "workflow") + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("errors[0].parameterName", equalTo("view")); + + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), "mifos", "password")) + .queryParam("limit", 0) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("errors[0].parameterName", equalTo("limit")); + + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), "mifos", "password")) + .queryParam("offset", -1) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("errors[0].parameterName", equalTo("offset")); + } + + @Test + void activationSourceComesFromFineractClientLifecycle() { + given(SavingsTestUtils.requestSpecWithAuth(getFineractPort(), "mifos", "password")) + .queryParam("clientId", ACTIVE_CLIENT_ID) + .when() + .get(ONBOARDING_CASES_PATH) + .then() + .statusCode(200) + .body(ACTIVATION_STAGE + ".status", equalTo("COMPLETED")) + .body(ACTIVATION_STAGE + ".completedAt", equalTo("2026-09-06")) + .body(ACTIVATION_STAGE + ".source", notNullValue()); + } +} diff --git a/src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResourceTest.java b/src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResourceTest.java new file mode 100644 index 0000000..01341b6 --- /dev/null +++ b/src/test/java/org/apache/fineract/onboarding/api/OnboardingCasesApiResourceTest.java @@ -0,0 +1,38 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.api; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.onboarding.data.EnrollmentCasesRequest; +import org.apache.fineract.onboarding.service.EnrollmentStatusReadPlatformService; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.Test; + +class OnboardingCasesApiResourceTest { + + @Test + void retrieveEnrollmentCasesRequiresReadClientAndEnrollmentStatusPermissionsAndDelegates() { + final PlatformSecurityContext context = mock(PlatformSecurityContext.class); + final AppUser user = mock(AppUser.class); + final EnrollmentStatusReadPlatformService service = + mock(EnrollmentStatusReadPlatformService.class); + final EnrollmentCasesRequest request = new EnrollmentCasesRequest(); + when(context.authenticatedUser()).thenReturn(user); + when(service.retrieveEnrollmentCases(request)).thenReturn(new Page<>(java.util.List.of(), 0)); + + new OnboardingCasesApiResource(context, service).retrieveEnrollmentCases(request); + + verify(user).validateHasReadPermission("CLIENT"); + verify(user).validateHasReadPermission("ENROLLMENT_STATUS"); + verify(service).retrieveEnrollmentCases(request); + } +} diff --git a/src/test/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImplTest.java b/src/test/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImplTest.java new file mode 100644 index 0000000..d4b319a --- /dev/null +++ b/src/test/java/org/apache/fineract/onboarding/service/EnrollmentStatusReadPlatformServiceImplTest.java @@ -0,0 +1,358 @@ +/** + * Copyright since 2026 Mifos Initiative + * + *

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy + * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.onboarding.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.onboarding.data.EnrollmentAgingData; +import org.apache.fineract.onboarding.data.EnrollmentCaseData; +import org.apache.fineract.onboarding.data.EnrollmentCasesRequest; +import org.apache.fineract.onboarding.data.EnrollmentStageData; +import org.apache.fineract.onboarding.validation.EnrollmentCasesValidator; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; + +class EnrollmentStatusReadPlatformServiceImplTest { + + private NamedParameterJdbcTemplate jdbcTemplate; + private AppUser user; + private EnrollmentStatusReadPlatformServiceImpl service; + + @BeforeEach + void setUp() { + jdbcTemplate = mock(NamedParameterJdbcTemplate.class); + final PlatformSecurityContext context = mock(PlatformSecurityContext.class); + user = mock(AppUser.class); + final Office office = mock(Office.class); + final DatabaseSpecificSQLGenerator sqlGenerator = mock(DatabaseSpecificSQLGenerator.class); + when(context.authenticatedUser()).thenReturn(user); + when(user.getOffice()).thenReturn(office); + when(office.getHierarchy()).thenReturn(".1."); + when(sqlGenerator.limit(any(Integer.class), any(Integer.class))) + .thenAnswer( + invocation -> + "LIMIT " + invocation.getArgument(0) + " OFFSET " + invocation.getArgument(1)); + service = + new EnrollmentStatusReadPlatformServiceImpl( + jdbcTemplate, context, sqlGenerator, new EnrollmentCasesValidator()); + } + + @Test + void emptyResultIsOfficeScopedAndDoesNotRunPageQuery() { + when(jdbcTemplate.queryForObject(any(String.class), anyMap(), eq(Long.class))).thenReturn(0L); + + final Page result = + service.retrieveEnrollmentCases(new EnrollmentCasesRequest()); + + assertEquals(0, result.getTotalFilteredRecords()); + assertTrue(result.getPageItems().isEmpty()); + verify(user).validateHasReadPermission("CLIENT"); + verify(user).validateHasReadPermission("ENROLLMENT_STATUS"); + final ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor> parameters = ArgumentCaptor.forClass(Map.class); + verify(jdbcTemplate).queryForObject(sql.capture(), parameters.capture(), eq(Long.class)); + assertTrue(sql.getValue().contains("o.hierarchy LIKE :officeHierarchy")); + assertTrue(sql.getValue().contains("transfer_o.hierarchy LIKE :officeHierarchy")); + assertEquals(".1.%", parameters.getValue().get("officeHierarchy")); + verify(jdbcTemplate, never()).query(any(String.class), anyMap(), any(RowMapper.class)); + } + + @Test + void clientFilterAndLatestVerificationSubqueryAreApplied() { + final EnrollmentCasesRequest request = new EnrollmentCasesRequest(); + request.setClientId(11L); + when(jdbcTemplate.queryForObject(any(String.class), anyMap(), eq(Long.class))).thenReturn(1L); + when(jdbcTemplate.query(any(String.class), anyMap(), any(RowMapper.class))) + .thenReturn(List.of()); + + service.retrieveEnrollmentCases(request); + + final ArgumentCaptor dataSql = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor> parameters = ArgumentCaptor.forClass(Map.class); + verify(jdbcTemplate).query(dataSql.capture(), parameters.capture(), any(RowMapper.class)); + assertTrue( + dataSql + .getValue() + .contains( + "COALESCE(newer.last_modified_on_utc, newer.created_on_utc)" + + " > COALESCE(v.last_modified_on_utc, v.created_on_utc)")); + assertTrue(dataSql.getValue().contains("AND newer.id > v.id")); + assertTrue(dataSql.getValue().contains("AND c.id = :clientId")); + assertEquals(11L, parameters.getValue().get("clientId")); + } + + @Test + void enrollmentStatusPermissionFailureStopsBeforeQueryingRows() { + doThrow(new SecurityException("denied")) + .when(user) + .validateHasReadPermission("ENROLLMENT_STATUS"); + + assertThrows( + SecurityException.class, + () -> service.retrieveEnrollmentCases(new EnrollmentCasesRequest())); + + verify(jdbcTemplate, never()).queryForObject(any(String.class), anyMap(), eq(Long.class)); + } + + @Test + void permissionFailureStopsBeforeQueryingRows() { + doThrow(new SecurityException("denied")).when(user).validateHasReadPermission("CLIENT"); + + assertThrows( + SecurityException.class, + () -> service.retrieveEnrollmentCases(new EnrollmentCasesRequest())); + + verify(jdbcTemplate, never()).queryForObject(any(String.class), anyMap(), eq(Long.class)); + } + + @Test + void unsupportedViewIsRejectedBeforeQueryingRows() { + final EnrollmentCasesRequest request = new EnrollmentCasesRequest(); + request.setView("workflow"); + + assertThrows( + org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException.class, + () -> service.retrieveEnrollmentCases(request)); + + verify(jdbcTemplate, never()).queryForObject(any(String.class), anyMap(), eq(Long.class)); + } + + @Test + void invalidPagingIsRejectedBeforeQueryingRows() { + final EnrollmentCasesRequest request = new EnrollmentCasesRequest(); + request.setLimit(0); + + assertThrows( + org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException.class, + () -> service.retrieveEnrollmentCases(request)); + + request.setLimit(50); + request.setOffset(-1); + assertThrows( + org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException.class, + () -> service.retrieveEnrollmentCases(request)); + verify(jdbcTemplate, never()).queryForObject(any(String.class), anyMap(), eq(Long.class)); + } + + @Test + void emptyNoKycClientReturnsLifecycleAndUnknownUnsupportedStages() throws Exception { + final ResultSet rs = clientRow(11L, 100, LocalDate.parse("2026-09-01"), null); + + final EnrollmentCaseData result = + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper().mapRow(rs, 0); + + assertEquals(11L, result.clientId()); + assertEquals("clientStatusType.pending", result.clientLifecycleStatus()); + assertStage(result, "COMMERCIAL_REGISTRATION", "UNKNOWN", "2026-09-01"); + assertStage(result, "BIOMETRICS_KYC_LEVEL_1", "UNKNOWN", null); + assertStage(result, "KYC_LEVEL_2", "UNKNOWN", null); + assertStage(result, "KYC_LEVEL_3", "UNKNOWN", null); + assertStage(result, "COMPLIANCE", "UNKNOWN", null); + assertStage(result, "ACTIVATION", "PENDING", null); + assertNull(result.kycEvidence().verificationId()); + assertFalse(result.kycEvidence().faceMatchEvidencePresent()); + } + + @Test + void successfulKycEvidenceIsSeparatedFromUnknownKycLevels() throws Exception { + final ResultSet rs = clientRow(11L, 100, LocalDate.parse("2026-09-01"), null); + withKyc(rs, 101L, "kyc-a", "Approved", "Approved", 1, 1, 1); + + final EnrollmentCaseData result = + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper().mapRow(rs, 0); + + assertEquals(101L, result.kycEvidence().verificationId()); + assertEquals("Approved", result.kycEvidence().providerDecisionStatus()); + assertEquals("Approved", result.kycEvidence().derivedKycStatus()); + assertTrue(result.kycEvidence().faceMatchEvidencePresent()); + assertEquals("APPROVED", result.kycEvidence().faceMatchEvidenceStatus()); + assertTrue(result.kycEvidence().faceMatchEvidenceSucceeded()); + assertEquals("APPROVED", result.kycEvidence().idVerificationEvidenceStatus()); + assertTrue(result.kycEvidence().idVerificationEvidenceSucceeded()); + assertEquals("APPROVED", result.kycEvidence().amlScreeningEvidenceStatus()); + assertTrue(result.kycEvidence().amlScreeningEvidenceSucceeded()); + assertStage(result, "BIOMETRICS_KYC_LEVEL_1", "UNKNOWN", null); + } + + @Test + void evidencePresentButFailedIsNotTreatedAsSucceeded() throws Exception { + final ResultSet rs = clientRow(11L, 100, LocalDate.parse("2026-09-01"), null); + withKyc(rs, 102L, "kyc-failed", "Declined", "Declined", 0, 0, 0); + + final EnrollmentCaseData result = + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper().mapRow(rs, 0); + + assertTrue(result.kycEvidence().faceMatchEvidencePresent()); + assertFalse(result.kycEvidence().faceMatchEvidenceSucceeded()); + assertEquals("NOT_APPROVED", result.kycEvidence().faceMatchEvidenceStatus()); + assertTrue(result.kycEvidence().idVerificationEvidencePresent()); + assertFalse(result.kycEvidence().idVerificationEvidenceSucceeded()); + assertEquals("NOT_APPROVED", result.kycEvidence().idVerificationEvidenceStatus()); + assertTrue(result.kycEvidence().amlScreeningEvidencePresent()); + assertFalse(result.kycEvidence().amlScreeningEvidenceSucceeded()); + assertEquals("NOT_APPROVED", result.kycEvidence().amlScreeningEvidenceStatus()); + assertEquals("Declined", result.kycEvidence().providerDecisionStatus()); + } + + @Test + void conflictingProviderAndDerivedStatusAreBothReturned() throws Exception { + final ResultSet rs = clientRow(11L, 100, LocalDate.parse("2026-09-01"), null); + withKyc(rs, 103L, "kyc-conflict", "Declined", "Approved", 1, 1, 1); + + final EnrollmentCaseData result = + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper().mapRow(rs, 0); + + assertEquals("Declined", result.kycEvidence().providerDecisionStatus()); + assertEquals("Approved", result.kycEvidence().derivedKycStatus()); + } + + @Test + void activationUsesClientLifecycleOnly() throws Exception { + final ResultSet rs = + clientRow(12L, 300, LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-06")); + + final EnrollmentCaseData result = + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper().mapRow(rs, 0); + + assertEquals("clientStatusType.active", result.clientLifecycleStatus()); + final EnrollmentStageData activation = stage(result, "ACTIVATION"); + assertEquals("COMPLETED", activation.status()); + assertEquals("2026-09-06", activation.completedAt()); + assertEquals("m_client.status_enum,m_client.activation_date", activation.source()); + } + + @Test + void activationKeepsRejectedWithdrawnAndClosedClientStates() throws Exception { + assertEquals( + "REJECTED", + stage( + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper() + .mapRow(clientRow(13L, 700, LocalDate.parse("2026-09-01"), null), 0), + "ACTIVATION") + .status()); + assertEquals( + "WITHDRAWN", + stage( + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper() + .mapRow(clientRow(14L, 800, LocalDate.parse("2026-09-01"), null), 0), + "ACTIVATION") + .status()); + assertEquals( + "CLOSED", + stage( + new EnrollmentStatusReadPlatformServiceImpl.EnrollmentCaseRowMapper() + .mapRow(clientRow(15L, 600, LocalDate.parse("2026-09-01"), null), 0), + "ACTIVATION") + .status()); + } + + @Test + void agingDataRecordKeepsSourceQualifiedTrafficLight() { + final EnrollmentAgingData aging = + new EnrollmentAgingData(5, "Yellow", "m_client.submittedon_date"); + + assertEquals(5, aging.days()); + assertEquals("Yellow", aging.trafficLight()); + assertEquals("m_client.submittedon_date", aging.source()); + } + + private static ResultSet clientRow( + final Long clientId, + final Integer statusEnum, + final LocalDate submittedOnDate, + final LocalDate activationDate) + throws Exception { + final ResultSet rs = mock(ResultSet.class); + when(rs.getLong("client_id")).thenReturn(clientId); + when(rs.getLong("office_id")).thenReturn(2L); + when(rs.getInt("status_enum")).thenReturn(statusEnum); + when(rs.getString("display_name")).thenReturn("Enrollment Client"); + when(rs.getDate("submittedon_date")).thenReturn(Date.valueOf(submittedOnDate)); + when(rs.getDate("activation_date")) + .thenReturn(activationDate == null ? null : Date.valueOf(activationDate)); + when(rs.getLong("verification_id")).thenReturn(0L); + when(rs.getInt("face_match_count")).thenReturn(0); + when(rs.getInt("id_verification_count")).thenReturn(0); + when(rs.getInt("aml_screening_count")).thenReturn(0); + when(rs.wasNull()).thenReturn(true); + return rs; + } + + private static void withKyc( + final ResultSet rs, + final Long verificationId, + final String sessionId, + final String decisionStatus, + final String derivedStatus, + final Integer approvedFaceMatches, + final Integer approvedIdVerifications, + final Integer approvedAmlScreenings) + throws Exception { + when(rs.getLong("verification_id")).thenReturn(verificationId); + when(rs.wasNull()).thenReturn(false); + when(rs.getString("session_id")).thenReturn(sessionId); + when(rs.getString("decision_status")).thenReturn(decisionStatus); + when(rs.getString("derived_kyc_status")).thenReturn(derivedStatus); + when(rs.getTimestamp("verification_created_on")) + .thenReturn(Timestamp.from(Instant.parse("2026-09-01T00:00:00Z"))); + when(rs.getTimestamp("verification_last_modified_on")) + .thenReturn(Timestamp.from(Instant.parse("2026-09-02T00:00:00Z"))); + when(rs.getTimestamp("decision_created_at")) + .thenReturn(Timestamp.from(Instant.parse("2026-09-02T00:00:00Z"))); + when(rs.getInt("face_match_count")).thenReturn(1); + when(rs.getInt("id_verification_count")).thenReturn(1); + when(rs.getInt("aml_screening_count")).thenReturn(1); + when(rs.getInt("approved_face_match_count")).thenReturn(approvedFaceMatches); + when(rs.getInt("approved_id_verification_count")).thenReturn(approvedIdVerifications); + when(rs.getInt("approved_aml_screening_count")).thenReturn(approvedAmlScreenings); + } + + private static EnrollmentStageData stage(final EnrollmentCaseData data, final String name) { + return data.enrollmentStages().stream() + .filter(stage -> name.equals(stage.name())) + .findFirst() + .orElseThrow(); + } + + private static void assertStage( + final EnrollmentCaseData data, + final String name, + final String status, + final String startedAt) { + final EnrollmentStageData stage = stage(data, name); + assertEquals(status, stage.status()); + assertEquals(startedAt, stage.startedAt()); + assertNull(stage.aging()); + } +}